mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
Relax CORS preflight handling across interfaces
This commit is contained in:
@@ -31,7 +31,7 @@ Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Depl
|
||||
| Base URL | `http://localhost:5001` or your deployment domain |
|
||||
| Default Content-Type | `application/json` |
|
||||
| Health probes | `GET /healthz`, `GET /readyz` |
|
||||
| CORS | Enabled (`Access-Control-Allow-Origin: *`, allows `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`) |
|
||||
| CORS | Enabled (uniformly covers `/v1/*`, `/anthropic/*`, `/v1beta/models/*`, and `/admin/*`; echoes the browser `Origin` when present, otherwise `*`; default allow-list includes `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`, `X-Goog-Api-Key`, `Anthropic-Version`, `Anthropic-Beta`, and also accepts third-party preflight-requested headers such as `x-stainless-*`; `/v1/chat/completions` on Vercel Node Runtime matches the same behavior; internal-only `X-Ds2-Internal-Token` remains blocked) |
|
||||
|
||||
### 3.0 Adapter-Layer Notes
|
||||
|
||||
|
||||
2
API.md
2
API.md
@@ -31,7 +31,7 @@
|
||||
| Base URL | `http://localhost:5001` 或你的部署域名 |
|
||||
| 默认 Content-Type | `application/json` |
|
||||
| 健康检查 | `GET /healthz`、`GET /readyz` |
|
||||
| CORS | 已启用(`Access-Control-Allow-Origin: *`,允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`) |
|
||||
| CORS | 已启用(统一覆盖 `/v1/*`、`/anthropic/*`、`/v1beta/models/*`、`/admin/*`;浏览器有 `Origin` 时回显该 Origin,否则为 `*`;默认允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`, `X-Goog-Api-Key`, `Anthropic-Version`, `Anthropic-Beta`,并会放行预检里声明的第三方请求头,如 `x-stainless-*`;Vercel 上 `/v1/chat/completions` 的 Node Runtime 也对齐相同行为;内部专用头 `X-Ds2-Internal-Token` 仍被拦截) |
|
||||
|
||||
### 3.0 接口适配层说明
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ flowchart LR
|
||||
| OpenAI 兼容 | `GET /v1/models`、`GET /v1/models/{id}`、`POST /v1/chat/completions`、`POST /v1/responses`、`GET /v1/responses/{response_id}`、`POST /v1/embeddings`、`POST /v1/files` |
|
||||
| Claude 兼容 | `GET /anthropic/v1/models`、`POST /anthropic/v1/messages`、`POST /anthropic/v1/messages/count_tokens`(及快捷路径 `/v1/messages`、`/messages`) |
|
||||
| Gemini 兼容 | `POST /v1beta/models/{model}:generateContent`、`POST /v1beta/models/{model}:streamGenerateContent`(及 `/v1/models/{model}:*` 路径) |
|
||||
| 统一 CORS 兼容 | `/v1/*`、`/anthropic/*`、`/v1beta/models/*`、`/admin/*` 统一走同一套 CORS 策略;Vercel 上 `/v1/chat/completions` 的 Node Runtime 也对齐相同放行规则,尽量减少第三方预检请求头限制 |
|
||||
| 多账号轮询 | 自动 token 刷新、邮箱/手机号双登录方式 |
|
||||
| 并发队列控制 | 每账号 in-flight 上限 + 等待队列,动态计算建议并发值 |
|
||||
| DeepSeek PoW | 纯 Go 高性能实现(DeepSeekHashV1),毫秒级响应 |
|
||||
@@ -233,7 +234,7 @@ cp config.example.json config.json
|
||||
base64 < config.json | tr -d '\n'
|
||||
```
|
||||
|
||||
> **流式说明**:`/v1/chat/completions` 在 Vercel 上默认走 `api/chat-stream.js`(Node Runtime)以保证实时 SSE。鉴权、账号选择、会话/PoW 准备仍由 Go 内部 prepare 接口完成;流式响应(含 `tools`)在 Node 侧执行与 Go 对齐的输出组装与防泄漏处理。
|
||||
> **流式说明**:`/v1/chat/completions` 在 Vercel 上默认走 `api/chat-stream.js`(Node Runtime)以保证实时 SSE。鉴权、账号选择、会话/PoW 准备仍由 Go 内部 prepare 接口完成;流式响应(含 `tools`)在 Node 侧执行与 Go 对齐的输出组装与防泄漏处理。虽然这里只有 OpenAI chat 流式走 Node,但 CORS 放行策略仍与 Go 主路由保持一致,统一覆盖第三方客户端预检场景。
|
||||
|
||||
详细部署说明请参阅 [部署指南](docs/DEPLOY.md)。
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ For the full module-by-module architecture and directory responsibilities, see [
|
||||
| OpenAI compatible | `GET /v1/models`, `GET /v1/models/{id}`, `POST /v1/chat/completions`, `POST /v1/responses`, `GET /v1/responses/{response_id}`, `POST /v1/embeddings`, `POST /v1/files` |
|
||||
| Claude compatible | `GET /anthropic/v1/models`, `POST /anthropic/v1/messages`, `POST /anthropic/v1/messages/count_tokens` (plus shortcut paths `/v1/messages`, `/messages`) |
|
||||
| Gemini compatible | `POST /v1beta/models/{model}:generateContent`, `POST /v1beta/models/{model}:streamGenerateContent` (plus `/v1/models/{model}:*` paths) |
|
||||
| Unified CORS compatibility | `/v1/*`, `/anthropic/*`, `/v1beta/models/*`, and `/admin/*` share one CORS policy; on Vercel, the Node Runtime for `/v1/chat/completions` mirrors the same relaxed preflight behavior for third-party clients |
|
||||
| Multi-account rotation | Auto token refresh, email/mobile dual login |
|
||||
| Concurrency control | Per-account in-flight limit + waiting queue, dynamic recommended concurrency |
|
||||
| DeepSeek PoW | Pure Go high-performance solver (DeepSeekHashV1), ms-level response |
|
||||
@@ -231,7 +232,7 @@ Recommended: convert `config.json` to Base64 locally, then paste into `DS2API_CO
|
||||
base64 < config.json | tr -d '\n'
|
||||
```
|
||||
|
||||
> **Streaming note**: `/v1/chat/completions` on Vercel is routed to `api/chat-stream.js` (Node Runtime) for real-time SSE. Auth, account selection, and session/PoW preparation are still handled by the Go internal prepare endpoint; streaming output (including `tools`) is assembled on Node with Go-aligned anti-leak handling.
|
||||
> **Streaming note**: `/v1/chat/completions` on Vercel is routed to `api/chat-stream.js` (Node Runtime) for real-time SSE. Auth, account selection, and session/PoW preparation are still handled by the Go internal prepare endpoint; streaming output (including `tools`) is assembled on Node with Go-aligned anti-leak handling. This is the only interface family currently routed through Node, and its CORS allow behavior is kept aligned with the Go router so third-party preflight handling stays unified.
|
||||
|
||||
For detailed deployment instructions, see the [Deployment Guide](docs/DEPLOY.en.md).
|
||||
|
||||
|
||||
134
internal/js/chat-stream/cors.js
Normal file
134
internal/js/chat-stream/cors.js
Normal file
@@ -0,0 +1,134 @@
|
||||
'use strict';
|
||||
|
||||
const DEFAULT_CORS_ALLOW_HEADERS = [
|
||||
'Content-Type',
|
||||
'Authorization',
|
||||
'X-API-Key',
|
||||
'X-Ds2-Target-Account',
|
||||
'X-Ds2-Source',
|
||||
'X-Vercel-Protection-Bypass',
|
||||
'X-Goog-Api-Key',
|
||||
'Anthropic-Version',
|
||||
'Anthropic-Beta',
|
||||
];
|
||||
|
||||
const BLOCKED_CORS_REQUEST_HEADERS = new Set([
|
||||
'x-ds2-internal-token',
|
||||
]);
|
||||
|
||||
function setCorsHeaders(res, req) {
|
||||
const origin = asString(readHeader(req, 'origin'));
|
||||
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
|
||||
res.setHeader('Access-Control-Max-Age', '600');
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
buildCORSAllowHeaders(req),
|
||||
);
|
||||
addVaryHeader(res, 'Origin');
|
||||
addVaryHeader(res, 'Access-Control-Request-Headers');
|
||||
if (asString(readHeader(req, 'access-control-request-private-network')).toLowerCase() === 'true') {
|
||||
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
||||
addVaryHeader(res, 'Access-Control-Request-Private-Network');
|
||||
}
|
||||
}
|
||||
|
||||
function buildCORSAllowHeaders(req) {
|
||||
const seen = new Set();
|
||||
const headers = [];
|
||||
for (const name of DEFAULT_CORS_ALLOW_HEADERS) {
|
||||
appendCORSHeaderName(headers, seen, name);
|
||||
}
|
||||
for (const name of splitCORSRequestHeaders(readHeader(req, 'access-control-request-headers'))) {
|
||||
appendCORSHeaderName(headers, seen, name);
|
||||
}
|
||||
return headers.join(', ');
|
||||
}
|
||||
|
||||
function splitCORSRequestHeaders(raw) {
|
||||
const text = asString(raw);
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
return text
|
||||
.split(',')
|
||||
.map((part) => asString(part))
|
||||
.filter((name) => isValidCORSHeaderToken(name))
|
||||
.filter((name) => !BLOCKED_CORS_REQUEST_HEADERS.has(name.toLowerCase()));
|
||||
}
|
||||
|
||||
function appendCORSHeaderName(headers, seen, name) {
|
||||
const text = asString(name);
|
||||
if (!isValidCORSHeaderToken(text)) {
|
||||
return;
|
||||
}
|
||||
const lower = text.toLowerCase();
|
||||
if (BLOCKED_CORS_REQUEST_HEADERS.has(lower) || seen.has(lower)) {
|
||||
return;
|
||||
}
|
||||
seen.add(lower);
|
||||
headers.push(text);
|
||||
}
|
||||
|
||||
function isValidCORSHeaderToken(name) {
|
||||
return /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(asString(name));
|
||||
}
|
||||
|
||||
function addVaryHeader(res, token) {
|
||||
const text = asString(token);
|
||||
if (!text || typeof res.setHeader !== 'function') {
|
||||
return;
|
||||
}
|
||||
const current = typeof res.getHeader === 'function' ? res.getHeader('Vary') : '';
|
||||
const seen = new Set();
|
||||
const merged = [];
|
||||
const addToken = (value) => {
|
||||
const trimmed = asString(value);
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (seen.has(lower)) {
|
||||
return;
|
||||
}
|
||||
seen.add(lower);
|
||||
merged.push(trimmed);
|
||||
};
|
||||
if (Array.isArray(current)) {
|
||||
for (const value of current) {
|
||||
for (const part of String(value).split(',')) {
|
||||
addToken(part);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const part of String(current || '').split(',')) {
|
||||
addToken(part);
|
||||
}
|
||||
}
|
||||
addToken(text);
|
||||
res.setHeader('Vary', merged.join(', '));
|
||||
}
|
||||
|
||||
function readHeader(req, key) {
|
||||
if (!req || !req.headers) {
|
||||
return '';
|
||||
}
|
||||
return req.headers[String(key).toLowerCase()];
|
||||
}
|
||||
|
||||
function asString(v) {
|
||||
if (typeof v === 'string') {
|
||||
return v.trim();
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
return asString(v[0]);
|
||||
}
|
||||
if (v == null) {
|
||||
return '';
|
||||
}
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setCorsHeaders,
|
||||
};
|
||||
@@ -3,15 +3,9 @@
|
||||
const {
|
||||
writeOpenAIError,
|
||||
} = require('./error_shape');
|
||||
|
||||
function setCorsHeaders(res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Content-Type, Authorization, X-API-Key, X-Ds2-Target-Account, X-Vercel-Protection-Bypass',
|
||||
);
|
||||
}
|
||||
const {
|
||||
setCorsHeaders,
|
||||
} = require('./cors');
|
||||
|
||||
function header(req, key) {
|
||||
if (!req || !req.headers) {
|
||||
|
||||
@@ -40,7 +40,7 @@ const {
|
||||
} = require('./dedupe');
|
||||
|
||||
async function handler(req, res) {
|
||||
setCorsHeaders(res);
|
||||
setCorsHeaders(res, req);
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
|
||||
@@ -140,11 +140,25 @@ func (noopLogEntry) Write(_ int, _ int, _ http.Header, _ time.Duration, _ interf
|
||||
|
||||
func (noopLogEntry) Panic(_ interface{}, _ []byte) {}
|
||||
|
||||
var defaultCORSAllowHeaders = []string{
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"X-API-Key",
|
||||
"X-Ds2-Target-Account",
|
||||
"X-Ds2-Source",
|
||||
"X-Vercel-Protection-Bypass",
|
||||
"X-Goog-Api-Key",
|
||||
"Anthropic-Version",
|
||||
"Anthropic-Beta",
|
||||
}
|
||||
|
||||
var blockedCORSRequestHeaders = map[string]struct{}{
|
||||
"x-ds2-internal-token": {},
|
||||
}
|
||||
|
||||
func cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Ds2-Target-Account, X-Ds2-Source, X-Vercel-Protection-Bypass")
|
||||
setCORSHeaders(w, r)
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
@@ -153,6 +167,125 @@ func cors(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func setCORSHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin == "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
} else {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
addVaryHeaderToken(w.Header(), "Origin")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", buildCORSAllowHeaders(r))
|
||||
w.Header().Set("Access-Control-Max-Age", "600")
|
||||
addVaryHeaderToken(w.Header(), "Access-Control-Request-Headers")
|
||||
if strings.EqualFold(strings.TrimSpace(r.Header.Get("Access-Control-Request-Private-Network")), "true") {
|
||||
w.Header().Set("Access-Control-Allow-Private-Network", "true")
|
||||
addVaryHeaderToken(w.Header(), "Access-Control-Request-Private-Network")
|
||||
}
|
||||
}
|
||||
|
||||
func buildCORSAllowHeaders(r *http.Request) string {
|
||||
names := make([]string, 0, len(defaultCORSAllowHeaders)+4)
|
||||
seen := make(map[string]struct{}, len(defaultCORSAllowHeaders)+4)
|
||||
for _, name := range defaultCORSAllowHeaders {
|
||||
appendCORSHeaderName(&names, seen, name)
|
||||
}
|
||||
if r == nil {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
for _, name := range splitCORSRequestHeaders(r.Header.Get("Access-Control-Request-Headers")) {
|
||||
appendCORSHeaderName(&names, seen, name)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func splitCORSRequestHeaders(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
name := strings.TrimSpace(part)
|
||||
if !isValidCORSHeaderToken(name) {
|
||||
continue
|
||||
}
|
||||
if _, blocked := blockedCORSRequestHeaders[strings.ToLower(name)]; blocked {
|
||||
continue
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendCORSHeaderName(dst *[]string, seen map[string]struct{}, name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if !isValidCORSHeaderToken(name) {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, blocked := blockedCORSRequestHeaders[key]; blocked {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
*dst = append(*dst, name)
|
||||
}
|
||||
|
||||
func isValidCORSHeaderToken(v string) bool {
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(v); i++ {
|
||||
c := v[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func addVaryHeaderToken(h http.Header, token string) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
current := h.Values("Vary")
|
||||
seen := map[string]struct{}{}
|
||||
merged := make([]string, 0, len(current)+1)
|
||||
for _, value := range current {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
name := strings.TrimSpace(part)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
merged = append(merged, name)
|
||||
}
|
||||
}
|
||||
key := strings.ToLower(token)
|
||||
if _, ok := seen[key]; !ok {
|
||||
merged = append(merged, token)
|
||||
}
|
||||
h.Set("Vary", strings.Join(merged, ", "))
|
||||
}
|
||||
|
||||
func WriteUnhandledError(w http.ResponseWriter, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
119
internal/server/router_cors_test.go
Normal file
119
internal/server/router_cors_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCORSPreflightAllowsThirdPartyRequestedHeaders(t *testing.T) {
|
||||
handler := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil)
|
||||
req.Header.Set("Origin", "app://obsidian.md")
|
||||
req.Header.Set("Access-Control-Request-Headers", "authorization, x-stainless-os, x-stainless-runtime, x-ds2-internal-token")
|
||||
req.Header.Set("Access-Control-Request-Private-Network", "true")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204 for preflight, got %d", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "app://obsidian.md" {
|
||||
t.Fatalf("expected origin echo, got %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Private-Network"); got != "true" {
|
||||
t.Fatalf("expected private network allow header, got %q", got)
|
||||
}
|
||||
|
||||
allowHeaders := strings.ToLower(rec.Header().Get("Access-Control-Allow-Headers"))
|
||||
for _, want := range []string{"authorization", "x-stainless-os", "x-stainless-runtime"} {
|
||||
if !strings.Contains(allowHeaders, want) {
|
||||
t.Fatalf("expected allow headers to include %q, got %q", want, rec.Header().Get("Access-Control-Allow-Headers"))
|
||||
}
|
||||
}
|
||||
if strings.Contains(allowHeaders, "x-ds2-internal-token") {
|
||||
t.Fatalf("expected internal-only header to stay blocked, got %q", rec.Header().Get("Access-Control-Allow-Headers"))
|
||||
}
|
||||
|
||||
vary := strings.ToLower(rec.Header().Get("Vary"))
|
||||
for _, want := range []string{"origin", "access-control-request-headers", "access-control-request-private-network"} {
|
||||
if !strings.Contains(vary, want) {
|
||||
t.Fatalf("expected vary to include %q, got %q", want, rec.Header().Get("Vary"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCORSAllowHeadersKeepsDefaultsWithoutRequest(t *testing.T) {
|
||||
got := strings.ToLower(buildCORSAllowHeaders(nil))
|
||||
for _, want := range []string{"content-type", "x-goog-api-key", "anthropic-version", "x-ds2-source"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected default allow headers to include %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppCORSPreflightIsUnifiedAcrossInterfaces(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"u@example.com","password":"p"}]}`)
|
||||
t.Setenv("DS2API_ENV_WRITEBACK", "0")
|
||||
|
||||
app, err := NewApp()
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
headers string
|
||||
}{
|
||||
{
|
||||
name: "openai",
|
||||
path: "/v1/chat/completions",
|
||||
headers: "authorization, x-stainless-os",
|
||||
},
|
||||
{
|
||||
name: "claude",
|
||||
path: "/anthropic/v1/messages",
|
||||
headers: "x-api-key, anthropic-version, x-stainless-os",
|
||||
},
|
||||
{
|
||||
name: "gemini",
|
||||
path: "/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
headers: "x-goog-api-key, x-client-version",
|
||||
},
|
||||
{
|
||||
name: "admin",
|
||||
path: "/admin/login",
|
||||
headers: "content-type, x-requested-with",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodOptions, tc.path, nil)
|
||||
req.Header.Set("Origin", "app://obsidian.md")
|
||||
req.Header.Set("Access-Control-Request-Headers", tc.headers)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
app.Router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected %s preflight status 204, got %d", tc.path, rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "app://obsidian.md" {
|
||||
t.Fatalf("expected origin echo for %s, got %q", tc.path, got)
|
||||
}
|
||||
allowHeaders := strings.ToLower(rec.Header().Get("Access-Control-Allow-Headers"))
|
||||
for _, want := range splitCORSRequestHeaders(tc.headers) {
|
||||
if !strings.Contains(allowHeaders, strings.ToLower(want)) {
|
||||
t.Fatalf("expected allow headers for %s to include %q, got %q", tc.path, want, rec.Header().Get("Access-Control-Allow-Headers"))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ const {
|
||||
processToolSieveChunk,
|
||||
flushToolSieve,
|
||||
} = require('../../internal/js/helpers/stream-tool-sieve.js');
|
||||
const {
|
||||
setCorsHeaders,
|
||||
} = require('../../internal/js/chat-stream/http_internal.js');
|
||||
|
||||
const {
|
||||
parseChunkForContent,
|
||||
@@ -26,6 +29,18 @@ const {
|
||||
trimContinuationOverlap,
|
||||
} = handler.__test;
|
||||
|
||||
function createMockResponse() {
|
||||
const headers = new Map();
|
||||
return {
|
||||
setHeader(key, value) {
|
||||
headers.set(String(key).toLowerCase(), value);
|
||||
},
|
||||
getHeader(key) {
|
||||
return headers.get(String(key).toLowerCase());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('chat-stream exposes parser test hooks', () => {
|
||||
assert.equal(typeof parseChunkForContent, 'function');
|
||||
assert.equal(typeof resolveToolcallPolicy, 'function');
|
||||
@@ -400,6 +415,32 @@ test('extractPathname strips query only', () => {
|
||||
assert.equal(extractPathname('/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=1'), '/v1beta/models/gemini-2.5-flash:streamGenerateContent');
|
||||
});
|
||||
|
||||
test('setCorsHeaders reflects requested third-party headers and blocks internal-only headers', () => {
|
||||
const res = createMockResponse();
|
||||
setCorsHeaders(res, {
|
||||
headers: {
|
||||
origin: 'app://obsidian.md',
|
||||
'access-control-request-headers': 'authorization, x-stainless-os, x-stainless-runtime, x-ds2-internal-token',
|
||||
'access-control-request-private-network': 'true',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(res.getHeader('access-control-allow-origin'), 'app://obsidian.md');
|
||||
assert.equal(res.getHeader('access-control-allow-private-network'), 'true');
|
||||
assert.equal(res.getHeader('access-control-max-age'), '600');
|
||||
|
||||
const allowHeaders = String(res.getHeader('access-control-allow-headers') || '').toLowerCase();
|
||||
assert.equal(allowHeaders.includes('authorization'), true);
|
||||
assert.equal(allowHeaders.includes('x-stainless-os'), true);
|
||||
assert.equal(allowHeaders.includes('x-stainless-runtime'), true);
|
||||
assert.equal(allowHeaders.includes('x-ds2-internal-token'), false);
|
||||
|
||||
const vary = String(res.getHeader('vary') || '').toLowerCase();
|
||||
assert.equal(vary.includes('origin'), true);
|
||||
assert.equal(vary.includes('access-control-request-headers'), true);
|
||||
assert.equal(vary.includes('access-control-request-private-network'), true);
|
||||
});
|
||||
|
||||
test('trimContinuationOverlap preserves short normal tokens and trims long snapshots', () => {
|
||||
assert.equal(trimContinuationOverlap('我们被问到', '我们'), '我们');
|
||||
const existing = '我们被问到:这是一个很长的续答快照前缀,用来验证去重逻辑不会误伤正常 token。';
|
||||
|
||||
Reference in New Issue
Block a user