From fe8232bfc14a3334d5113329d59235101fb6d64d Mon Sep 17 00:00:00 2001 From: TesseractLHY Date: Tue, 31 Mar 2026 11:16:13 -0400 Subject: [PATCH 01/16] Fixes bad tool call --- internal/util/tool_prompt.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/util/tool_prompt.go b/internal/util/tool_prompt.go index a1dbbfd..b0b0f75 100644 --- a/internal/util/tool_prompt.go +++ b/internal/util/tool_prompt.go @@ -31,7 +31,7 @@ func BuildToolCallInstructions(toolNames []string) string { return `TOOL CALL FORMAT — FOLLOW EXACTLY: -When calling tools, emit ONLY raw XML. No text before, no text after, no markdown fences. +When calling tools, emit ONLY raw XML at the very end of your response. No text before, no text after, no markdown fences. From bfca84c2c7dd4a7d79788a4e58d7a2f0cd0a8f17 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 1 Apr 2026 01:24:55 +0800 Subject: [PATCH 02/16] Align tool-call parsing across Go/JS and pass quality gates --- .../adapter/claude/handler_helpers_misc.go | 97 +++++++++++++ internal/adapter/claude/handler_util_test.go | 41 ++++++ internal/adapter/claude/handler_utils.go | 130 ++++-------------- internal/adapter/claude/tool_call_state.go | 25 ++++ internal/adapter/gemini/convert_messages.go | 21 ++- .../adapter/gemini/convert_messages_test.go | 45 ++++++ internal/adapter/openai/tool_sieve_core.go | 4 +- .../js/helpers/stream-tool-sieve/parse.js | 5 +- .../stream-tool-sieve/parse_payload.js | 24 ++++ .../stream-tool-sieve/tool-keywords.js | 2 + internal/util/toolcalls_candidates.go | 2 +- internal/util/toolcalls_parse.go | 67 +-------- internal/util/toolcalls_parse_item.go | 88 ++++++++++++ internal/util/toolcalls_test.go | 28 ++++ tests/node/stream-tool-sieve.test.js | 18 +++ 15 files changed, 423 insertions(+), 174 deletions(-) create mode 100644 internal/adapter/claude/handler_helpers_misc.go create mode 100644 internal/adapter/claude/tool_call_state.go create mode 100644 internal/util/toolcalls_parse_item.go diff --git a/internal/adapter/claude/handler_helpers_misc.go b/internal/adapter/claude/handler_helpers_misc.go new file mode 100644 index 0000000..7b89734 --- /dev/null +++ b/internal/adapter/claude/handler_helpers_misc.go @@ -0,0 +1,97 @@ +package claude + +import ( + "fmt" + "strings" +) + +func hasSystemMessage(messages []any) bool { + for _, m := range messages { + msg, ok := m.(map[string]any) + if ok && msg["role"] == "system" { + return true + } + } + return false +} + +func extractClaudeToolNames(tools []any) []string { + out := make([]string, 0, len(tools)) + for _, t := range tools { + m, ok := t.(map[string]any) + if !ok { + continue + } + name, _, _ := extractClaudeToolMeta(m) + if name != "" { + out = append(out, name) + } + } + return out +} + +func extractClaudeToolMeta(m map[string]any) (string, string, any) { + name, _ := m["name"].(string) + desc, _ := m["description"].(string) + schemaObj := m["input_schema"] + if schemaObj == nil { + schemaObj = m["parameters"] + } + + if fn, ok := m["function"].(map[string]any); ok { + if strings.TrimSpace(name) == "" { + name, _ = fn["name"].(string) + } + if strings.TrimSpace(desc) == "" { + desc, _ = fn["description"].(string) + } + if schemaObj == nil { + if v, ok := fn["input_schema"]; ok { + schemaObj = v + } + } + if schemaObj == nil { + if v, ok := fn["parameters"]; ok { + schemaObj = v + } + } + } + return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj +} + +func toMessageMaps(v any) []map[string]any { + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(arr)) + for _, item := range arr { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +func extractMessageContent(v any) string { + switch x := v.(type) { + case string: + return x + case []any: + parts := make([]string, 0, len(x)) + for _, it := range x { + parts = append(parts, fmt.Sprintf("%v", it)) + } + return strings.Join(parts, "\n") + default: + return fmt.Sprintf("%v", x) + } +} + +func cloneMap(in map[string]any) map[string]any { + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/internal/adapter/claude/handler_util_test.go b/internal/adapter/claude/handler_util_test.go index 0b6085b..82302f0 100644 --- a/internal/adapter/claude/handler_util_test.go +++ b/internal/adapter/claude/handler_util_test.go @@ -225,6 +225,47 @@ func TestNormalizeClaudeMessagesToolResultNonTextPayloadStringified(t *testing.T } } +func TestNormalizeClaudeMessagesBackfillsToolResultCallIDByName(t *testing.T) { + msgs := []any{ + map[string]any{ + "role": "assistant", + "content": []any{ + map[string]any{ + "type": "tool_use", + "name": "search_web", + "input": map[string]any{"query": "latest"}, + }, + }, + }, + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{ + "type": "tool_result", + "name": "search_web", + "content": "ok", + }, + }, + }, + } + + got := normalizeClaudeMessages(msgs) + if len(got) != 2 { + t.Fatalf("expected 2 messages, got %#v", got) + } + assistant, _ := got[0].(map[string]any) + tc, _ := assistant["tool_calls"].([]any) + call, _ := tc[0].(map[string]any) + callID, _ := call["id"].(string) + if !strings.HasPrefix(callID, "call_claude_") { + t.Fatalf("expected generated call id, got %#v", call) + } + toolMsg, _ := got[1].(map[string]any) + if toolMsg["tool_call_id"] != callID { + t.Fatalf("expected tool_result to reuse generated id, got %#v", toolMsg) + } +} + // ─── buildClaudeToolPrompt ─────────────────────────────────────────── func TestBuildClaudeToolPromptSingleTool(t *testing.T) { diff --git a/internal/adapter/claude/handler_utils.go b/internal/adapter/claude/handler_utils.go index c46e37a..fef1194 100644 --- a/internal/adapter/claude/handler_utils.go +++ b/internal/adapter/claude/handler_utils.go @@ -11,6 +11,11 @@ import ( func normalizeClaudeMessages(messages []any) []any { out := make([]any, 0, len(messages)) + state := &claudeToolCallState{ + nameByID: map[string]string{}, + lastIDByName: map[string]string{}, + callIDSequence: 0, + } for _, m := range messages { msg, ok := m.(map[string]any) if !ok { @@ -44,7 +49,7 @@ func normalizeClaudeMessages(messages []any) []any { case "tool_use": if role == "assistant" { flushText() - if toolMsg := normalizeClaudeToolUseToAssistant(b); toolMsg != nil { + if toolMsg := normalizeClaudeToolUseToAssistant(b, state); toolMsg != nil { out = append(out, toolMsg) } continue @@ -54,7 +59,7 @@ func normalizeClaudeMessages(messages []any) []any { } case "tool_result": flushText() - if toolMsg := normalizeClaudeToolResultToToolMessage(b); toolMsg != nil { + if toolMsg := normalizeClaudeToolResultToToolMessage(b, state); toolMsg != nil { out = append(out, toolMsg) } default: @@ -119,7 +124,7 @@ func formatClaudeToolResultForPrompt(block map[string]any) string { return string(b) } -func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any { +func normalizeClaudeToolUseToAssistant(block map[string]any, state *claudeToolCallState) map[string]any { if block == nil { return nil } @@ -127,13 +132,15 @@ func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any { if name == "" { return nil } - callID := strings.TrimSpace(fmt.Sprintf("%v", block["id"])) + callID := safeStringValue(block["id"]) if callID == "" { - callID = strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"])) + callID = safeStringValue(block["tool_use_id"]) } if callID == "" { - callID = "call_claude" + callID = state.nextID() } + state.nameByID[callID] = name + state.lastIDByName[strings.ToLower(name)] = callID arguments := block["input"] if arguments == nil { arguments = map[string]any{} @@ -159,24 +166,34 @@ func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any { } } -func normalizeClaudeToolResultToToolMessage(block map[string]any) map[string]any { +func normalizeClaudeToolResultToToolMessage(block map[string]any, state *claudeToolCallState) map[string]any { if block == nil { return nil } - toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"])) + name := safeStringValue(block["name"]) + toolCallID := safeStringValue(block["tool_use_id"]) if toolCallID == "" { - toolCallID = strings.TrimSpace(fmt.Sprintf("%v", block["tool_call_id"])) + toolCallID = safeStringValue(block["tool_call_id"]) } if toolCallID == "" { - toolCallID = "call_claude" + if name != "" { + toolCallID = strings.TrimSpace(state.lastIDByName[strings.ToLower(name)]) + } + } + if toolCallID == "" { + toolCallID = state.nextID() } out := map[string]any{ "role": "tool", "tool_call_id": toolCallID, "content": normalizeClaudeToolResultContent(block["content"]), } - if name := strings.TrimSpace(fmt.Sprintf("%v", block["name"])); name != "" { + if name != "" { out["name"] = name + state.nameByID[toolCallID] = name + state.lastIDByName[strings.ToLower(name)] = toolCallID + } else if inferred := strings.TrimSpace(state.nameByID[toolCallID]); inferred != "" { + out["name"] = inferred } return out } @@ -206,94 +223,3 @@ func formatClaudeBlockRaw(block map[string]any) string { } return string(b) } - -func hasSystemMessage(messages []any) bool { - for _, m := range messages { - msg, ok := m.(map[string]any) - if ok && msg["role"] == "system" { - return true - } - } - return false -} - -func extractClaudeToolNames(tools []any) []string { - out := make([]string, 0, len(tools)) - for _, t := range tools { - m, ok := t.(map[string]any) - if !ok { - continue - } - name, _, _ := extractClaudeToolMeta(m) - if name != "" { - out = append(out, name) - } - } - return out -} - -func extractClaudeToolMeta(m map[string]any) (string, string, any) { - name, _ := m["name"].(string) - desc, _ := m["description"].(string) - schemaObj := m["input_schema"] - if schemaObj == nil { - schemaObj = m["parameters"] - } - - if fn, ok := m["function"].(map[string]any); ok { - if strings.TrimSpace(name) == "" { - name, _ = fn["name"].(string) - } - if strings.TrimSpace(desc) == "" { - desc, _ = fn["description"].(string) - } - if schemaObj == nil { - if v, ok := fn["input_schema"]; ok { - schemaObj = v - } - } - if schemaObj == nil { - if v, ok := fn["parameters"]; ok { - schemaObj = v - } - } - } - return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj -} - -func toMessageMaps(v any) []map[string]any { - arr, ok := v.([]any) - if !ok { - return nil - } - out := make([]map[string]any, 0, len(arr)) - for _, item := range arr { - if m, ok := item.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -func extractMessageContent(v any) string { - switch x := v.(type) { - case string: - return x - case []any: - parts := make([]string, 0, len(x)) - for _, it := range x { - parts = append(parts, fmt.Sprintf("%v", it)) - } - return strings.Join(parts, "\n") - default: - return fmt.Sprintf("%v", x) - } -} - -func cloneMap(in map[string]any) map[string]any { - out := make(map[string]any, len(in)) - for k, v := range in { - out[k] = v - } - return out -} diff --git a/internal/adapter/claude/tool_call_state.go b/internal/adapter/claude/tool_call_state.go new file mode 100644 index 0000000..595d089 --- /dev/null +++ b/internal/adapter/claude/tool_call_state.go @@ -0,0 +1,25 @@ +package claude + +import ( + "fmt" + "strings" +) + +type claudeToolCallState struct { + nameByID map[string]string + lastIDByName map[string]string + callIDSequence int +} + +func (s *claudeToolCallState) nextID() string { + s.callIDSequence++ + return fmt.Sprintf("call_claude_%d", s.callIDSequence) +} + +func safeStringValue(v any) string { + s, ok := v.(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} diff --git a/internal/adapter/gemini/convert_messages.go b/internal/adapter/gemini/convert_messages.go index ec3f174..f6af145 100644 --- a/internal/adapter/gemini/convert_messages.go +++ b/internal/adapter/gemini/convert_messages.go @@ -1,11 +1,20 @@ package gemini -import "strings" +import ( + "fmt" + "strings" +) const maxGeminiRawPromptChars = 1024 func geminiMessagesFromRequest(req map[string]any) []any { out := make([]any, 0, 8) + toolCallCounter := 0 + nextToolCallID := func() string { + toolCallCounter++ + return fmt.Sprintf("call_gemini_%d", toolCallCounter) + } + lastToolCallIDByName := map[string]string{} if sys := normalizeGeminiSystemInstruction(req["systemInstruction"]); strings.TrimSpace(sys) != "" { out = append(out, map[string]any{ "role": "system", @@ -61,8 +70,11 @@ func geminiMessagesFromRequest(req map[string]any) []any { if name := strings.TrimSpace(asString(fnCall["name"])); name != "" { callID := strings.TrimSpace(asString(fnCall["id"])) if callID == "" { - callID = "call_gemini" + if callID = strings.TrimSpace(asString(fnCall["call_id"])); callID == "" { + callID = nextToolCallID() + } } + lastToolCallIDByName[strings.ToLower(name)] = callID out = append(out, map[string]any{ "role": "assistant", "tool_calls": []any{ @@ -91,7 +103,10 @@ func geminiMessagesFromRequest(req map[string]any) []any { callID = strings.TrimSpace(asString(fnResp["tool_call_id"])) } if callID == "" { - callID = "call_gemini" + callID = strings.TrimSpace(lastToolCallIDByName[strings.ToLower(name)]) + } + if callID == "" { + callID = nextToolCallID() } content := fnResp["response"] if content == nil { diff --git a/internal/adapter/gemini/convert_messages_test.go b/internal/adapter/gemini/convert_messages_test.go index 4c98778..a5191b9 100644 --- a/internal/adapter/gemini/convert_messages_test.go +++ b/internal/adapter/gemini/convert_messages_test.go @@ -82,3 +82,48 @@ func TestGeminiMessagesFromRequestPreservesUnknownPartAsRawJSONText(t *testing.T t.Fatalf("expected raw base64 payload not to be embedded, got %q", content) } } + +func TestGeminiMessagesFromRequestBackfillsFunctionResponseCallIDByName(t *testing.T) { + req := map[string]any{ + "contents": []any{ + map[string]any{ + "role": "model", + "parts": []any{ + map[string]any{ + "functionCall": map[string]any{ + "name": "search_web", + "args": map[string]any{"query": "docs"}, + }, + }, + }, + }, + map[string]any{ + "role": "user", + "parts": []any{ + map[string]any{ + "functionResponse": map[string]any{ + "name": "search_web", + "response": map[string]any{"ok": true}, + }, + }, + }, + }, + }, + } + + got := geminiMessagesFromRequest(req) + if len(got) != 2 { + t.Fatalf("expected two normalized messages, got %#v", got) + } + assistant, _ := got[0].(map[string]any) + tc, _ := assistant["tool_calls"].([]any) + call, _ := tc[0].(map[string]any) + callID, _ := call["id"].(string) + if !strings.HasPrefix(callID, "call_gemini_") { + t.Fatalf("expected generated call id prefix, got %#v", call) + } + toolMsg, _ := got[1].(map[string]any) + if toolMsg["tool_call_id"] != callID { + t.Fatalf("expected tool response to inherit generated call id, tool=%#v call=%#v", toolMsg, call) + } +} diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index 9e04d85..23bfdff 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -183,7 +183,7 @@ func findToolSegmentStart(s string) int { return -1 } lower := strings.ToLower(s) - keywords := []string{"tool_calls", "\"function\"", "function.name:"} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "functionCall", "\"tool_use\""} bestKeyIdx := -1 for _, kw := range keywords { idx := strings.Index(lower, kw) @@ -240,7 +240,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix lower := strings.ToLower(captured) keyIdx := -1 - keywords := []string{"tool_calls", "\"function\"", "function.name:"} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "functionCall", "\"tool_use\""} for _, kw := range keywords { idx := strings.Index(lower, kw) if idx >= 0 && (keyIdx < 0 || idx < keyIdx) { diff --git a/internal/js/helpers/stream-tool-sieve/parse.js b/internal/js/helpers/stream-tool-sieve/parse.js index 0930c08..3ab1651 100644 --- a/internal/js/helpers/stream-tool-sieve/parse.js +++ b/internal/js/helpers/stream-tool-sieve/parse.js @@ -237,7 +237,10 @@ function isLikelyJSONToolPayloadCandidate(text) { return false; } const lower = trimmed.toLowerCase(); - return lower.includes('tool_calls') || lower.includes('"function"'); + return lower.includes('tool_calls') + || lower.includes('"function"') + || lower.includes('functioncall') + || lower.includes('"tool_use"'); } module.exports = { diff --git a/internal/js/helpers/stream-tool-sieve/parse_payload.js b/internal/js/helpers/stream-tool-sieve/parse_payload.js index c480033..2970613 100644 --- a/internal/js/helpers/stream-tool-sieve/parse_payload.js +++ b/internal/js/helpers/stream-tool-sieve/parse_payload.js @@ -85,6 +85,8 @@ function extractToolCallObjects(text) { while (true) { const idxToolCalls = lower.indexOf('tool_calls', offset); const idxFunction = lower.indexOf('"function"', offset); + const idxFunctionCall = lower.indexOf('functioncall', offset); + const idxToolUse = lower.indexOf('"tool_use"', offset); let idx = -1; let matched = ''; if (idxToolCalls >= 0 && (idxFunction < 0 || idxToolCalls <= idxFunction)) { @@ -94,6 +96,14 @@ function extractToolCallObjects(text) { idx = idxFunction; matched = '"function"'; } + if (idxFunctionCall >= 0 && (idx < 0 || idxFunctionCall < idx)) { + idx = idxFunctionCall; + matched = 'functioncall'; + } + if (idxToolUse >= 0 && (idx < 0 || idxToolUse < idx)) { + idx = idxToolUse; + matched = '"tool_use"'; + } if (idx < 0) { break; } @@ -327,6 +337,20 @@ function parseToolCallItem(m) { let name = toStringSafe(m.name); let inputRaw = m.input; let hasInput = Object.prototype.hasOwnProperty.call(m, 'input'); + const fnCall = m.functionCall && typeof m.functionCall === 'object' ? m.functionCall : null; + if (fnCall) { + if (!name) { + name = toStringSafe(fnCall.name); + } + if (!hasInput && Object.prototype.hasOwnProperty.call(fnCall, 'args')) { + inputRaw = fnCall.args; + hasInput = true; + } + if (!hasInput && Object.prototype.hasOwnProperty.call(fnCall, 'arguments')) { + inputRaw = fnCall.arguments; + hasInput = true; + } + } const fn = m.function && typeof m.function === 'object' ? m.function : null; if (fn) { diff --git a/internal/js/helpers/stream-tool-sieve/tool-keywords.js b/internal/js/helpers/stream-tool-sieve/tool-keywords.js index 29896dc..04a0163 100644 --- a/internal/js/helpers/stream-tool-sieve/tool-keywords.js +++ b/internal/js/helpers/stream-tool-sieve/tool-keywords.js @@ -4,6 +4,8 @@ const TOOL_SEGMENT_KEYWORDS = [ 'tool_calls', '"function"', 'function.name:', + 'functioncall', + '"tool_use"', ]; const XML_TOOL_SEGMENT_TAGS = [ diff --git a/internal/util/toolcalls_candidates.go b/internal/util/toolcalls_candidates.go index d7dfe92..0c486bf 100644 --- a/internal/util/toolcalls_candidates.go +++ b/internal/util/toolcalls_candidates.go @@ -64,7 +64,7 @@ func extractToolCallObjects(text string) []string { lower := strings.ToLower(text) out := []string{} offset := 0 - keywords := []string{"tool_calls", "\"function\"", "function.name:"} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "functioncall", "\"tool_use\""} for { bestIdx := -1 matchedKeyword := "" diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index 7fe8068..6127592 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -196,18 +196,6 @@ func parseToolCallsPayload(payload string) []ParsedToolCall { return nil } -func isLikelyJSONToolPayloadCandidate(candidate string) bool { - trimmed := strings.TrimSpace(candidate) - if trimmed == "" { - return false - } - if !(strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) { - return false - } - lower := strings.ToLower(trimmed) - return strings.Contains(lower, "tool_calls") || strings.Contains(lower, "\"function\"") -} - func isLikelyChatMessageEnvelope(v map[string]any) bool { if v == nil { return false @@ -234,62 +222,11 @@ func looksLikeToolCallSyntax(text string) bool { lower := strings.ToLower(text) return strings.Contains(lower, "tool_calls") || strings.Contains(lower, "\"function\"") || + strings.Contains(lower, "functioncall") || + strings.Contains(lower, "\"tool_use\"") || strings.Contains(lower, "test` calls := ParseToolCalls(text, []string{"search_web"}) diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index e53086e..fa23e64 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -108,6 +108,24 @@ test('parseToolCalls parses text-kv fallback payload', () => { assert.equal(calls[0].input.command, 'cd scripts && python check_syntax.py example.py'); }); +test('parseToolCalls supports Gemini functionCall JSON payload', () => { + const payload = JSON.stringify({ + functionCall: { name: 'search_web', args: { query: 'latest' } }, + }); + const calls = parseToolCalls(payload, ['search_web']); + assert.deepEqual(calls, [{ name: 'search_web', input: { query: 'latest' } }]); +}); + +test('parseToolCalls supports Claude tool_use JSON payload', () => { + const payload = JSON.stringify({ + type: 'tool_use', + name: 'read_file', + input: { path: 'README.md' }, + }); + const calls = parseToolCalls(payload, ['read_file']); + assert.deepEqual(calls, [{ name: 'read_file', input: { path: 'README.md' } }]); +}); + test('parseToolCalls parses multiple text-kv fallback payloads', () => { const text = [ 'function.name: read_file', From 8a74dbff9cfc1eafd70162850e452bdcddd2e3e6 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 1 Apr 2026 01:50:56 +0800 Subject: [PATCH 03/16] Fix lowercase functioncall detection in stream tool sieve --- internal/adapter/openai/tool_sieve_core.go | 4 ++-- .../adapter/openai/tool_sieve_xml_test.go | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index 23bfdff..5d96503 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -183,7 +183,7 @@ func findToolSegmentStart(s string) int { return -1 } lower := strings.ToLower(s) - keywords := []string{"tool_calls", "\"function\"", "function.name:", "functionCall", "\"tool_use\""} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "functioncall", "\"tool_use\""} bestKeyIdx := -1 for _, kw := range keywords { idx := strings.Index(lower, kw) @@ -240,7 +240,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix lower := strings.ToLower(captured) keyIdx := -1 - keywords := []string{"tool_calls", "\"function\"", "function.name:", "functionCall", "\"tool_use\""} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "functioncall", "\"tool_use\""} for _, kw := range keywords { idx := strings.Index(lower, kw) if idx >= 0 && (keyIdx < 0 || idx < keyIdx) { diff --git a/internal/adapter/openai/tool_sieve_xml_test.go b/internal/adapter/openai/tool_sieve_xml_test.go index 9201189..0cf98e4 100644 --- a/internal/adapter/openai/tool_sieve_xml_test.go +++ b/internal/adapter/openai/tool_sieve_xml_test.go @@ -104,6 +104,7 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) { want int }{ {"tool_calls_tag", "some text \n", 10}, + {"gemini_function_call_json", `some text {"functionCall":{"name":"search","args":{"q":"latest"}}}`, 10}, {"tool_call_tag", "prefix \n", 7}, {"invoke_tag", "text body", 5}, {"function_call_tag", "body", 0}, @@ -119,6 +120,27 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) { } } +func TestProcessToolSieveDetectsGeminiFunctionCallPayload(t *testing.T) { + var state toolStreamSieveState + events := processToolSieveChunk(&state, `{"functionCall":{"name":"search_web","args":{"query":"latest"}}}`, []string{"search_web"}) + events = append(events, flushToolSieve(&state, []string{"search_web"})...) + + var textContent string + var toolCalls int + for _, evt := range events { + if evt.Content != "" { + textContent += evt.Content + } + toolCalls += len(evt.ToolCalls) + } + if toolCalls != 1 { + t.Fatalf("expected one tool call from functionCall payload, got events=%#v", events) + } + if strings.Contains(strings.ToLower(textContent), "functioncall") { + t.Fatalf("functionCall json leaked into text content: %q", textContent) + } +} + func TestFindPartialXMLToolTagStart(t *testing.T) { cases := []struct { name string From da3fafb79aa0526515532d9c5ffd42bf85c29f04 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 00:48:41 +0800 Subject: [PATCH 04/16] fix pool starvation of tokenless managed accounts --- internal/account/pool_acquire.go | 13 ++----------- internal/account/pool_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/internal/account/pool_acquire.go b/internal/account/pool_acquire.go index b0c548c..6d1ec7d 100644 --- a/internal/account/pool_acquire.go +++ b/internal/account/pool_acquire.go @@ -60,16 +60,10 @@ func (p *Pool) acquireLocked(target string, exclude map[string]bool) (config.Acc return acc, true } - if acc, ok := p.tryAcquire(exclude, true); ok { - return acc, true - } - if acc, ok := p.tryAcquire(exclude, false); ok { - return acc, true - } - return config.Account{}, false + return p.tryAcquire(exclude) } -func (p *Pool) tryAcquire(exclude map[string]bool, requireToken bool) (config.Account, bool) { +func (p *Pool) tryAcquire(exclude map[string]bool) (config.Account, bool) { for i := 0; i < len(p.queue); i++ { id := p.queue[i] if exclude[id] || !p.canAcquireIDLocked(id) { @@ -79,9 +73,6 @@ func (p *Pool) tryAcquire(exclude map[string]bool, requireToken bool) (config.Ac if !ok { continue } - if requireToken && acc.Token == "" { - continue - } p.inUse[id]++ p.bumpQueue(id) return acc, true diff --git a/internal/account/pool_test.go b/internal/account/pool_test.go index 37109ff..89bef64 100644 --- a/internal/account/pool_test.go +++ b/internal/account/pool_test.go @@ -215,6 +215,33 @@ func TestPoolDropsLegacyTokenOnlyAccountOnLoad(t *testing.T) { } } +func TestPoolAcquireRotatesIntoTokenlessAccounts(t *testing.T) { + t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1") + t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "") + t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "") + t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "") + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["k1"], + "accounts":[ + {"email":"acc1@example.com","token":"token1"}, + {"email":"acc2@example.com","token":""}, + {"email":"acc3@example.com","token":""} + ] + }`) + + pool := NewPool(config.LoadStore()) + for i, want := range []string{"acc1@example.com", "acc2@example.com", "acc3@example.com"} { + acc, ok := pool.Acquire("", nil) + if !ok { + t.Fatalf("expected acquire success at step %d", i+1) + } + if got := acc.Identifier(); got != want { + t.Fatalf("unexpected account at step %d: got %q want %q", i+1, got, want) + } + pool.Release(acc.Identifier()) + } +} + func TestPoolAcquireWaitQueuesAndSucceedsAfterRelease(t *testing.T) { pool := newSingleAccountPoolForTest(t, "1") first, ok := pool.Acquire("", nil) From f6cd541c6f3236512c00f8db168d40214de03764 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 02:04:58 +0800 Subject: [PATCH 05/16] auth: retry other managed accounts when token ensure fails --- internal/auth/auth_edge_test.go | 39 ++++++++++++++++++ internal/auth/request.go | 70 ++++++++++++++++++++++----------- internal/auth/request_test.go | 64 ++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 23 deletions(-) diff --git a/internal/auth/auth_edge_test.go b/internal/auth/auth_edge_test.go index 55c46ef..929b753 100644 --- a/internal/auth/auth_edge_test.go +++ b/internal/auth/auth_edge_test.go @@ -204,6 +204,45 @@ func TestSwitchAccountNilTriedAccounts(t *testing.T) { r.Release(a) } +func TestSwitchAccountSkipsLoginFailureAndContinues(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["managed-key"], + "accounts":[ + {"email":"acc1@test.com","password":"pwd","token":"t1"}, + {"email":"acc2@test.com","password":"pwd"}, + {"email":"acc3@test.com","password":"pwd","token":"t3"} + ] + }`) + store := config.LoadStore() + pool := account.NewPool(store) + r := NewResolver(store, pool, func(_ context.Context, acc config.Account) (string, error) { + if acc.Email == "acc2@test.com" { + return "", errors.New("login failed") + } + return "new-token", nil + }) + + req, _ := http.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "Bearer managed-key") + a, err := r.Determine(req) + if err != nil { + t.Fatalf("determine failed: %v", err) + } + defer r.Release(a) + if a.AccountID != "acc1@test.com" { + t.Fatalf("expected first account, got %q", a.AccountID) + } + if !r.SwitchAccount(context.Background(), a) { + t.Fatal("expected switch to succeed after skipping failed account") + } + if a.AccountID != "acc3@test.com" { + t.Fatalf("expected fallback to third account, got %q", a.AccountID) + } + if !a.TriedAccounts["acc2@test.com"] { + t.Fatalf("expected failed account to be marked as tried") + } +} + // ─── Release edge cases ───────────────────────────────────────────── func TestReleaseNilAuth(t *testing.T) { diff --git a/internal/auth/request.go b/internal/auth/request.go index fa39c61..9a147d2 100644 --- a/internal/auth/request.go +++ b/internal/auth/request.go @@ -70,25 +70,45 @@ func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) { }, nil } target := strings.TrimSpace(req.Header.Get("X-Ds2-Target-Account")) - acc, ok := r.Pool.AcquireWait(ctx, target, nil) - if !ok { - return nil, ErrNoAccount - } - a := &RequestAuth{ - UseConfigToken: true, - CallerID: callerID, - AccountID: acc.Identifier(), - Account: acc, - TriedAccounts: map[string]bool{}, - resolver: r, - } - if err := r.ensureManagedToken(ctx, a); err != nil { - r.Pool.Release(a.AccountID) + a, err := r.acquireManagedRequestAuth(ctx, callerID, target) + if err != nil { return nil, err } return a, nil } +func (r *Resolver) acquireManagedRequestAuth(ctx context.Context, callerID, target string) (*RequestAuth, error) { + tried := map[string]bool{} + for { + if target == "" && len(tried) >= len(r.Store.Accounts()) { + return nil, ErrNoAccount + } + acc, ok := r.Pool.AcquireWait(ctx, target, tried) + if !ok { + return nil, ErrNoAccount + } + + a := &RequestAuth{ + UseConfigToken: true, + CallerID: callerID, + AccountID: acc.Identifier(), + Account: acc, + TriedAccounts: tried, + resolver: r, + } + + if err := r.ensureManagedToken(ctx, a); err != nil { + tried[a.AccountID] = true + r.Pool.Release(a.AccountID) + if target != "" { + return nil, err + } + continue + } + return a, nil + } +} + // DetermineCaller resolves caller identity without acquiring any pooled account. // Use this for local-cache lookup routes that only need tenant isolation. func (r *Resolver) DetermineCaller(req *http.Request) (*RequestAuth, error) { @@ -164,16 +184,20 @@ func (r *Resolver) SwitchAccount(ctx context.Context, a *RequestAuth) bool { a.TriedAccounts[a.AccountID] = true r.Pool.Release(a.AccountID) } - acc, ok := r.Pool.Acquire("", a.TriedAccounts) - if !ok { - return false + for { + acc, ok := r.Pool.Acquire("", a.TriedAccounts) + if !ok { + return false + } + a.Account = acc + a.AccountID = acc.Identifier() + if err := r.ensureManagedToken(ctx, a); err != nil { + a.TriedAccounts[a.AccountID] = true + r.Pool.Release(a.AccountID) + continue + } + return true } - a.Account = acc - a.AccountID = acc.Identifier() - if err := r.ensureManagedToken(ctx, a); err != nil { - return false - } - return true } func (r *Resolver) Release(a *RequestAuth) { diff --git a/internal/auth/request_test.go b/internal/auth/request_test.go index eab97a4..d8f36b3 100644 --- a/internal/auth/request_test.go +++ b/internal/auth/request_test.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "net/http" "sync/atomic" "testing" @@ -301,3 +302,66 @@ func TestDetermineManagedAccountUsesUpdatedRefreshInterval(t *testing.T) { t.Fatalf("expected exactly one login after runtime update, got %d", got) } } + +func TestDetermineManagedAccountRetriesOtherAccountOnLoginFailure(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["managed-key"], + "accounts":[ + {"email":"bad@example.com","password":"pwd"}, + {"email":"good@example.com","password":"pwd","token":"good-token"} + ] + }`) + store := config.LoadStore() + pool := account.NewPool(store) + resolver := NewResolver(store, pool, func(_ context.Context, acc config.Account) (string, error) { + if acc.Email == "bad@example.com" { + return "", errors.New("stale account") + } + return "fresh-good-token", nil + }) + + req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + req.Header.Set("x-api-key", "managed-key") + + a, err := resolver.Determine(req) + if err != nil { + t.Fatalf("determine failed: %v", err) + } + defer resolver.Release(a) + if a.AccountID != "good@example.com" { + t.Fatalf("expected fallback to good account, got %q", a.AccountID) + } + if a.DeepSeekToken == "" { + t.Fatal("expected non-empty token from fallback account") + } + if !a.TriedAccounts["bad@example.com"] { + t.Fatalf("expected bad account to be tracked as tried") + } +} + +func TestDetermineTargetAccountDoesNotFallbackOnLoginFailure(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["managed-key"], + "accounts":[ + {"email":"bad@example.com","password":"pwd"}, + {"email":"good@example.com","password":"pwd","token":"good-token"} + ] + }`) + store := config.LoadStore() + pool := account.NewPool(store) + resolver := NewResolver(store, pool, func(_ context.Context, acc config.Account) (string, error) { + if acc.Email == "bad@example.com" { + return "", errors.New("stale account") + } + return "fresh-good-token", nil + }) + + req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + req.Header.Set("x-api-key", "managed-key") + req.Header.Set("X-Ds2-Target-Account", "bad@example.com") + + _, err := resolver.Determine(req) + if err == nil { + t.Fatal("expected determine to fail for broken target account") + } +} From e60738b084a2a8c9a3e442a3946f6d95a121d828 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 12:58:09 +0800 Subject: [PATCH 06/16] auth: preserve ensure error after retry exhaustion --- internal/auth/request.go | 8 ++++++++ internal/auth/request_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/internal/auth/request.go b/internal/auth/request.go index 9a147d2..e6a0d88 100644 --- a/internal/auth/request.go +++ b/internal/auth/request.go @@ -79,12 +79,19 @@ func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) { func (r *Resolver) acquireManagedRequestAuth(ctx context.Context, callerID, target string) (*RequestAuth, error) { tried := map[string]bool{} + var lastEnsureErr error for { if target == "" && len(tried) >= len(r.Store.Accounts()) { + if lastEnsureErr != nil { + return nil, lastEnsureErr + } return nil, ErrNoAccount } acc, ok := r.Pool.AcquireWait(ctx, target, tried) if !ok { + if lastEnsureErr != nil { + return nil, lastEnsureErr + } return nil, ErrNoAccount } @@ -98,6 +105,7 @@ func (r *Resolver) acquireManagedRequestAuth(ctx context.Context, callerID, targ } if err := r.ensureManagedToken(ctx, a); err != nil { + lastEnsureErr = err tried[a.AccountID] = true r.Pool.Release(a.AccountID) if target != "" { diff --git a/internal/auth/request_test.go b/internal/auth/request_test.go index d8f36b3..edf163d 100644 --- a/internal/auth/request_test.go +++ b/internal/auth/request_test.go @@ -365,3 +365,33 @@ func TestDetermineTargetAccountDoesNotFallbackOnLoginFailure(t *testing.T) { t.Fatal("expected determine to fail for broken target account") } } + +func TestDetermineManagedAccountReturnsLastEnsureErrorWhenAllFail(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["managed-key"], + "accounts":[ + {"email":"bad1@example.com","password":"pwd"}, + {"email":"bad2@example.com","password":"pwd"} + ] + }`) + store := config.LoadStore() + pool := account.NewPool(store) + ensureErr := errors.New("all credentials stale") + resolver := NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) { + return "", ensureErr + }) + + req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + req.Header.Set("x-api-key", "managed-key") + + _, err := resolver.Determine(req) + if err == nil { + t.Fatal("expected determine to fail") + } + if !errors.Is(err, ensureErr) { + t.Fatalf("expected ensure error, got %v", err) + } + if errors.Is(err, ErrNoAccount) { + t.Fatalf("expected auth-style ensure error, got ErrNoAccount") + } +} From 1289e8afd8cc5ac6cf9fbcb8f1a290aaded9bbbd Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 12:59:05 +0800 Subject: [PATCH 07/16] fix(webui): make API key copy action reliable --- webui/src/features/account/ApiKeysPanel.jsx | 71 ++++++++++++++++++--- webui/src/locales/en.json | 1 + webui/src/locales/zh.json | 1 + 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/webui/src/features/account/ApiKeysPanel.jsx b/webui/src/features/account/ApiKeysPanel.jsx index 301d826..956c0fa 100644 --- a/webui/src/features/account/ApiKeysPanel.jsx +++ b/webui/src/features/account/ApiKeysPanel.jsx @@ -1,6 +1,31 @@ +import { useState } from 'react' import { Check, ChevronDown, Copy, Plus, Trash2 } from 'lucide-react' import clsx from 'clsx' +function fallbackCopyText(text) { + const textArea = document.createElement('textarea') + textArea.value = text + textArea.setAttribute('readonly', '') + textArea.style.position = 'fixed' + textArea.style.top = '-9999px' + textArea.style.left = '-9999px' + + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + + let copied = false + try { + copied = document.execCommand('copy') + } finally { + document.body.removeChild(textArea) + } + + if (!copied) { + throw new Error('copy failed') + } +} + export default function ApiKeysPanel({ t, config, @@ -11,6 +36,31 @@ export default function ApiKeysPanel({ setCopiedKey, onDeleteKey, }) { + const [failedKey, setFailedKey] = useState(null) + + const handleCopyKey = async (key) => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(key) + } else { + fallbackCopyText(key) + } + setCopiedKey(key) + setFailedKey(null) + setTimeout(() => setCopiedKey(null), 2000) + } catch { + try { + fallbackCopyText(key) + setCopiedKey(key) + setFailedKey(null) + setTimeout(() => setCopiedKey(null), 2000) + } catch { + setFailedKey(key) + setTimeout(() => setFailedKey(null), 2500) + } + } + } + return (
(
-
+
+ {copiedKey === key && ( {t('accountManager.copied')} )} + {failedKey === key && ( + {t('accountManager.copyFailed')} + )}