diff --git a/API.en.md b/API.en.md index a7446c1..a982b0e 100644 --- a/API.en.md +++ b/API.en.md @@ -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 diff --git a/API.md b/API.md index f77d576..0fa81d3 100644 --- a/API.md +++ b/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 接口适配层说明 diff --git a/README.MD b/README.MD index 0c9bcf3..c8686bc 100644 --- a/README.MD +++ b/README.MD @@ -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)。 diff --git a/README.en.md b/README.en.md index daabc70..91c0bfe 100644 --- a/README.en.md +++ b/README.en.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). diff --git a/internal/js/chat-stream/cors.js b/internal/js/chat-stream/cors.js new file mode 100644 index 0000000..1a4b36a --- /dev/null +++ b/internal/js/chat-stream/cors.js @@ -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, +}; diff --git a/internal/js/chat-stream/http_internal.js b/internal/js/chat-stream/http_internal.js index 20f24c8..01caa8d 100644 --- a/internal/js/chat-stream/http_internal.js +++ b/internal/js/chat-stream/http_internal.js @@ -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) { diff --git a/internal/js/chat-stream/index.js b/internal/js/chat-stream/index.js index 57740fd..398fc9b 100644 --- a/internal/js/chat-stream/index.js +++ b/internal/js/chat-stream/index.js @@ -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(); diff --git a/internal/server/router.go b/internal/server/router.go index e1bf6f4..0e547e0 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -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) diff --git a/internal/server/router_cors_test.go b/internal/server/router_cors_test.go new file mode 100644 index 0000000..448b1f1 --- /dev/null +++ b/internal/server/router_cors_test.go @@ -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")) + } + } + }) + } +} diff --git a/tests/node/chat-stream.test.js b/tests/node/chat-stream.test.js index 4f78374..4394a86 100644 --- a/tests/node/chat-stream.test.js +++ b/tests/node/chat-stream.test.js @@ -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。';