diff --git a/API.en.md b/API.en.md index 7c29663..6e93202 100644 --- a/API.en.md +++ b/API.en.md @@ -243,6 +243,8 @@ Retired historical families such as `claude-1.*`, `claude-2.*`, `claude-instant- ### `POST /v1/chat/completions` +> Path note: besides the canonical `/v1/chat/completions`, DS2API also accepts the root shortcut `/chat/completions`. On Vercel Runtime, `stream=true` on either path is handled by the Node streaming bridge, while non-stream stays on the Go primary path. + **Headers**: ```http diff --git a/API.md b/API.md index ff5f6c1..0470fd5 100644 --- a/API.md +++ b/API.md @@ -249,6 +249,8 @@ OpenAI `/v1/*` 仍是规范路径。对于只配置 DS2API 根地址的客户端 ### `POST /v1/chat/completions` +> 路径说明:除规范路径 `/v1/chat/completions` 外,也支持根路径快捷别名 `/chat/completions`;在 Vercel Runtime 上,这两个路径的 `stream=true` 请求都会进入 Node 流式桥接逻辑,非流式仍走 Go 主链路。 + **请求头**: ```http diff --git a/README.MD b/README.MD index 3b8e841..3edf3b8 100644 --- a/README.MD +++ b/README.MD @@ -295,7 +295,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 对齐的输出组装与防泄漏处理。虽然这里只有 OpenAI chat 流式走 Node,但 CORS 放行策略仍与 Go 主路由保持一致,统一覆盖第三方客户端预检场景。 +> **流式说明**:OpenAI Chat 流式在 Vercel 上会由 `api/chat-stream.js`(Node Runtime)承接,支持规范路径 `/v1/chat/completions` 与根路径快捷别名 `/chat/completions`。鉴权、账号选择、会话/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 e232f61..62503b6 100644 --- a/README.en.md +++ b/README.en.md @@ -283,7 +283,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. 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. +> **Streaming note**: OpenAI Chat streaming on Vercel is routed to `api/chat-stream.js` (Node Runtime), with both the canonical `/v1/chat/completions` path and the root shortcut `/chat/completions` supported. 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/VERSION b/VERSION index fa1ba04..b98ff4c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.4.5 +4.4.6 diff --git a/internal/httpapi/claude/handler_util_test.go b/internal/httpapi/claude/handler_util_test.go index 7b83c88..d69dc25 100644 --- a/internal/httpapi/claude/handler_util_test.go +++ b/internal/httpapi/claude/handler_util_test.go @@ -292,7 +292,7 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) { if !containsStr(prompt, "Search the web") { t.Fatalf("expected description in prompt") } - if !containsStr(prompt, "<|DSML|tool_calls>") { + if !containsStr(prompt, "<|DSML|tool_calls>") { t.Fatalf("expected DSML tool_calls format in prompt") } if !containsStr(prompt, "TOOL CALL FORMAT") { diff --git a/internal/js/chat-stream/cors.js b/internal/js/chat-stream/cors.js index 1a4b36a..f796639 100644 --- a/internal/js/chat-stream/cors.js +++ b/internal/js/chat-stream/cors.js @@ -19,13 +19,15 @@ const BLOCKED_CORS_REQUEST_HEADERS = new Set([ function setCorsHeaders(res, req) { const origin = asString(readHeader(req, 'origin')); res.setHeader('Access-Control-Allow-Origin', origin || '*'); + if (origin) { + addVaryHeader(res, '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'); diff --git a/internal/js/chat-stream/index.js b/internal/js/chat-stream/index.js index 398fc9b..af9b264 100644 --- a/internal/js/chat-stream/index.js +++ b/internal/js/chat-stream/index.js @@ -88,7 +88,7 @@ function isVercelRuntime() { function isNodeStreamSupportedPath(rawURL) { const path = extractPathname(rawURL); - return path === '/v1/chat/completions'; + return path === '/v1/chat/completions' || path === '/chat/completions'; } function extractPathname(rawURL) { diff --git a/internal/promptcompat/prompt_build_test.go b/internal/promptcompat/prompt_build_test.go index dd80b6d..0c9b87b 100644 --- a/internal/promptcompat/prompt_build_test.go +++ b/internal/promptcompat/prompt_build_test.go @@ -74,7 +74,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t * } finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false) - if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>... block at the end of your response.") { + if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>... block at the end of your response.") { t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt) } if !strings.Contains(finalPrompt, "TOOL CALL FORMAT") { diff --git a/internal/toolcall/tool_prompt.go b/internal/toolcall/tool_prompt.go index 6844eb4..1a8ed1e 100644 --- a/internal/toolcall/tool_prompt.go +++ b/internal/toolcall/tool_prompt.go @@ -11,46 +11,45 @@ import "strings" func BuildToolCallInstructions(toolNames []string) string { return `TOOL CALL FORMAT — FOLLOW EXACTLY: -<|DSML|tool_calls> - <|DSML|invoke name="TOOL_NAME_HERE"> - <|DSML|parameter name="PARAMETER_NAME"> - - +<|DSML|tool_calls> + <|DSML|invoke name="TOOL_NAME_HERE"> + <|DSML|parameter name="PARAMETER_NAME"> + + RULES: -1) Use the <|DSML|tool_calls> wrapper format. -2) Put one or more <|DSML|invoke> entries under a single <|DSML|tool_calls> root. -3) Put the tool name in the invoke name attribute: <|DSML|invoke name="TOOL_NAME">. +1) Use the <|DSML|tool_calls> wrapper format. +2) Put one or more <|DSML|invoke> entries under a single <|DSML|tool_calls> root. +3) Put the tool name in the invoke name attribute: <|DSML|invoke name="TOOL_NAME">. 4) All string values must use , even short ones. This includes code, scripts, file contents, prompts, paths, names, and queries. -5) Every top-level argument must be a <|DSML|parameter name="ARG_NAME">... node. +5) Every top-level argument must be a <|DSML|parameter name="ARG_NAME">... node. 6) Objects use nested XML elements inside the parameter body. Arrays may repeat children. 7) Numbers, booleans, and null stay plain text. 8) Use only the parameter names in the tool schema. Do not invent fields. 9) Do NOT wrap XML in markdown fences. Do NOT output explanations, role markers, or internal monologue. -10) If you call a tool, the first non-whitespace characters of that tool block must be exactly <|DSML|tool_calls>. -11) Never omit the opening <|DSML|tool_calls> tag, even if you already plan to close with . +10) If you call a tool, the first non-whitespace characters of that tool block must be exactly <|DSML|tool_calls>. +11) Never omit the opening <|DSML|tool_calls> tag, even if you already plan to close with . 12) Compatibility note: the runtime also accepts the legacy XML tags / / , but prefer the DSML-prefixed form above. PARAMETER SHAPES: -- string => <|DSML|parameter name="x"> -- object => <|DSML|parameter name="x">... -- array => <|DSML|parameter name="x">...... -- number/bool/null => <|DSML|parameter name="x">plain_text +- string => <|DSML|parameter name="x"> +- object => <|DSML|parameter name="x">... +- array => <|DSML|parameter name="x">...... +- number/bool/null => <|DSML|parameter name="x">plain_text 【WRONG — Do NOT do these】: Wrong 1 — mixed text after XML: - <|DSML|tool_calls>... I hope this helps. + <|DSML|tool_calls>... I hope this helps. Wrong 2 — Markdown code fences: ` + "```xml" + ` - <|DSML|tool_calls>... + <|DSML|tool_calls>... ` + "```" + ` Wrong 3 — missing opening wrapper: - <|DSML|invoke name="TOOL_NAME">... - - -Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>... block at the end of your response. + <|DSML|invoke name="TOOL_NAME">... + +Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>... block at the end of your response. ` + buildCorrectToolExamples(toolNames) } @@ -141,21 +140,21 @@ func firstScriptExample(names []string) (promptToolExample, bool) { func renderToolExampleBlock(calls []promptToolExample) string { var b strings.Builder - b.WriteString("<|DSML|tool_calls>\n") + b.WriteString("<|DSML|tool_calls>\n") for _, call := range calls { - b.WriteString(` <|DSML|invoke name="`) + b.WriteString(` <|DSML|invoke name="`) b.WriteString(call.name) b.WriteString(`">` + "\n") b.WriteString(indentPromptParameters(call.params, " ")) - b.WriteString("\n \n") + b.WriteString("\n \n") } - b.WriteString("") + b.WriteString("") return b.String() } func indentPromptParameters(body, indent string) string { if strings.TrimSpace(body) == "" { - return indent + `<|DSML|parameter name="content">` + return indent + `<|DSML|parameter name="content">` } lines := strings.Split(body, "\n") for i, line := range lines { @@ -169,7 +168,7 @@ func indentPromptParameters(body, indent string) string { } func wrapParameter(name, inner string) string { - return `<|DSML|parameter name="` + name + `">` + inner + `` + return `<|DSML|parameter name="` + name + `">` + inner + `` } func exampleBasicParams(name string) (string, bool) { @@ -195,7 +194,7 @@ func exampleBasicParams(name string) (string, bool) { case "Edit": return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + wrapParameter("old_string", promptCDATA("foo")) + "\n" + wrapParameter("new_string", promptCDATA("bar")), true case "MultiEdit": - return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits">` + promptCDATA("foo") + `` + promptCDATA("bar") + ``, true + return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits">` + promptCDATA("foo") + `` + promptCDATA("bar") + ``, true } return "", false } @@ -203,11 +202,11 @@ func exampleBasicParams(name string) (string, bool) { func exampleNestedParams(name string) (string, bool) { switch strings.TrimSpace(name) { case "MultiEdit": - return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits">` + promptCDATA("foo") + `` + promptCDATA("bar") + ``, true + return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits">` + promptCDATA("foo") + `` + promptCDATA("bar") + ``, true case "Task": return wrapParameter("description", promptCDATA("Investigate flaky tests")) + "\n" + wrapParameter("prompt", promptCDATA("Run targeted tests and summarize failures")), true case "ask_followup_question": - return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `<|DSML|parameter name="follow_up">` + promptCDATA("Option A") + `` + promptCDATA("Option B") + ``, true + return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `<|DSML|parameter name="follow_up">` + promptCDATA("Option A") + `` + promptCDATA("Option B") + ``, true } return "", false } diff --git a/internal/toolcall/tool_prompt_test.go b/internal/toolcall/tool_prompt_test.go index f153e43..5e5eca6 100644 --- a/internal/toolcall/tool_prompt_test.go +++ b/internal/toolcall/tool_prompt_test.go @@ -7,20 +7,20 @@ import ( func TestBuildToolCallInstructions_ExecCommandUsesCmdExample(t *testing.T) { out := BuildToolCallInstructions([]string{"exec_command"}) - if !strings.Contains(out, `<|DSML|invoke name="exec_command">`) { + if !strings.Contains(out, `<|DSML|invoke name="exec_command">`) { t.Fatalf("expected exec_command in examples, got: %s", out) } - if !strings.Contains(out, `<|DSML|parameter name="cmd">`) { + if !strings.Contains(out, `<|DSML|parameter name="cmd">`) { t.Fatalf("expected cmd parameter example for exec_command, got: %s", out) } } func TestBuildToolCallInstructions_ExecuteCommandUsesCommandExample(t *testing.T) { out := BuildToolCallInstructions([]string{"execute_command"}) - if !strings.Contains(out, `<|DSML|invoke name="execute_command">`) { + if !strings.Contains(out, `<|DSML|invoke name="execute_command">`) { t.Fatalf("expected execute_command in examples, got: %s", out) } - if !strings.Contains(out, `<|DSML|parameter name="command">`) { + if !strings.Contains(out, `<|DSML|parameter name="command">`) { t.Fatalf("expected command parameter example for execute_command, got: %s", out) } } @@ -34,20 +34,20 @@ func TestBuildToolCallInstructions_BashUsesCommandAndDescriptionExamples(t *test sawDescription := false for _, block := range blocks { - if !strings.Contains(block, `<|DSML|parameter name="command">`) { + if !strings.Contains(block, `<|DSML|parameter name="command">`) { t.Fatalf("expected every Bash example to use command parameter, got: %s", block) } - if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { + if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { t.Fatalf("expected Bash examples not to use file write parameters, got: %s", block) } - if strings.Contains(block, `<|DSML|parameter name="description">`) { + if strings.Contains(block, `<|DSML|parameter name="description">`) { sawDescription = true } } if !sawDescription { t.Fatalf("expected Bash long-script example to include description, got: %s", out) } - if strings.Contains(out, `<|DSML|invoke name="Read">`) { + if strings.Contains(out, `<|DSML|invoke name="Read">`) { t.Fatalf("expected examples to avoid unavailable hard-coded Read tool, got: %s", out) } } @@ -60,10 +60,10 @@ func TestBuildToolCallInstructions_ExecuteCommandLongScriptUsesCommand(t *testin } for _, block := range blocks { - if !strings.Contains(block, `<|DSML|parameter name="command">`) { + if !strings.Contains(block, `<|DSML|parameter name="command">`) { t.Fatalf("expected execute_command examples to use command parameter, got: %s", block) } - if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { + if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { t.Fatalf("expected execute_command examples not to use file write parameters, got: %s", block) } } @@ -80,10 +80,10 @@ func TestBuildToolCallInstructions_ExecCommandLongScriptUsesCmd(t *testing.T) { } for _, block := range blocks { - if !strings.Contains(block, `<|DSML|parameter name="cmd">`) { + if !strings.Contains(block, `<|DSML|parameter name="cmd">`) { t.Fatalf("expected exec_command examples to use cmd parameter, got: %s", block) } - if strings.Contains(block, `<|DSML|parameter name="command">`) || strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { + if strings.Contains(block, `<|DSML|parameter name="command">`) || strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { t.Fatalf("expected exec_command examples not to use command or file write parameters, got: %s", block) } } @@ -100,10 +100,10 @@ func TestBuildToolCallInstructions_WriteUsesFilePathAndContent(t *testing.T) { } for _, block := range blocks { - if !strings.Contains(block, `<|DSML|parameter name="file_path">`) || !strings.Contains(block, `<|DSML|parameter name="content">`) { + if !strings.Contains(block, `<|DSML|parameter name="file_path">`) || !strings.Contains(block, `<|DSML|parameter name="content">`) { t.Fatalf("expected Write examples to use file_path and content, got: %s", block) } - if strings.Contains(block, `<|DSML|parameter name="path">`) { + if strings.Contains(block, `<|DSML|parameter name="path">`) { t.Fatalf("expected Write examples not to use path, got: %s", block) } } @@ -111,7 +111,7 @@ func TestBuildToolCallInstructions_WriteUsesFilePathAndContent(t *testing.T) { func TestBuildToolCallInstructions_AnchorsMissingOpeningWrapperFailureMode(t *testing.T) { out := BuildToolCallInstructions([]string{"read_file"}) - if !strings.Contains(out, "Never omit the opening <|DSML|tool_calls> tag") { + if !strings.Contains(out, "Never omit the opening <|DSML|tool_calls> tag") { t.Fatalf("expected explicit missing-opening-tag warning, got: %s", out) } if !strings.Contains(out, "Wrong 3 — missing opening wrapper") { @@ -120,7 +120,7 @@ func TestBuildToolCallInstructions_AnchorsMissingOpeningWrapperFailureMode(t *te } func findInvokeBlocks(text, name string) []string { - open := `<|DSML|invoke name="` + name + `">` + open := `<|DSML|invoke name="` + name + `">` remaining := text blocks := []string{} for { @@ -129,11 +129,11 @@ func findInvokeBlocks(text, name string) []string { return blocks } remaining = remaining[start:] - end := strings.Index(remaining, ``) + end := strings.Index(remaining, ``) if end < 0 { return blocks } - end += len(``) + end += len(``) blocks = append(blocks, remaining[:end]) remaining = remaining[end:] } diff --git a/internal/toolcall/toolcalls_markup.go b/internal/toolcall/toolcalls_markup.go index f9f2b4f..c52ed85 100644 --- a/internal/toolcall/toolcalls_markup.go +++ b/internal/toolcall/toolcalls_markup.go @@ -145,7 +145,6 @@ func SanitizeLooseCDATA(text string) string { return "" } - lower := strings.ToLower(text) const openMarker = "" @@ -154,17 +153,16 @@ func SanitizeLooseCDATA(text string) string { changed := false pos := 0 for pos < len(text) { - startRel := strings.Index(lower[pos:], openMarker) - if startRel < 0 { + start := indexASCIIFold(text, pos, openMarker) + if start < 0 { b.WriteString(text[pos:]) break } - start := pos + startRel contentStart := start + len(openMarker) b.WriteString(text[pos:start]) - if endRel := strings.Index(lower[contentStart:], closeMarker); endRel >= 0 { - end := contentStart + endRel + len(closeMarker) + if endRel := indexASCIIFold(text, contentStart, closeMarker); endRel >= 0 { + end := endRel + len(closeMarker) b.WriteString(text[start:end]) pos = end continue diff --git a/internal/toolcall/toolcalls_parse.go b/internal/toolcall/toolcalls_parse.go index 772b297..05b5a8b 100644 --- a/internal/toolcall/toolcalls_parse.go +++ b/internal/toolcall/toolcalls_parse.go @@ -212,17 +212,16 @@ func firstFenceMarkerIndex(line string) int { } func updateCDATAStateForStrip(inCDATA bool, cdataFenceMarker, line string) (bool, string) { - lower := strings.ToLower(line) pos := 0 state := inCDATA fenceMarker := cdataFenceMarker lineForFence := line if !state { - start := strings.Index(lower[pos:], "") - if end < 0 { + for pos < len(line) { + endPos := indexASCIIFold(line, pos, "]]>") + if endPos < 0 { return true, fenceMarker } - endPos := pos + end pos = endPos + len("]]>") if fenceMarker != "" { continue } - if cdataEndLooksStructural(lower, pos) || strings.TrimSpace(lower[pos:]) == "" { + if cdataEndLooksStructural(line, pos) || strings.TrimSpace(line[pos:]) == "" { state = false - for pos < len(lower) { - start := strings.Index(lower[pos:], "= len(text) { return -1 diff --git a/internal/toolcall/toolcalls_scan.go b/internal/toolcall/toolcalls_scan.go index c635b67..6acff6e 100644 --- a/internal/toolcall/toolcalls_scan.go +++ b/internal/toolcall/toolcalls_scan.go @@ -134,7 +134,6 @@ func scanToolMarkupTagAt(text string, start int) (ToolMarkupTag, bool) { if start < 0 || start >= len(text) || text[start] != '<' { return ToolMarkupTag{}, false } - lower := strings.ToLower(text) i := start + 1 for i < len(text) && text[i] == '<' { i++ @@ -144,8 +143,8 @@ func scanToolMarkupTagAt(text string, start int) (ToolMarkupTag, bool) { closing = true i++ } - i, dsmlLike := consumeToolMarkupNamePrefix(lower, text, i) - name, nameLen := matchToolMarkupName(lower, i, dsmlLike) + i, dsmlLike := consumeToolMarkupNamePrefix(text, i) + name, nameLen := matchToolMarkupName(text, i, dsmlLike) if nameLen == 0 { return ToolMarkupTag{}, false } @@ -188,7 +187,6 @@ func IsPartialToolMarkupTagPrefix(text string) bool { if text == "" || text[0] != '<' || strings.Contains(text, ">") { return false } - lower := strings.ToLower(text) i := 1 for i < len(text) && text[i] == '<' { i++ @@ -203,13 +201,13 @@ func IsPartialToolMarkupTagPrefix(text string) bool { if i == len(text) { return true } - if hasToolMarkupNamePrefix(lower[i:]) { + if hasToolMarkupNamePrefix(text, i) { return true } - if strings.HasPrefix("dsml", lower[i:]) { + if hasASCIIPartialPrefixFoldAt(text, i, "dsml") { return true } - next, ok := consumeToolMarkupNamePrefixOnce(lower, text, i) + next, ok := consumeToolMarkupNamePrefixOnce(text, i) if !ok { return false } @@ -218,10 +216,10 @@ func IsPartialToolMarkupTagPrefix(text string) bool { return false } -func consumeToolMarkupNamePrefix(lower, text string, idx int) (int, bool) { +func consumeToolMarkupNamePrefix(text string, idx int) (int, bool) { dsmlLike := false for { - next, ok := consumeToolMarkupNamePrefixOnce(lower, text, idx) + next, ok := consumeToolMarkupNamePrefixOnce(text, idx) if !ok { return idx, dsmlLike } @@ -230,14 +228,14 @@ func consumeToolMarkupNamePrefix(lower, text string, idx int) (int, bool) { } } -func consumeToolMarkupNamePrefixOnce(lower, text string, idx int) (int, bool) { +func consumeToolMarkupNamePrefixOnce(text string, idx int) (int, bool) { if next, ok := consumeToolMarkupPipe(text, idx); ok { return next, true } if idx < len(text) && (text[idx] == ' ' || text[idx] == '\t' || text[idx] == '\r' || text[idx] == '\n') { return idx + 1, true } - if strings.HasPrefix(lower[idx:], "dsml") { + if hasASCIIPrefixFoldAt(text, idx, "dsml") { next := idx + len("dsml") if next < len(text) && (text[next] == '-' || text[next] == '_') { next++ @@ -247,21 +245,37 @@ func consumeToolMarkupNamePrefixOnce(lower, text string, idx int) (int, bool) { return idx, false } -func hasToolMarkupNamePrefix(lowerTail string) bool { +func hasASCIIPartialPrefixFoldAt(text string, start int, prefix string) bool { + remain := len(text) - start + if remain <= 0 || remain > len(prefix) { + return false + } + for j := 0; j < remain; j++ { + if asciiLower(text[start+j]) != asciiLower(prefix[j]) { + return false + } + } + return true +} + +func hasToolMarkupNamePrefix(text string, start int) bool { for _, name := range toolMarkupNames { - if strings.HasPrefix(lowerTail, name.raw) || strings.HasPrefix(name.raw, lowerTail) { + if hasASCIIPrefixFoldAt(text, start, name.raw) { + return true + } + if hasASCIIPartialPrefixFoldAt(text, start, name.raw) { return true } } return false } -func matchToolMarkupName(lower string, start int, dsmlLike bool) (string, int) { +func matchToolMarkupName(text string, start int, dsmlLike bool) (string, int) { for _, name := range toolMarkupNames { if name.dsmlOnly && !dsmlLike { continue } - if strings.HasPrefix(lower[start:], name.raw) { + if hasASCIIPrefixFoldAt(text, start, name.raw) { return name.canonical, len(name.raw) } } diff --git a/tests/node/chat-stream.test.js b/tests/node/chat-stream.test.js index 5ac771b..cf49fa1 100644 --- a/tests/node/chat-stream.test.js +++ b/tests/node/chat-stream.test.js @@ -758,9 +758,11 @@ test('shouldSkipPath skips dynamic response/fragments/*/status paths only', () = assert.equal(shouldSkipPath('response/status'), false); }); -test('node stream path guard only allows /v1/chat/completions', () => { +test('node stream path guard allows OpenAI v1 and root alias chat completions paths', () => { assert.equal(isNodeStreamSupportedPath('/v1/chat/completions'), true); assert.equal(isNodeStreamSupportedPath('/v1/chat/completions?x=1'), true); + assert.equal(isNodeStreamSupportedPath('/chat/completions'), true); + assert.equal(isNodeStreamSupportedPath('/chat/completions?x=1'), true); assert.equal(isNodeStreamSupportedPath('/v1beta/models/gemini-2.5-flash:streamGenerateContent'), false); assert.equal(isNodeStreamSupportedPath('/anthropic/v1/messages'), false); }); @@ -768,6 +770,7 @@ test('node stream path guard only allows /v1/chat/completions', () => { test('extractPathname strips query only', () => { assert.equal(extractPathname('/v1/chat/completions?stream=true'), '/v1/chat/completions'); assert.equal(extractPathname('/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=1'), '/v1beta/models/gemini-2.5-flash:streamGenerateContent'); + assert.equal(extractPathname('/chat/completions?stream=true'), '/chat/completions'); }); test('setCorsHeaders reflects requested third-party headers and blocks internal-only headers', () => {