Compare commits

...

49 Commits

Author SHA1 Message Date
CJACK.
6e8f3185d5 Merge pull request #157 from CJackHwang/codex/analyze-toolcall-output-formatting-issue
Sanitize leaked tool-call wire format in assistant text
2026-03-22 22:46:07 +08:00
CJACK.
0925e83b9b Stop embedding tool-call envelopes into prompt content 2026-03-22 22:36:15 +08:00
CJACK.
87c231e736 Sanitize leaked tool-call wire format in assistant text 2026-03-22 22:17:40 +08:00
CJACK.
5887821a9d Merge pull request #153 from CJackHwang/codex/investigate-tool-execution-bugs-in-output-7ocr8f
Relax tool-name allow-listing and improve tool-call detection/parsing across adapters and sieve
2026-03-22 21:26:55 +08:00
CJACK.
7794006513 Update VERSION 2026-03-22 21:26:34 +08:00
CJACK.
47d4499d47 Merge pull request #155 from CJackHwang/codex/review-and-fix-pr-#153-issues
Sync tool-call compat fixtures and update node test to match permissive tool-call policy
2026-03-22 21:25:18 +08:00
CJACK.
15891ddc25 Fix quality-gate fixture drift for permissive tool-call policy 2026-03-22 21:24:06 +08:00
CJACK.
97a81c4191 Harden toolcall leak interception for function-style payloads 2026-03-22 20:07:12 +08:00
CJACK.
b0a09dfab0 Merge pull request #149 from CJackHwang/codex/fix-tool-miscall-during-complex-json-test
Ignore tool_call payloads inside fenced code blocks and chat envelopes; stream-aware code-fence tracking
2026-03-22 16:50:44 +08:00
CJACK.
58f753d0c0 Merge pull request #150 from CJackHwang/codex/fix-markup-bypass-in-tool-call-parsing
Do not promote fenced code examples to tool calls and centralize tool-keyword detection
2026-03-22 16:36:39 +08:00
CJACK.
2e0586d060 Merge branch 'codex/fix-tool-miscall-during-complex-json-test' into codex/fix-markup-bypass-in-tool-call-parsing 2026-03-22 16:32:43 +08:00
CJACK.
1676c8e4f2 Add backward-compatible aliases for renamed fenced-example tests 2026-03-22 16:25:03 +08:00
CJACK.
add13366d2 Split parse syntax markers to shared keyword module 2026-03-22 15:55:47 +08:00
CJACK.
d5a23191f2 Refactor stream sieve keyword scanning into shared helper 2026-03-22 15:55:38 +08:00
CJACK.
d2d4e39983 Fix refactor line gate for stream tool sieve helper 2026-03-22 15:28:51 +08:00
CJACK.
6e0dca3b30 Update VERSION 2026-03-22 15:16:29 +08:00
CJACK.
b108a7915a Support nested fenced blocks in stream fence tracking 2026-03-22 15:12:55 +08:00
CJACK.
2caabd8ce6 Add files via upload 2026-03-22 14:18:08 +08:00
CJACK.
6802a3d53e Fix Claude tool block normalization and tool_result fidelity 2026-03-22 13:42:01 +08:00
CJACK.
e828006cb0 Merge pull request #147 from CJackHwang/codex/fix-tool-call-history-retrieval
Preserve tool call/result roundtrip and raw payloads across Claude, Gemini and OpenAI adapters
2026-03-22 13:06:23 +08:00
CJACK.
a6499cbece Split Claude sanitize helpers to satisfy refactor line gate 2026-03-22 13:05:41 +08:00
CJACK.
a504905626 Fix Claude/Gemini prompt flattening for tool history and binary parts 2026-03-22 12:47:00 +08:00
CJACK.
59bf78d2c4 Unify adapter message normalization across Claude and Gemini 2026-03-22 12:07:58 +08:00
CJACK.
6cf4f0528c Merge pull request #145 from CJackHwang/codex/determine-which-pr-fixes-json-leak-issue
Merge pull request #144 from CJackHwang/codex/refactor-codebase-to-remove-redundancy

Refactor tool-sieve and response streaming, remove unused helpers and UI wrappers
2026-03-22 10:59:31 +08:00
CJACK.
d8f8dcb704 Merge pull request #144 from CJackHwang/codex/refactor-codebase-to-remove-redundancy
Refactor tool-sieve and response streaming, remove unused helpers and UI wrappers
2026-03-22 10:39:36 +08:00
CJACK.
455489ffeb ci: upgrade GitHub Actions Node runtime to 24 2026-03-22 10:38:18 +08:00
CJACK.
5031ae0e6f ci: align refactor line gate with removed files 2026-03-22 10:38:08 +08:00
CJACK.
3fccec0e22 test: remove unused asFloat helper 2026-03-22 10:24:11 +08:00
CJACK.
00d38f1187 fix: parse claude tool_use function/parameter format 2026-03-22 09:58:29 +08:00
CJACK.
fe0f3d2c17 fix: strip empty json fences from sanitized stream text 2026-03-22 09:29:21 +08:00
CJACK.
f67cbfad35 fix: stop instructing fenced JSON for tool calls 2026-03-22 09:25:01 +08:00
CJACK.
9afc533153 Merge pull request #141 from CJackHwang/codex/investigate-json-leakage-in-vercel-deployment-rh84s1
Fix raw tool-call JSON leaks when feature_match mode is off
2026-03-22 08:38:18 +08:00
CJACK.
6a39543288 fix tool-call json leaks when feature_match is disabled 2026-03-22 08:29:01 +08:00
CJACK.
8fa1f998aa Merge pull request #139 from CJackHwang/codex/fix-issues-from-codex-review
[Follow-up] Preserve empty tool completion turns in OpenAI prompt normalization
2026-03-22 01:26:43 +08:00
CJACK.
f8936887d0 fix(openai): preserve empty tool completion turns 2026-03-22 01:19:17 +08:00
CJACK.
db89744055 Merge branch 'main' into dev 2026-03-22 01:07:14 +08:00
CJACK.
65312fc573 Merge pull request #135 from CJackHwang/codex/add-global-token-refresh-logic
Sanitize leaked tool-history markers, simplify normalization, and add managed token refresh
2026-03-22 01:05:10 +08:00
CJACK.
661d753fd3 Merge pull request #137 from CJackHwang/codex/optimize-configuration-file-management
Make account `test_status` runtime-only (in-memory cache)
2026-03-22 01:04:42 +08:00
CJACK.
7ca3f141c6 Pass refactor line gate for tool sieve files 2026-03-22 01:04:01 +08:00
CJACK.
d530d25793 Expand history-sanitize boundary coverage for stream chunks 2026-03-22 00:57:13 +08:00
CJACK.
990cdcf02d refactor config: keep account test status runtime-only 2026-03-22 00:49:53 +08:00
CJACK.
648bb74587 Fix streaming whitespace trim and capture TOOL_RESULT_HISTORY 2026-03-22 00:44:44 +08:00
CJACK.
9e5baed061 Merge pull request #136 from CJackHwang/codex/add-file-import-and-export-for-project-config
feat(webui): add config backup download and file-based import in Settings
2026-03-22 00:31:30 +08:00
CJACK.
4884773639 feat(webui): support backup file export and import 2026-03-22 00:29:01 +08:00
CJACK.
6758514c61 chore: remove obsolete openai tool-history normalization helpers 2026-03-22 00:28:32 +08:00
CJACK.
55f11e655a Update VERSION 2026-03-21 18:04:11 +08:00
CJACK.
2275e931f9 Merge pull request #133 from CJackHwang/dev
Merge pull request #132 from CJackHwang/codex/toolcallhistory-6t7271

Preserve code fences around standalone tool JSON and add marker-output guards
2026-03-21 17:54:56 +08:00
CJACK.
ce1b76c90f Merge pull request #126 from CJackHwang/dev
Merge pull request #125 from CJackHwang/codex/align-documentation-with-configuration-updates

Docs: add `auto_delete.sessions`, rename `claude_model_mapping` to `claude_mapping`, and clarify config token handling
2026-03-21 15:44:28 +08:00
CJACK.
696b403173 Merge pull request #123 from CJackHwang/dev
Merge pull request #122 from CJackHwang/codex/refactor-configuration-to-remove-token-support

Treat account tokens as runtime-only; remove token-only account support and always refresh tokens on admin actions
2026-03-21 15:14:17 +08:00
77 changed files with 2338 additions and 1121 deletions

View File

@@ -24,7 +24,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: "npm"
cache-dependency-path: webui/package-lock.json

View File

@@ -32,7 +32,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: "npm"
cache-dependency-path: webui/package-lock.json

View File

@@ -1 +1 @@
2.3.8
2.4.1

View File

@@ -358,7 +358,41 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
}
}
func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.T) {
func TestHandleClaudeStreamRealtimeDetectsToolUseWithLeadingProse(t *testing.T) {
h := &Handler{}
payload := "I'll call a tool now.\\n<tool_use><tool_name>write_file</tool_name><parameters>{\\\"path\\\":\\\"/tmp/a.txt\\\",\\\"content\\\":\\\"abc\\\"}</parameters></tool_use>"
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/content","v":"`+payload+`"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"write_file"})
frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" && contentBlock["name"] == "write_file" {
foundToolUse = true
break
}
}
if !foundToolUse {
t.Fatalf("expected tool_use block with leading prose payload, body=%s", rec.Body.String())
}
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "tool_use" {
return
}
}
t.Fatalf("expected stop_reason=tool_use, body=%s", rec.Body.String())
}
func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"Here is an example:\\n```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"Bash\\\",\\\"input\\\":{\\\"command\\\":\\\"pwd\\\"}}]}\"}",
@@ -379,8 +413,8 @@ func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.
break
}
}
if !foundToolUse {
t.Fatalf("expected tool_use for fenced example, body=%s", rec.Body.String())
if foundToolUse {
t.Fatalf("expected no tool_use for fenced example, body=%s", rec.Body.String())
}
foundToolStop := false
@@ -391,7 +425,12 @@ func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.
break
}
}
if !foundToolStop {
t.Fatalf("expected stop_reason=tool_use, body=%s", rec.Body.String())
if foundToolStop {
t.Fatalf("expected stop_reason to remain content-only, body=%s", rec.Body.String())
}
}
// Backward-compatible alias for historical test name used in CI logs.
func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.T) {
TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t)
}

View File

@@ -48,10 +48,85 @@ func TestNormalizeClaudeMessagesToolResult(t *testing.T) {
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "tool" {
t.Fatalf("expected tool role preserved, got %#v", m["role"])
}
content, _ := m["content"].(string)
if !strings.Contains(content, "[TOOL_RESULT_HISTORY]") || !strings.Contains(content, "content: tool output") {
t.Fatalf("expected serialized tool result marker, got %q", content)
if content != "tool output" {
t.Fatalf("expected raw tool output content preserved, got %q", content)
}
}
func TestNormalizeClaudeMessagesToolUseToAssistantToolCalls(t *testing.T) {
msgs := []any{
map[string]any{
"role": "assistant",
"content": []any{
map[string]any{
"type": "tool_use",
"id": "call_1",
"name": "search_web",
"input": map[string]any{"query": "latest"},
},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized tool-call message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "assistant" {
t.Fatalf("expected assistant role, got %#v", m["role"])
}
tc, _ := m["tool_calls"].([]any)
if len(tc) != 1 {
t.Fatalf("expected one tool call, got %#v", m["tool_calls"])
}
call, _ := tc[0].(map[string]any)
if call["id"] != "call_1" {
t.Fatalf("expected call id preserved, got %#v", call)
}
content, _ := m["content"].(string)
if !containsStr(content, "search_web") || !containsStr(content, `"arguments":"{\"query\":\"latest\"}"`) {
t.Fatalf("expected assistant content to include serialized tool call for prompt roundtrip, got %q", content)
}
}
func TestNormalizeClaudeMessagesDoesNotPromoteUserToolUse(t *testing.T) {
msgs := []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_use",
"id": "call_unsafe",
"name": "dangerous_tool",
"input": map[string]any{"value": "x"},
},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "user" {
t.Fatalf("expected user role preserved, got %#v", m["role"])
}
if _, ok := m["tool_calls"]; ok {
t.Fatalf("expected no tool_calls promotion for user message, got %#v", m["tool_calls"])
}
content, _ := m["content"].(string)
if !containsStr(content, `"type":"tool_use"`) || !containsStr(content, "dangerous_tool") {
t.Fatalf("expected raw tool_use block preserved in user content, got %q", content)
}
}
@@ -87,15 +162,63 @@ func TestNormalizeClaudeMessagesMixedContentBlocks(t *testing.T) {
"role": "user",
"content": []any{
map[string]any{"type": "text", "text": "Hello"},
map[string]any{"type": "image", "source": "data:..."},
map[string]any{"type": "image", "source": map[string]any{"type": "base64", "data": strings.Repeat("A", 2048)}},
map[string]any{"type": "text", "text": "World"},
},
},
}
got := normalizeClaudeMessages(msgs)
m := got[0].(map[string]any)
if m["content"] != "Hello\nWorld" {
t.Fatalf("expected only text parts joined, got %q", m["content"])
content, _ := m["content"].(string)
if !containsStr(content, "Hello") || !containsStr(content, "World") || !containsStr(content, `"type":"image"`) {
t.Fatalf("expected text plus non-text block marker preserved, got %q", content)
}
if !containsStr(content, omittedBinaryMarker) {
t.Fatalf("expected binary payload omitted marker, got %q", content)
}
if containsStr(content, strings.Repeat("A", 100)) {
t.Fatalf("expected raw base64 payload not to be included, got %q", content)
}
}
func TestNormalizeClaudeMessagesToolResultNonTextPayloadStringified(t *testing.T) {
msgs := []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "call_image_1",
"name": "vision_tool",
"content": []any{
map[string]any{"type": "text", "text": "image analysis"},
map[string]any{
"type": "image",
"source": map[string]any{"type": "base64", "media_type": "image/png", "data": strings.Repeat("B", 2048)},
},
},
},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "tool" {
t.Fatalf("expected tool role, got %#v", m["role"])
}
content, _ := m["content"].(string)
if !containsStr(content, `"type":"tool_result"`) || !containsStr(content, `"type":"image"`) {
t.Fatalf("expected non-text tool_result payload to be JSON stringified, got %q", content)
}
if !containsStr(content, omittedBinaryMarker) {
t.Fatalf("expected binary data to be sanitized with omitted marker, got %q", content)
}
if containsStr(content, strings.Repeat("B", 100)) {
t.Fatalf("expected raw base64 payload not to be included, got %q", content)
}
}
@@ -128,11 +251,11 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
if !containsStr(prompt, "tool_use") {
t.Fatalf("expected tool_use instruction in prompt")
}
if !containsStr(prompt, "Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers yourself") {
t.Fatalf("expected marker guard instruction in prompt")
if containsStr(prompt, "TOOL_CALL_HISTORY") || containsStr(prompt, "TOOL_RESULT_HISTORY") {
t.Fatalf("expected legacy tool history markers removed from prompt")
}
if containsStr(prompt, "tool_calls") {
t.Fatalf("expected prompt to avoid tool_calls JSON instruction")
if !containsStr(prompt, "Do not print tool-call JSON in text") {
t.Fatalf("expected prompt to keep no tool-call-json instruction")
}
}

View File

@@ -13,28 +13,58 @@ func normalizeClaudeMessages(messages []any) []any {
if !ok {
continue
}
copied := cloneMap(msg)
role := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", msg["role"])))
switch content := msg["content"].(type) {
case []any:
parts := make([]string, 0, len(content))
textParts := make([]string, 0, len(content))
flushText := func() {
if len(textParts) == 0 {
return
}
out = append(out, map[string]any{
"role": role,
"content": strings.Join(textParts, "\n"),
})
textParts = textParts[:0]
}
for _, block := range content {
b, ok := block.(map[string]any)
if !ok {
continue
}
typeStr, _ := b["type"].(string)
if typeStr == "text" {
typeStr := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", b["type"])))
switch typeStr {
case "text":
if t, ok := b["text"].(string); ok {
parts = append(parts, t)
textParts = append(textParts, t)
}
case "tool_use":
if role == "assistant" {
flushText()
if toolMsg := normalizeClaudeToolUseToAssistant(b); toolMsg != nil {
out = append(out, toolMsg)
}
continue
}
if raw := strings.TrimSpace(formatClaudeUnknownBlockForPrompt(b)); raw != "" {
textParts = append(textParts, raw)
}
case "tool_result":
flushText()
if toolMsg := normalizeClaudeToolResultToToolMessage(b); toolMsg != nil {
out = append(out, toolMsg)
}
default:
if raw := strings.TrimSpace(formatClaudeUnknownBlockForPrompt(b)); raw != "" {
textParts = append(textParts, raw)
}
}
if typeStr == "tool_result" {
parts = append(parts, formatClaudeToolResultForPrompt(b))
}
}
copied["content"] = strings.Join(parts, "\n")
flushText()
default:
copied := cloneMap(msg)
out = append(out, copied)
}
out = append(out, copied)
}
return out
}
@@ -52,9 +82,8 @@ func buildClaudeToolPrompt(tools []any) string {
}
parts = append(parts,
"When you need a tool, respond with Claude-native tool use (tool_use) using the provided tool schema. Do not print tool-call JSON in text.",
"History markers in conversation: [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] are your previous tool calls; [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] are runtime tool outputs, not user input.",
"After a valid [TOOL_RESULT_HISTORY], continue with final answer instead of repeating the same call unless required fields are still missing.",
"Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers yourself; they are system-side context only.",
"Tool roundtrip context is included directly in the conversation messages (assistant tool_use/tool_calls and tool results).",
"After receiving a valid tool result, continue with final answer instead of repeating the same call unless required fields are still missing.",
)
return strings.Join(parts, "\n\n")
}
@@ -63,22 +92,111 @@ func formatClaudeToolResultForPrompt(block map[string]any) string {
if block == nil {
return ""
}
payload := map[string]any{
"type": "tool_result",
"content": block["content"],
}
if toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"])); toolCallID != "" {
payload["tool_call_id"] = toolCallID
} else if toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_call_id"])); toolCallID != "" {
payload["tool_call_id"] = toolCallID
}
if name := strings.TrimSpace(fmt.Sprintf("%v", block["name"])); name != "" {
payload["name"] = name
}
b, err := json.Marshal(payload)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", payload))
}
return string(b)
}
func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any {
if block == nil {
return nil
}
name := strings.TrimSpace(fmt.Sprintf("%v", block["name"]))
if name == "" {
return nil
}
callID := strings.TrimSpace(fmt.Sprintf("%v", block["id"]))
if callID == "" {
callID = strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"]))
}
if callID == "" {
callID = "call_claude"
}
arguments := block["input"]
if arguments == nil {
arguments = map[string]any{}
}
argsJSON, err := json.Marshal(arguments)
if err != nil || len(argsJSON) == 0 {
argsJSON = []byte("{}")
}
toolCalls := []any{
map[string]any{
"id": callID,
"type": "function",
"function": map[string]any{
"name": name,
"arguments": string(argsJSON),
},
},
}
return map[string]any{
"role": "assistant",
"content": marshalCompactJSON(toolCalls),
"tool_calls": toolCalls,
}
}
func normalizeClaudeToolResultToToolMessage(block map[string]any) map[string]any {
if block == nil {
return nil
}
toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"]))
if toolCallID == "" {
toolCallID = strings.TrimSpace(fmt.Sprintf("%v", block["tool_call_id"]))
}
if toolCallID == "" {
toolCallID = "unknown"
toolCallID = "call_claude"
}
name := strings.TrimSpace(fmt.Sprintf("%v", block["name"]))
if name == "" {
name = "unknown"
out := map[string]any{
"role": "tool",
"tool_call_id": toolCallID,
"content": normalizeClaudeToolResultContent(block["content"]),
}
content := strings.TrimSpace(fmt.Sprintf("%v", block["content"]))
if content == "" {
content = "null"
if name := strings.TrimSpace(fmt.Sprintf("%v", block["name"])); name != "" {
out["name"] = name
}
return fmt.Sprintf("[TOOL_RESULT_HISTORY]\nstatus: already_returned\norigin: tool_runtime\nnot_user_input: true\ntool_call_id: %s\nname: %s\ncontent: %s\n[/TOOL_RESULT_HISTORY]", toolCallID, name, content)
return out
}
func normalizeClaudeToolResultContent(content any) any {
if text, ok := content.(string); ok {
return text
}
payload := map[string]any{
"type": "tool_result",
"content": content,
}
b, err := json.Marshal(sanitizeClaudeBlockForPrompt(payload))
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", content))
}
return string(b)
}
func formatClaudeBlockRaw(block map[string]any) string {
if block == nil {
return ""
}
b, err := json.Marshal(block)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", block))
}
return string(b)
}
func hasSystemMessage(messages []any) bool {

View File

@@ -0,0 +1,105 @@
package claude
import (
"encoding/json"
"fmt"
"strings"
)
const (
maxClaudeRawPromptChars = 1024
omittedBinaryMarker = "[omitted_binary_payload]"
)
func formatClaudeUnknownBlockForPrompt(block map[string]any) string {
if block == nil {
return ""
}
safe := sanitizeClaudeBlockForPrompt(block)
raw := strings.TrimSpace(formatClaudeBlockRaw(safe))
if raw == "" {
return ""
}
if len(raw) > maxClaudeRawPromptChars {
return raw[:maxClaudeRawPromptChars] + "...(truncated)"
}
return raw
}
func sanitizeClaudeBlockForPrompt(block map[string]any) map[string]any {
out := cloneMap(block)
for k, v := range out {
if looksLikeBinaryFieldName(k) {
out[k] = omittedBinaryMarker
continue
}
switch inner := v.(type) {
case map[string]any:
out[k] = sanitizeClaudeBlockForPrompt(inner)
case []any:
out[k] = sanitizeClaudeArrayForPrompt(inner)
case string:
out[k] = sanitizeClaudeStringForPrompt(k, inner)
}
}
return out
}
func sanitizeClaudeArrayForPrompt(items []any) []any {
out := make([]any, 0, len(items))
for _, item := range items {
switch v := item.(type) {
case map[string]any:
out = append(out, sanitizeClaudeBlockForPrompt(v))
case []any:
out = append(out, sanitizeClaudeArrayForPrompt(v))
default:
out = append(out, v)
}
}
return out
}
func sanitizeClaudeStringForPrompt(key, value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if looksLikeBinaryFieldName(key) || looksLikeBase64Payload(trimmed) {
return omittedBinaryMarker
}
if len(trimmed) > maxClaudeRawPromptChars {
return trimmed[:maxClaudeRawPromptChars] + "...(truncated)"
}
return trimmed
}
func looksLikeBinaryFieldName(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
return n == "data" || n == "bytes" || n == "base64" || n == "inline_data" || n == "inlinedata"
}
func looksLikeBase64Payload(v string) bool {
if len(v) < 512 {
return false
}
compact := strings.TrimRight(v, "=")
if compact == "" {
return false
}
for _, ch := range compact {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '+' || ch == '/' || ch == '-' || ch == '_' {
continue
}
return false
}
return true
}
func marshalCompactJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
return string(b)
}

View File

@@ -38,6 +38,9 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
}
finalPrompt := deepseek.MessagesPrepare(toMessageMaps(dsPayload["messages"]))
toolNames := extractClaudeToolNames(toolsRequested)
if len(toolNames) == 0 && len(toolsRequested) > 0 {
toolNames = []string{"__any_tool__"}
}
return claudeNormalizedRequest{
Standard: util.StandardRequest{

View File

@@ -8,7 +8,6 @@ import (
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
"ds2api/internal/util"
)
type claudeStreamRuntime struct {
@@ -120,15 +119,6 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
if hasUnclosedCodeFence(s.text.String()) {
continue
}
detected := util.ParseToolCalls(s.text.String(), s.toolNames)
if len(detected) > 0 {
s.finalize("tool_use")
return streamengine.ParsedDecision{
ContentSeen: true,
Stop: true,
StopReason: streamengine.StopReason("tool_use_detected"),
}
}
continue
}
s.closeThinkingBlock()

View File

@@ -45,9 +45,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
finalText := s.text.String()
if s.bufferToolContent {
detected := util.ParseToolCalls(finalText, s.toolNames)
detected := util.ParseStandaloneToolCalls(finalText, s.toolNames)
if len(detected) == 0 && finalText == "" && finalThinking != "" {
detected = util.ParseToolCalls(finalThinking, s.toolNames)
detected = util.ParseStandaloneToolCalls(finalThinking, s.toolNames)
}
if len(detected) > 0 {
stopReason = "tool_use"

View File

@@ -2,6 +2,8 @@ package gemini
import "strings"
const maxGeminiRawPromptChars = 1024
func geminiMessagesFromRequest(req map[string]any) []any {
out := make([]any, 0, 8)
if sys := normalizeGeminiSystemInstruction(req["systemInstruction"]); strings.TrimSpace(sys) != "" {
@@ -107,6 +109,11 @@ func geminiMessagesFromRequest(req map[string]any) []any {
msg["name"] = name
}
out = append(out, msg)
continue
}
if raw := strings.TrimSpace(formatGeminiUnknownPartForPrompt(part)); raw != "" && raw != "null" {
textParts = append(textParts, raw)
}
}
flushText()
@@ -151,3 +158,87 @@ func mapGeminiRole(v any) string {
return ""
}
}
func formatGeminiUnknownPartForPrompt(part map[string]any) string {
safe := sanitizeGeminiPartForPrompt(part)
raw := strings.TrimSpace(stringifyJSON(safe))
if raw == "" {
return ""
}
if len(raw) > maxGeminiRawPromptChars {
return raw[:maxGeminiRawPromptChars] + "...(truncated)"
}
return raw
}
func sanitizeGeminiPartForPrompt(part map[string]any) map[string]any {
out := make(map[string]any, len(part))
for k, v := range part {
if looksLikeGeminiBinaryField(k) {
out[k] = "[omitted_binary_payload]"
continue
}
switch x := v.(type) {
case map[string]any:
out[k] = sanitizeGeminiPartForPrompt(x)
case []any:
out[k] = sanitizeGeminiArrayForPrompt(x)
case string:
out[k] = sanitizeGeminiStringForPrompt(k, x)
default:
out[k] = v
}
}
return out
}
func sanitizeGeminiArrayForPrompt(items []any) []any {
out := make([]any, 0, len(items))
for _, item := range items {
switch x := item.(type) {
case map[string]any:
out = append(out, sanitizeGeminiPartForPrompt(x))
case []any:
out = append(out, sanitizeGeminiArrayForPrompt(x))
default:
out = append(out, x)
}
}
return out
}
func sanitizeGeminiStringForPrompt(key, value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if looksLikeGeminiBinaryField(key) || looksLikeGeminiBase64(trimmed) {
return "[omitted_binary_payload]"
}
if len(trimmed) > maxGeminiRawPromptChars {
return trimmed[:maxGeminiRawPromptChars] + "...(truncated)"
}
return trimmed
}
func looksLikeGeminiBinaryField(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
return n == "data" || n == "bytes" || n == "inlinedata" || n == "inline_data" || n == "base64"
}
func looksLikeGeminiBase64(v string) bool {
if len(v) < 512 {
return false
}
compact := strings.TrimRight(v, "=")
if compact == "" {
return false
}
for _, ch := range compact {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '+' || ch == '/' || ch == '-' || ch == '_' {
continue
}
return false
}
return true
}

View File

@@ -0,0 +1,84 @@
package gemini
import (
"strings"
"testing"
)
func TestGeminiMessagesFromRequestPreservesFunctionRoundtrip(t *testing.T) {
req := map[string]any{
"contents": []any{
map[string]any{
"role": "model",
"parts": []any{
map[string]any{
"functionCall": map[string]any{
"id": "call_g1",
"name": "search_web",
"args": map[string]any{"query": "ai"},
},
},
},
},
map[string]any{
"role": "user",
"parts": []any{
map[string]any{
"functionResponse": map[string]any{
"id": "call_g1",
"name": "search_web",
"response": "ok",
},
},
},
},
},
}
got := geminiMessagesFromRequest(req)
if len(got) != 2 {
t.Fatalf("expected two normalized messages, got %#v", got)
}
assistant, _ := got[0].(map[string]any)
if assistant["role"] != "assistant" {
t.Fatalf("expected assistant first, got %#v", assistant)
}
tc, _ := assistant["tool_calls"].([]any)
if len(tc) != 1 {
t.Fatalf("expected one tool call, got %#v", assistant["tool_calls"])
}
toolMsg, _ := got[1].(map[string]any)
if toolMsg["role"] != "tool" || toolMsg["tool_call_id"] != "call_g1" {
t.Fatalf("expected tool message with call id, got %#v", toolMsg)
}
}
func TestGeminiMessagesFromRequestPreservesUnknownPartAsRawJSONText(t *testing.T) {
req := map[string]any{
"contents": []any{
map[string]any{
"role": "user",
"parts": []any{
map[string]any{"text": "hello"},
map[string]any{"inlineData": map[string]any{"mimeType": "image/png", "data": strings.Repeat("A", 2048)}},
},
},
},
}
got := geminiMessagesFromRequest(req)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %#v", got)
}
msg, _ := got[0].(map[string]any)
content, _ := msg["content"].(string)
if !strings.Contains(content, "hello") || !strings.Contains(content, "inlineData") {
t.Fatalf("expected unknown part preserved as raw json text, got %q", content)
}
if !strings.Contains(content, "[omitted_binary_payload]") {
t.Fatalf("expected inlineData payload to be redacted, got %q", content)
}
if strings.Contains(content, strings.Repeat("A", 100)) {
t.Fatalf("expected raw base64 payload not to be embedded, got %q", content)
}
}

View File

@@ -97,7 +97,7 @@ func (s *chatStreamRuntime) sendDone() {
func (s *chatStreamRuntime) finalize(finishReason string) {
finalThinking := s.thinking.String()
finalText := s.text.String()
finalText := sanitizeLeakedToolHistory(s.text.String())
detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
finishReason = "tool_calls"
@@ -141,8 +141,12 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
if evt.Content == "" {
continue
}
cleaned := sanitizeLeakedToolHistory(evt.Content)
if cleaned == "" {
continue
}
delta := map[string]any{
"content": evt.Content,
"content": cleaned,
}
if !s.firstChunkSent {
delta["role"] = "assistant"
@@ -246,8 +250,12 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
continue
}
if evt.Content != "" {
cleaned := sanitizeLeakedToolHistory(evt.Content)
if cleaned == "" {
continue
}
contentDelta := map[string]any{
"content": evt.Content,
"content": cleaned,
}
if !s.firstChunkSent {
contentDelta["role"] = "assistant"

View File

@@ -105,7 +105,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re
result := sse.CollectStream(resp, thinkingEnabled, true)
finalThinking := result.Thinking
finalText := result.Text
finalText := sanitizeLeakedToolHistory(result.Text)
respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames)
writeJSON(w, http.StatusOK, respBody)
}
@@ -128,8 +128,8 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt
}
created := time.Now().Unix()
bufferToolContent := len(toolNames) > 0 && h.toolcallFeatureMatchEnabled()
emitEarlyToolDeltas := h.toolcallEarlyEmitHighConfidence()
bufferToolContent := len(toolNames) > 0
emitEarlyToolDeltas := h.toolcallFeatureMatchEnabled() && h.toolcallEarlyEmitHighConfidence()
initialType := "text"
if thinkingEnabled {
initialType = "thinking"

View File

@@ -53,13 +53,13 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh
if len(toolSchemas) == 0 {
return messages, names
}
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY a JSON code block like this:\n```json\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n```\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n```json\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n```\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON code block. The response must start with ```json and end with ```.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block.\n5) Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers in your answer; these markers are system-side context only.\n6) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n7) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets."
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY this JSON object format:\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON object above. Do NOT include any extra text.\n2) Do NOT wrap tool-call JSON in markdown/code fences (for example, do not use triple backticks).\n3) After receiving a tool result, you MUST use it to produce the final answer.\n4) Only call another tool when the previous result is missing required data or returned an error.\n5) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n6) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets."
if policy.Mode == util.ToolChoiceRequired {
toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list."
toolPrompt += "\n7) For this response, you MUST call at least one tool from the allowed list."
}
if policy.Mode == util.ToolChoiceForced && strings.TrimSpace(policy.ForcedName) != "" {
toolPrompt += "\n5) For this response, you MUST call exactly this tool name: " + strings.TrimSpace(policy.ForcedName)
toolPrompt += "\n6) Do not call any other tool."
toolPrompt += "\n7) For this response, you MUST call exactly this tool name: " + strings.TrimSpace(policy.ForcedName)
toolPrompt += "\n8) Do not call any other tool."
}
for i := range messages {
@@ -111,28 +111,21 @@ func filterIncrementalToolCallDeltasByAllowed(deltas []toolCallDelta, allowedNam
if len(deltas) == 0 {
return nil
}
allowed := namesToSet(allowedNames)
if len(allowed) == 0 {
for _, d := range deltas {
if d.Name != "" {
seenNames[d.Index] = "__blocked__"
}
}
return nil
}
out := make([]toolCallDelta, 0, len(deltas))
for _, d := range deltas {
if d.Name != "" {
if _, ok := allowed[d.Name]; !ok {
seenNames[d.Index] = "__blocked__"
continue
if seenNames != nil {
seenNames[d.Index] = d.Name
}
seenNames[d.Index] = d.Name
out = append(out, d)
continue
}
if seenNames == nil {
out = append(out, d)
continue
}
name := strings.TrimSpace(seenNames[d.Index])
if name == "" || name == "__blocked__" {
if name == "" {
continue
}
out = append(out, d)

View File

@@ -2,12 +2,6 @@ package openai
import "strings"
func applyOpenAIChatPassThrough(req map[string]any, payload map[string]any) {
for k, v := range collectOpenAIChatPassThrough(req) {
payload[k] = v
}
}
func (h *Handler) toolcallFeatureMatchEnabled() bool {
if h == nil || h.Store == nil {
return true

View File

@@ -182,7 +182,7 @@ func TestHandleNonStreamToolCallInterceptsReasonerModel(t *testing.T) {
}
}
func TestHandleNonStreamUnknownToolNotIntercepted(t *testing.T) {
func TestHandleNonStreamUnknownToolIntercepted(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
@@ -198,16 +198,13 @@ func TestHandleNonStreamUnknownToolNotIntercepted(t *testing.T) {
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "stop" {
t.Fatalf("expected finish_reason=stop, got %#v", choice["finish_reason"])
if choice["finish_reason"] != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
}
msg, _ := choice["message"].(map[string]any)
if _, ok := msg["tool_calls"]; ok {
t.Fatalf("did not expect tool_calls for unknown schema name, got %#v", msg["tool_calls"])
}
content, _ := msg["content"].(string)
if !strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected unknown tool json to pass through as text, got %#v", content)
toolCalls, _ := msg["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("expected tool_calls for unknown schema name, got %#v", msg["tool_calls"])
}
}
@@ -243,7 +240,7 @@ func TestHandleNonStreamEmbeddedToolCallExamplePromotesToolCall(t *testing.T) {
}
}
func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
func TestHandleNonStreamFencedToolCallExampleDoesNotPromoteToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"search\\\",\\\"input\\\":{\\\"q\\\":\\\"go\\\"}}]}\\n```\"}",
@@ -259,20 +256,25 @@ func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
if choice["finish_reason"] == "tool_calls" {
t.Fatalf("expected fenced example to remain content-only, got finish_reason=%#v", choice["finish_reason"])
}
msg, _ := choice["message"].(map[string]any)
toolCalls, _ := msg["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("expected one tool_call field for fenced example: %#v", msg["tool_calls"])
if len(toolCalls) != 0 {
t.Fatalf("expected no tool_call field for fenced example: %#v", msg["tool_calls"])
}
content, _ := msg["content"].(string)
if strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected raw tool_calls json stripped from content, got %q", content)
if !strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected fenced example content preserved, got %q", content)
}
}
// Backward-compatible alias for historical test name used in CI logs.
func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
TestHandleNonStreamFencedToolCallExampleDoesNotPromoteToolCall(t)
}
func TestHandleStreamToolCallInterceptsWithoutRawContentLeak(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
@@ -408,7 +410,7 @@ func TestHandleStreamReasonerToolCallInterceptsWithoutRawContentLeak(t *testing.
}
}
func TestHandleStreamUnknownToolDoesNotLeakRawPayload(t *testing.T) {
func TestHandleStreamUnknownToolEmitsToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
@@ -423,18 +425,18 @@ func TestHandleStreamUnknownToolDoesNotLeakRawPayload(t *testing.T) {
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if streamHasToolCallsDelta(frames) {
t.Fatalf("did not expect tool_calls delta for unknown schema name, body=%s", rec.Body.String())
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta for unknown schema name, body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name: %s", rec.Body.String())
}
if streamFinishReason(frames) != "stop" {
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamUnknownToolNoArgsDoesNotLeakRawPayload(t *testing.T) {
func TestHandleStreamUnknownToolNoArgsEmitsToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\"}]}"}`,
@@ -449,14 +451,14 @@ func TestHandleStreamUnknownToolNoArgsDoesNotLeakRawPayload(t *testing.T) {
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if streamHasToolCallsDelta(frames) {
t.Fatalf("did not expect tool_calls delta for unknown schema name (no args), body=%s", rec.Body.String())
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta for unknown schema name (no args), body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name (no args): %s", rec.Body.String())
}
if streamFinishReason(frames) != "stop" {
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}

View File

@@ -2,14 +2,13 @@ package openai
import (
"encoding/json"
"fmt"
"strings"
"ds2api/internal/config"
"ds2api/internal/prompt"
)
func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]any {
_ = traceID
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
msg, ok := item.(map[string]any)
@@ -19,20 +18,19 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
switch role {
case "assistant":
content := normalizeOpenAIContentForPrompt(msg["content"])
toolCalls := formatAssistantToolCallsForPrompt(msg, traceID)
combined := joinNonEmpty(content, toolCalls)
if combined == "" {
content := buildAssistantContentForPrompt(msg)
if content == "" {
continue
}
out = append(out, map[string]any{
"role": "assistant",
"content": combined,
"content": content,
})
case "tool", "function":
content := buildToolContentForPrompt(msg)
out = append(out, map[string]any{
"role": "user",
"content": formatToolResultForPrompt(msg),
"role": "tool",
"content": content,
})
case "user", "system", "developer":
out = append(out, map[string]any{
@@ -56,95 +54,22 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
return out
}
func formatAssistantToolCallsForPrompt(msg map[string]any, traceID string) string {
entries := make([]string, 0)
if calls, ok := msg["tool_calls"].([]any); ok {
for i, item := range calls {
call, ok := item.(map[string]any)
if !ok {
continue
}
id := strings.TrimSpace(asString(call["id"]))
if id == "" {
id = fmt.Sprintf("call_%d", i+1)
}
name := strings.TrimSpace(asString(call["name"]))
args := ""
if fn, ok := call["function"].(map[string]any); ok {
if name == "" {
name = strings.TrimSpace(asString(fn["name"]))
}
args = normalizeOpenAIArgumentsForPrompt(fn["arguments"])
}
if name == "" {
continue
}
if args == "" {
args = normalizeOpenAIArgumentsForPrompt(call["arguments"])
}
if args == "" {
args = normalizeOpenAIArgumentsForPrompt(call["input"])
}
if args == "" {
args = "{}"
}
maybeWarnSuspiciousToolHistory(traceID, id, name, args)
entries = append(entries, fmt.Sprintf("[TOOL_CALL_HISTORY]\nstatus: already_called\norigin: assistant\nnot_user_input: true\ntool_call_id: %s\nfunction.name: %s\nfunction.arguments: %s\n[/TOOL_CALL_HISTORY]", id, name, args))
}
}
if legacy, ok := msg["function_call"].(map[string]any); ok {
name := strings.TrimSpace(asString(legacy["name"]))
if name == "" {
name = "unknown"
}
args := normalizeOpenAIArgumentsForPrompt(legacy["arguments"])
if args == "" {
args = "{}"
}
maybeWarnSuspiciousToolHistory(traceID, "call_legacy", name, args)
entries = append(entries, fmt.Sprintf("[TOOL_CALL_HISTORY]\nstatus: already_called\norigin: assistant\nnot_user_input: true\ntool_call_id: call_legacy\nfunction.name: %s\nfunction.arguments: %s\n[/TOOL_CALL_HISTORY]", name, args))
}
return strings.Join(entries, "\n\n")
func buildAssistantContentForPrompt(msg map[string]any) string {
return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"]))
}
func formatToolResultForPrompt(msg map[string]any) string {
toolCallID := strings.TrimSpace(asString(msg["tool_call_id"]))
if toolCallID == "" {
toolCallID = strings.TrimSpace(asString(msg["id"]))
}
if toolCallID == "" {
toolCallID = "unknown"
}
name := strings.TrimSpace(asString(msg["name"]))
if name == "" {
name = "unknown"
}
func buildToolContentForPrompt(msg map[string]any) string {
content := normalizeOpenAIContentForPrompt(msg["content"])
if content == "" {
content = "null"
if strings.TrimSpace(content) == "" {
return "null"
}
return fmt.Sprintf("[TOOL_RESULT_HISTORY]\nstatus: already_returned\norigin: tool_runtime\nnot_user_input: true\ntool_call_id: %s\nname: %s\ncontent: %s\n[/TOOL_RESULT_HISTORY]", toolCallID, name, content)
return content
}
func normalizeOpenAIContentForPrompt(v any) string {
return prompt.NormalizeContent(v)
}
func normalizeOpenAIArgumentsForPrompt(v any) string {
switch x := v.(type) {
case string:
return normalizeToolArgumentString(x)
default:
return marshalToPromptString(v)
}
}
func normalizeToolArgumentString(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
@@ -157,14 +82,6 @@ func normalizeToolArgumentString(raw string) string {
return trimmed
}
func marshalToPromptString(v any) string {
b, err := json.Marshal(v)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
return string(b)
}
func normalizeOpenAIRoleForPrompt(role string) string {
role = strings.ToLower(strings.TrimSpace(role))
if role == "developer" {
@@ -180,34 +97,6 @@ func asString(v any) string {
return ""
}
func joinNonEmpty(parts ...string) string {
nonEmpty := make([]string, 0, len(parts))
for _, p := range parts {
if strings.TrimSpace(p) == "" {
continue
}
nonEmpty = append(nonEmpty, p)
}
return strings.Join(nonEmpty, "\n\n")
}
func maybeWarnSuspiciousToolHistory(traceID, callID, name, args string) {
if !looksLikeConcatenatedJSON(args) {
return
}
traceID = strings.TrimSpace(traceID)
if traceID == "" {
traceID = "unknown"
}
config.Logger.Warn(
"[openai] suspicious tool call history payload detected",
"trace_id", traceID,
"tool_call_id", strings.TrimSpace(callID),
"name", strings.TrimSpace(name),
"arguments_preview", previewToolArgs(args, 160),
)
}
func looksLikeConcatenatedJSON(raw string) bool {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
@@ -224,11 +113,3 @@ func looksLikeConcatenatedJSON(raw string) bool {
var second any
return dec.Decode(&second) == nil
}
func previewToolArgs(raw string, max int) string {
trimmed := strings.TrimSpace(raw)
if max <= 0 || len(trimmed) <= max {
return trimmed
}
return trimmed[:max]
}

View File

@@ -34,24 +34,20 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 4 {
t.Fatalf("expected 4 normalized messages, got %d", len(normalized))
if len(normalized) != 3 {
t.Fatalf("expected 3 normalized messages with tool-call-only assistant turn omitted, got %d", len(normalized))
}
assistantContent, _ := normalized[2]["content"].(string)
if !strings.Contains(assistantContent, "[TOOL_CALL_HISTORY]") ||
!strings.Contains(assistantContent, "tool_call_id: call_1") ||
!strings.Contains(assistantContent, "function.name: get_weather") ||
!strings.Contains(assistantContent, "function.arguments: {\"city\":\"beijing\"}") {
t.Fatalf("assistant tool call not serialized correctly: %q", assistantContent)
toolContent, _ := normalized[2]["content"].(string)
if !strings.Contains(toolContent, `"temp":18`) {
t.Fatalf("tool result should be transparently forwarded, got %q", toolContent)
}
toolContent, _ := normalized[3]["content"].(string)
if !strings.Contains(toolContent, "[TOOL_RESULT_HISTORY]") || !strings.Contains(toolContent, "name: get_weather") {
t.Fatalf("tool result not serialized correctly: %q", toolContent)
if strings.Contains(toolContent, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("tool history marker should not be injected: %q", toolContent)
}
prompt := util.MessagesPrepare(normalized)
if !strings.Contains(prompt, "tool_call_id: call_1") || !strings.Contains(prompt, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("expected prompt to include tool call + result semantics: %q", prompt)
if strings.Contains(prompt, "[TOOL_CALL_HISTORY]") || strings.Contains(prompt, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("expected no synthetic history markers in prompt: %q", prompt)
}
}
@@ -91,8 +87,8 @@ func TestNormalizeOpenAIMessagesForPrompt_ToolArrayBlocksJoined(t *testing.T) {
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, "line-1\nline-2") {
t.Fatalf("expected joined text blocks, got %q", got)
if !strings.Contains(got, `line-1`) || !strings.Contains(got, `line-2`) {
t.Fatalf("expected tool content blocks preserved, got %q", got)
}
}
@@ -112,15 +108,42 @@ func TestNormalizeOpenAIMessagesForPrompt_FunctionRoleCompatible(t *testing.T) {
if len(normalized) != 1 {
t.Fatalf("expected one normalized message, got %d", len(normalized))
}
if normalized[0]["role"] != "user" {
t.Fatalf("expected function role mapped to user, got %#v", normalized[0]["role"])
if normalized[0]["role"] != "tool" {
t.Fatalf("expected function role normalized as tool, got %#v", normalized[0]["role"])
}
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, "name: legacy_tool") || !strings.Contains(got, `"ok":true`) {
if !strings.Contains(got, `"ok":true`) || strings.Contains(got, `"name":"legacy_tool"`) {
t.Fatalf("unexpected normalized function-role content: %q", got)
}
}
func TestNormalizeOpenAIMessagesForPrompt_EmptyToolContentPreservedAsNull(t *testing.T) {
raw := []any{
map[string]any{
"role": "tool",
"tool_call_id": "call_5",
"name": "noop_tool",
"content": "",
},
map[string]any{
"role": "assistant",
"content": "done",
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 2 {
t.Fatalf("expected tool completion turn to be preserved, got %#v", normalized)
}
if normalized[0]["role"] != "tool" {
t.Fatalf("expected tool role preserved, got %#v", normalized[0]["role"])
}
got, _ := normalized[0]["content"].(string)
if got != "null" {
t.Fatalf("expected empty tool content normalized as null string, got %q", got)
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSeparated(t *testing.T) {
raw := []any{
map[string]any{
@@ -147,24 +170,8 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSepara
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized assistant message, got %d", len(normalized))
}
content, _ := normalized[0]["content"].(string)
if strings.Count(content, "[TOOL_CALL_HISTORY]") != 2 {
t.Fatalf("expected two TOOL_CALL_HISTORY blocks, got %q", content)
}
if !strings.Contains(content, "tool_call_id: call_search") || !strings.Contains(content, "function.name: search_web") {
t.Fatalf("missing first tool call block, got %q", content)
}
if !strings.Contains(content, "tool_call_id: call_eval") || !strings.Contains(content, "function.name: eval_javascript") {
t.Fatalf("missing second tool call block, got %q", content)
}
if strings.Contains(content, "search_webeval_javascript") {
t.Fatalf("unexpected merged function name detected: %q", content)
}
if strings.Contains(content, `}{"`) {
t.Fatalf("unexpected concatenated function arguments detected: %q", content)
if len(normalized) != 0 {
t.Fatalf("expected assistant tool_call-only message omitted, got %#v", normalized)
}
}
@@ -185,16 +192,11 @@ func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t *
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized message, got %d", len(normalized))
}
content, _ := normalized[0]["content"].(string)
if !strings.Contains(content, `function.arguments: {}{"query":"测试工具调用"}`) {
t.Fatalf("expected original concatenated arguments in tool history, got %q", content)
if len(normalized) != 0 {
t.Fatalf("expected assistant tool_call-only content omitted, got %#v", normalized)
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDropped(t *testing.T) {
raw := []any{
map[string]any{
@@ -213,7 +215,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDroppe
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 0 {
t.Fatalf("expected nameless assistant tool_calls to be dropped, got %#v", normalized)
t.Fatalf("expected assistant tool_calls without text omitted, got %#v", normalized)
}
}
@@ -235,15 +237,8 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLi
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized message, got %d", len(normalized))
}
content, _ := normalized[0]["content"].(string)
if strings.Contains(content, "<Assistant>null") || strings.HasPrefix(strings.TrimSpace(content), "null") {
t.Fatalf("unexpected null literal injected into assistant tool history: %q", content)
}
if !strings.Contains(content, "function.name: send_file_to_user") {
t.Fatalf("expected tool history block preserved, got %q", content)
if len(normalized) != 0 {
t.Fatalf("expected nil-content assistant tool_call-only message omitted, got %#v", normalized)
}
}

View File

@@ -44,11 +44,11 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes
if len(toolNames) != 1 || toolNames[0] != "get_weather" {
t.Fatalf("unexpected tool names: %#v", toolNames)
}
if !strings.Contains(finalPrompt, "tool_call_id: call_1") ||
!strings.Contains(finalPrompt, "function.name: get_weather") ||
!strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") ||
!strings.Contains(finalPrompt, `"condition":"sunny"`) {
t.Fatalf("handler finalPrompt missing tool roundtrip semantics: %q", finalPrompt)
if !strings.Contains(finalPrompt, `"condition":"sunny"`) {
t.Fatalf("handler finalPrompt should preserve tool output content: %q", finalPrompt)
}
if strings.Contains(finalPrompt, "[TOOL_CALL_HISTORY]") || strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("handler finalPrompt should not include synthetic history markers: %q", finalPrompt)
}
}
@@ -77,10 +77,10 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
if !strings.Contains(finalPrompt, "Only call another tool when the previous result is missing required data or returned an error.") {
t.Fatalf("vercel prepare finalPrompt missing retry guard instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("vercel prepare finalPrompt missing history marker instruction: %q", finalPrompt)
if !strings.Contains(finalPrompt, "Do NOT wrap tool-call JSON in markdown/code fences") {
t.Fatalf("vercel prepare finalPrompt missing no-fence instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers in your answer") {
t.Fatalf("vercel prepare finalPrompt missing marker-output guard instruction: %q", finalPrompt)
if strings.Contains(finalPrompt, "```json") {
t.Fatalf("vercel prepare finalPrompt should not require fenced json tool calls: %q", finalPrompt)
}
}

View File

@@ -113,7 +113,8 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
return
}
result := sse.CollectStream(resp, thinkingEnabled, true)
textParsed := util.ParseStandaloneToolCallsDetailed(result.Text, toolNames)
sanitizedText := sanitizeLeakedToolHistory(result.Text)
textParsed := util.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames)
logResponsesToolPolicyRejection(traceID, toolChoice, textParsed, "text")
callCount := len(textParsed.Calls)
@@ -122,7 +123,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
return
}
responseObj := openaifmt.BuildResponseObject(responseID, model, finalPrompt, result.Thinking, result.Text, toolNames)
responseObj := openaifmt.BuildResponseObject(responseID, model, finalPrompt, result.Thinking, sanitizedText, toolNames)
h.getResponseStore().put(owner, responseID, responseObj)
writeJSON(w, http.StatusOK, responseObj)
}
@@ -145,8 +146,8 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request,
if thinkingEnabled {
initialType = "thinking"
}
bufferToolContent := len(toolNames) > 0 && h.toolcallFeatureMatchEnabled()
emitEarlyToolDeltas := h.toolcallEarlyEmitHighConfidence()
bufferToolContent := len(toolNames) > 0
emitEarlyToolDeltas := h.toolcallFeatureMatchEnabled() && h.toolcallEarlyEmitHighConfidence()
streamRuntime := newResponsesStreamRuntime(
w,

View File

@@ -19,6 +19,27 @@ func normalizeResponsesInputItemWithState(m map[string]any, callNameByID map[str
role := strings.ToLower(strings.TrimSpace(asString(m["role"])))
if role != "" {
if role == "assistant" {
out := map[string]any{
"role": "assistant",
}
if toolCalls, ok := m["tool_calls"].([]any); ok && len(toolCalls) > 0 {
out["tool_calls"] = toolCalls
}
content := m["content"]
if content == nil {
if txt, _ := m["text"].(string); strings.TrimSpace(txt) != "" {
content = txt
}
}
if content != nil {
out["content"] = content
}
if _, hasToolCalls := out["tool_calls"]; hasToolCalls || out["content"] != nil {
return out
}
return nil
}
content := m["content"]
if content == nil {
if txt, _ := m["text"].(string); strings.TrimSpace(txt) != "" {
@@ -28,10 +49,22 @@ func normalizeResponsesInputItemWithState(m map[string]any, callNameByID map[str
if content == nil {
return nil
}
return map[string]any{
out := map[string]any{
"role": normalizeOpenAIRoleForPrompt(role),
"content": content,
}
if role == "tool" || role == "function" {
if callID := strings.TrimSpace(asString(m["tool_call_id"])); callID != "" {
out["tool_call_id"] = callID
}
if callID := strings.TrimSpace(asString(m["call_id"])); callID != "" {
out["tool_call_id"] = callID
}
if name := strings.TrimSpace(asString(m["name"])); name != "" {
out["name"] = name
}
}
return out
}
itemType := strings.ToLower(strings.TrimSpace(asString(m["type"])))

View File

@@ -32,7 +32,6 @@ type responsesStreamRuntime struct {
toolCallsDoneEmitted bool
sieve toolStreamSieveState
thinkingSieve toolStreamSieveState
thinking strings.Builder
text strings.Builder
visibleText strings.Builder
@@ -98,7 +97,7 @@ func newResponsesStreamRuntime(
func (s *responsesStreamRuntime) finalize() {
finalThinking := s.thinking.String()
finalText := s.text.String()
finalText := sanitizeLeakedToolHistory(s.text.String())
if s.bufferToolContent {
s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true)
@@ -169,15 +168,6 @@ func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed util.ToolCal
logRejected(textParsed, "text")
}
func (s *responsesStreamRuntime) hasFunctionCallDone() bool {
for _, done := range s.functionDone {
if done {
return true
}
}
return false
}
func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedDecision {
if !parsed.Parsed {
return streamengine.ParsedDecision{}
@@ -204,12 +194,16 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
continue
}
s.text.WriteString(p.Text)
if !s.bufferToolContent {
s.emitTextDelta(p.Text)
cleanedText := sanitizeLeakedToolHistory(p.Text)
if cleanedText == "" {
continue
}
s.processToolStreamEvents(processToolSieveChunk(&s.sieve, p.Text, s.toolNames), true)
s.text.WriteString(cleanedText)
if !s.bufferToolContent {
s.emitTextDelta(cleanedText)
continue
}
s.processToolStreamEvents(processToolSieveChunk(&s.sieve, cleanedText, s.toolNames), true)
}
return streamengine.ParsedDecision{ContentSeen: contentSeen}

View File

@@ -354,7 +354,7 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleEmitsFunctionCall(t *te
}
}
func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
func TestHandleResponsesStreamToolChoiceNoneStillAllowsFunctionCall(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
@@ -376,8 +376,8 @@ func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, nil, policy, "")
body := rec.Body.String()
if strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("did not expect function_call events for tool_choice=none, body=%s", body)
if !strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("expected function_call events for tool_choice=none, body=%s", body)
}
}
@@ -518,7 +518,7 @@ func TestHandleResponsesStreamRequiredMalformedToolPayloadFails(t *testing.T) {
}
}
func TestHandleResponsesStreamRejectsUnknownToolName(t *testing.T) {
func TestHandleResponsesStreamAllowsUnknownToolName(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
@@ -539,8 +539,8 @@ func TestHandleResponsesStreamRejectsUnknownToolName(t *testing.T) {
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, []string{"read_file"}, util.DefaultToolChoicePolicy(), "")
body := rec.Body.String()
if strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("did not expect function_call events for unknown tool, body=%s", body)
if !strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("expected function_call events for unknown tool, body=%s", body)
}
}
@@ -597,7 +597,7 @@ func TestHandleResponsesNonStreamRequiredToolChoiceIgnoresThinkingToolPayload(t
}
}
func TestHandleResponsesNonStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
func TestHandleResponsesNonStreamToolChoiceNoneStillAllowsFunctionCall(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
resp := &http.Response{
@@ -611,16 +611,20 @@ func TestHandleResponsesNonStreamToolChoiceNoneRejectsFunctionCall(t *testing.T)
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, nil, policy, "")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for tool_choice=none passthrough text, got %d body=%s", rec.Code, rec.Body.String())
t.Fatalf("expected 200 for tool_choice=none handling, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
output, _ := out["output"].([]any)
foundFunctionCall := false
for _, item := range output {
m, _ := item.(map[string]any)
if m != nil && m["type"] == "function_call" {
t.Fatalf("did not expect function_call output item for tool_choice=none, got %#v", output)
foundFunctionCall = true
}
}
if !foundFunctionCall {
t.Fatalf("expected function_call output item for tool_choice=none, got %#v", output)
}
}
func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) {
@@ -675,18 +679,3 @@ func extractAllSSEEventPayloads(body, targetEvent string) []map[string]any {
}
return out
}
func asFloat(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
default:
return 0
}
}

View File

@@ -25,6 +25,7 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
}
toolPolicy := util.DefaultToolChoicePolicy()
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy)
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
passThrough := collectOpenAIChatPassThrough(req)
return util.StandardRequest{
@@ -74,10 +75,8 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
return util.StandardRequest{}, err
}
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy)
if toolPolicy.IsNone() {
toolNames = nil
toolPolicy.Allowed = nil
} else {
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
if !toolPolicy.IsNone() {
toolPolicy.Allowed = namesToSet(toolNames)
}
passThrough := collectOpenAIChatPassThrough(req)
@@ -98,6 +97,20 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
}, nil
}
func ensureToolDetectionEnabled(toolNames []string, toolsRaw any) []string {
if len(toolNames) > 0 {
return toolNames
}
tools, _ := toolsRaw.([]any)
if len(tools) == 0 {
return toolNames
}
// Keep stream sieve/tool buffering enabled even when client tool schemas
// are malformed or lack explicit names; parsed tool payload names are no
// longer filtered by this list.
return []string{"__any_tool__"}
}
func collectOpenAIChatPassThrough(req map[string]any) map[string]any {
out := map[string]any{}
for _, k := range []string{

View File

@@ -152,7 +152,7 @@ func TestNormalizeOpenAIResponsesRequestToolChoiceForcedUndeclaredFails(t *testi
}
}
func TestNormalizeOpenAIResponsesRequestToolChoiceNoneDisablesTools(t *testing.T) {
func TestNormalizeOpenAIResponsesRequestToolChoiceNoneKeepsToolDetectionEnabled(t *testing.T) {
store := newEmptyStoreForNormalizeTest(t)
req := map[string]any{
"model": "gpt-4o",
@@ -174,7 +174,7 @@ func TestNormalizeOpenAIResponsesRequestToolChoiceNoneDisablesTools(t *testing.T
if n.ToolChoice.Mode != util.ToolChoiceNone {
t.Fatalf("expected tool choice mode none, got %q", n.ToolChoice.Mode)
}
if len(n.ToolNames) != 0 {
t.Fatalf("expected no tool names when tool_choice=none, got %#v", n.ToolNames)
if len(n.ToolNames) == 0 {
t.Fatalf("expected tool detection sentinel when tool_choice=none, got %#v", n.ToolNames)
}
}

View File

@@ -0,0 +1,23 @@
package openai
import (
"regexp"
)
var leakedToolHistoryPattern = regexp.MustCompile(`(?is)\[TOOL_CALL_HISTORY\][\s\S]*?\[/TOOL_CALL_HISTORY\]|\[TOOL_RESULT_HISTORY\][\s\S]*?\[/TOOL_RESULT_HISTORY\]`)
var emptyJSONFencePattern = regexp.MustCompile("(?is)```json\\s*```")
var leakedToolCallArrayPattern = regexp.MustCompile(`(?is)\[\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"call[^"]*"\s*,\s*"type"\s*:\s*"function"\s*}\]`)
var leakedToolResultBlobPattern = regexp.MustCompile(`(?is)<\s*\|\s*tool\s*\|\s*>\s*\{[\s\S]*?"tool_call_id"\s*:\s*"call[^"]*"\s*}`)
var leakedMetaMarkerPattern = regexp.MustCompile(`(?is)<\s*\|\s*(?:assistant|tool|end_of_sentence|end_of_thinking)\s*\|\s*>`)
func sanitizeLeakedToolHistory(text string) string {
if text == "" {
return text
}
out := leakedToolHistoryPattern.ReplaceAllString(text, "")
out = emptyJSONFencePattern.ReplaceAllString(out, "")
out = leakedToolCallArrayPattern.ReplaceAllString(out, "")
out = leakedToolResultBlobPattern.ReplaceAllString(out, "")
out = leakedMetaMarkerPattern.ReplaceAllString(out, "")
return out
}

View File

@@ -0,0 +1,122 @@
package openai
import "testing"
func TestSanitizeLeakedToolHistoryRemovesMarkerBlocks(t *testing.T) {
raw := "前缀\n[TOOL_CALL_HISTORY]\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]\n后缀"
got := sanitizeLeakedToolHistory(raw)
if got != "前缀\n\n后缀" {
t.Fatalf("unexpected sanitized content: %q", got)
}
}
func TestSanitizeLeakedToolHistoryPreservesChunkWhitespace(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{
name: "trailing space kept",
raw: "Hello ",
want: "Hello ",
},
{
name: "leading newline kept",
raw: "\nworld",
want: "\nworld",
},
{
name: "surrounding whitespace around marker is preserved",
raw: "A \n[TOOL_RESULT_HISTORY]\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]\n B",
want: "A \n\n B",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeLeakedToolHistory(tc.raw)
if got != tc.want {
t.Fatalf("unexpected sanitize result, want %q got %q", tc.want, got)
}
})
}
}
func TestSanitizeLeakedToolHistoryRemovesEmptyJSONFence(t *testing.T) {
raw := "before\n```json\n```\nafter"
got := sanitizeLeakedToolHistory(raw)
if got != "before\n\nafter" {
t.Fatalf("unexpected sanitized empty json fence: %q", got)
}
}
func TestFlushToolSieveDropsToolHistoryLeak(t *testing.T) {
var state toolStreamSieveState
chunk := "[TOOL_CALL_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]"
evts := processToolSieveChunk(&state, chunk, []string{"exec"})
if len(evts) != 0 {
t.Fatalf("expected no immediate output before history block is complete, got %+v", evts)
}
flushed := flushToolSieve(&state, []string{"exec"})
if len(flushed) != 0 {
t.Fatalf("expected history block to be swallowed, got %+v", flushed)
}
}
func TestFlushToolSieveDropsToolResultHistoryLeak(t *testing.T) {
var state toolStreamSieveState
chunk := "[TOOL_RESULT_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]"
evts := processToolSieveChunk(&state, chunk, []string{"exec"})
if len(evts) != 0 {
t.Fatalf("expected no immediate output before result history block is complete, got %+v", evts)
}
flushed := flushToolSieve(&state, []string{"exec"})
if len(flushed) != 0 {
t.Fatalf("expected result history block to be swallowed, got %+v", flushed)
}
}
func TestSanitizeLeakedToolHistoryRemovesLeakedWireToolCallAndResult(t *testing.T) {
raw := "开始\n[{\"function\":{\"arguments\":\"{\\\"command\\\":\\\"java -version\\\"}\",\"name\":\"exec\"},\"id\":\"callb9a321\",\"type\":\"function\"}]< | Tool | >{\"content\":\"openjdk version 21\",\"tool_call_id\":\"callb9a321\"}\n结束"
got := sanitizeLeakedToolHistory(raw)
if got != "开始\n\n结束" {
t.Fatalf("unexpected sanitize result for leaked wire format: %q", got)
}
}
func TestSanitizeLeakedToolHistoryRemovesStandaloneMetaMarkers(t *testing.T) {
raw := "A<| end_of_sentence |><| Assistant |>B<| end_of_thinking |>C"
got := sanitizeLeakedToolHistory(raw)
if got != "ABC" {
t.Fatalf("unexpected sanitize result for meta markers: %q", got)
}
}
func TestProcessToolSieveChunkSplitsResultHistoryBoundary(t *testing.T) {
var state toolStreamSieveState
parts := []string{
"Hello ",
"[TOOL_RESULT_HISTORY]\nstatus: already_called\n",
"function.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]",
"world",
}
var events []toolStreamEvent
for _, p := range parts {
events = append(events, processToolSieveChunk(&state, p, []string{"exec"})...)
}
events = append(events, flushToolSieve(&state, []string{"exec"})...)
var text string
for _, evt := range events {
if evt.Content != "" {
text += evt.Content
}
if len(evt.ToolCalls) > 0 {
t.Fatalf("did not expect parsed tool calls from history leak: %+v", evt.ToolCalls)
}
}
if text != "Hello world" {
t.Fatalf("expected clean text output preserving boundary spaces, got %q", text)
}
}

View File

@@ -167,7 +167,7 @@ func findToolSegmentStart(s string) int {
return -1
}
lower := strings.ToLower(s)
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]", "[tool_result_history]"}
bestKeyIdx := -1
for _, kw := range keywords {
idx := strings.Index(lower, kw)
@@ -194,9 +194,8 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
return "", nil, "", false
}
lower := strings.ToLower(captured)
keyIdx := -1
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]", "[tool_result_history]"}
for _, kw := range keywords {
idx := strings.Index(lower, kw)
if idx >= 0 && (keyIdx < 0 || idx < keyIdx) {
@@ -209,6 +208,9 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
}
start := strings.LastIndex(captured[:keyIdx], "{")
if start < 0 {
if blockStart, blockEnd, ok := extractToolHistoryBlock(captured, keyIdx); ok {
return captured[:blockStart], nil, captured[blockEnd:], true
}
start = keyIdx
}
obj, end, ok := extractJSONObjectFrom(captured, start)
@@ -233,6 +235,31 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
return prefixPart, parsed.Calls, suffixPart, true
}
func extractToolHistoryBlock(captured string, keyIdx int) (start int, end int, ok bool) {
if keyIdx < 0 || keyIdx >= len(captured) {
return 0, 0, false
}
rest := strings.ToLower(captured[keyIdx:])
switch {
case strings.HasPrefix(rest, "[tool_call_history]"):
closeTag := "[/tool_call_history]"
closeIdx := strings.Index(rest, closeTag)
if closeIdx < 0 {
return 0, 0, false
}
return keyIdx, keyIdx + closeIdx + len(closeTag), true
case strings.HasPrefix(rest, "[tool_result_history]"):
closeTag := "[/tool_result_history]"
closeIdx := strings.Index(rest, closeTag)
if closeIdx < 0 {
return 0, 0, false
}
return keyIdx, keyIdx + closeIdx + len(closeTag), true
default:
return 0, 0, false
}
}
func trimWrappingJSONFence(prefix, suffix string) (string, string) {
trimmedPrefix := strings.TrimRight(prefix, " \t\r\n")
fenceIdx := strings.LastIndex(trimmedPrefix, "```")

View File

@@ -1,288 +0,0 @@
package openai
import "strings"
func buildIncrementalToolDeltas(state *toolStreamSieveState) []toolCallDelta {
if state.disableDeltas {
return nil
}
captured := state.capture.String()
if captured == "" {
return nil
}
lower := strings.ToLower(captured)
keyIdx := strings.Index(lower, "tool_calls")
if keyIdx < 0 {
return nil
}
start := strings.LastIndex(captured[:keyIdx], "{")
if start < 0 {
return nil
}
certainSingle, hasMultiple := classifyToolCallsIncrementalSafety(captured, keyIdx)
if hasMultiple {
state.disableDeltas = true
return nil
}
if !certainSingle {
// In uncertain phases (e.g. first call arrived but array not closed yet),
// avoid speculative deltas and wait for final parsed tool_calls payload.
return nil
}
callStart, ok := findFirstToolCallObjectStart(captured, keyIdx)
if !ok {
return nil
}
deltas := make([]toolCallDelta, 0, 2)
if state.toolName == "" {
name, ok := extractToolCallName(captured, callStart)
if !ok || name == "" {
return nil
}
state.toolName = name
}
if state.toolArgsStart < 0 {
argsStart, stringMode, ok := findToolCallArgsStart(captured, callStart)
if ok {
state.toolArgsString = stringMode
if stringMode {
state.toolArgsStart = argsStart + 1
} else {
state.toolArgsStart = argsStart
}
state.toolArgsSent = state.toolArgsStart
}
}
if !state.toolNameSent {
if state.toolArgsStart < 0 {
return nil
}
state.toolNameSent = true
deltas = append(deltas, toolCallDelta{Index: 0, Name: state.toolName})
}
if state.toolArgsStart < 0 || state.toolArgsDone {
return deltas
}
end, complete, ok := scanToolCallArgsProgress(captured, state.toolArgsStart, state.toolArgsString)
if !ok {
return deltas
}
if end > state.toolArgsSent {
deltas = append(deltas, toolCallDelta{
Index: 0,
Arguments: captured[state.toolArgsSent:end],
})
state.toolArgsSent = end
}
if complete {
state.toolArgsDone = true
}
return deltas
}
func classifyToolCallsIncrementalSafety(text string, keyIdx int) (certainSingle bool, hasMultiple bool) {
arrStart, ok := findToolCallsArrayStart(text, keyIdx)
if !ok {
return false, false
}
i := skipSpaces(text, arrStart+1)
if i >= len(text) || text[i] != '{' {
return false, false
}
count := 0
depth := 0
quote := byte(0)
escaped := false
for ; i < len(text); i++ {
ch := text[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == quote {
quote = 0
}
continue
}
if ch == '"' || ch == '\'' {
quote = ch
continue
}
if ch == '{' {
if depth == 0 {
count++
if count > 1 {
return false, true
}
}
depth++
continue
}
if ch == '}' {
if depth > 0 {
depth--
}
continue
}
if ch == ',' && depth == 0 {
// top-level separator means at least one more tool call exists
// (or is expected). Treat as multi-call and stop incremental deltas.
return false, true
}
if ch == ']' && depth == 0 {
return count == 1, false
}
}
// array not closed yet: still uncertain whether more calls will appear
return false, false
}
func findFirstToolCallObjectStart(text string, keyIdx int) (int, bool) {
arrStart, ok := findToolCallsArrayStart(text, keyIdx)
if !ok {
return -1, false
}
i := skipSpaces(text, arrStart+1)
if i >= len(text) || text[i] != '{' {
return -1, false
}
return i, true
}
func findToolCallsArrayStart(text string, keyIdx int) (int, bool) {
i := keyIdx + len("tool_calls")
for i < len(text) && text[i] != ':' {
i++
}
if i >= len(text) {
return -1, false
}
i = skipSpaces(text, i+1)
if i >= len(text) || text[i] != '[' {
return -1, false
}
return i, true
}
func extractToolCallName(text string, callStart int) (string, bool) {
valueStart, ok := findObjectFieldValueStart(text, callStart, []string{"name"})
if !ok || valueStart >= len(text) || text[valueStart] != '"' {
fnStart, fnOK := findFunctionObjectStart(text, callStart)
if !fnOK {
return "", false
}
valueStart, ok = findObjectFieldValueStart(text, fnStart, []string{"name"})
if !ok || valueStart >= len(text) || text[valueStart] != '"' {
return "", false
}
}
name, _, ok := parseJSONStringLiteral(text, valueStart)
if !ok {
return "", false
}
return name, true
}
func findToolCallArgsStart(text string, callStart int) (int, bool, bool) {
keys := []string{"input", "arguments", "args", "parameters", "params"}
valueStart, ok := findObjectFieldValueStart(text, callStart, keys)
if !ok {
fnStart, fnOK := findFunctionObjectStart(text, callStart)
if !fnOK {
return -1, false, false
}
valueStart, ok = findObjectFieldValueStart(text, fnStart, keys)
if !ok {
return -1, false, false
}
}
if valueStart >= len(text) {
return -1, false, false
}
ch := text[valueStart]
if ch == '{' || ch == '[' {
return valueStart, false, true
}
if ch == '"' {
return valueStart, true, true
}
return -1, false, false
}
func scanToolCallArgsProgress(text string, start int, stringMode bool) (int, bool, bool) {
if start < 0 || start > len(text) {
return 0, false, false
}
if stringMode {
escaped := false
for i := start; i < len(text); i++ {
ch := text[i]
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
return i, true, true
}
}
return len(text), false, true
}
if start >= len(text) {
return start, false, false
}
if text[start] != '{' && text[start] != '[' {
return 0, false, false
}
depth := 0
quote := byte(0)
escaped := false
for i := start; i < len(text); i++ {
ch := text[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == quote {
quote = 0
}
continue
}
if ch == '"' || ch == '\'' {
quote = ch
continue
}
if ch == '{' || ch == '[' {
depth++
continue
}
if ch == '}' || ch == ']' {
depth--
if depth == 0 {
return i + 1, true, true
}
}
}
return len(text), false, true
}
func findFunctionObjectStart(text string, callStart int) (int, bool) {
valueStart, ok := findObjectFieldValueStart(text, callStart, []string{"function"})
if !ok || valueStart >= len(text) || text[valueStart] != '{' {
return -1, false
}
return valueStart, true
}

View File

@@ -1,7 +1,5 @@
package openai
import "strings"
func extractJSONObjectFrom(text string, start int) (string, int, bool) {
if start < 0 || start >= len(text) || text[start] != '{' {
return "", 0, false
@@ -43,110 +41,3 @@ func extractJSONObjectFrom(text string, start int) (string, int, bool) {
}
return "", 0, false
}
func findObjectFieldValueStart(text string, objStart int, keys []string) (int, bool) {
if objStart < 0 || objStart >= len(text) || text[objStart] != '{' {
return 0, false
}
depth := 0
quote := byte(0)
escaped := false
for i := objStart; i < len(text); i++ {
ch := text[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == quote {
quote = 0
}
continue
}
if ch == '"' || ch == '\'' {
if depth == 1 {
key, end, ok := parseJSONStringLiteral(text, i)
if !ok {
return 0, false
}
j := skipSpaces(text, end)
if j >= len(text) || text[j] != ':' {
i = end - 1
continue
}
j = skipSpaces(text, j+1)
if j >= len(text) {
return 0, false
}
if containsKey(keys, key) {
return j, true
}
i = j - 1
continue
}
quote = ch
continue
}
if ch == '{' {
depth++
continue
}
if ch == '}' {
depth--
if depth == 0 {
break
}
}
}
return 0, false
}
func parseJSONStringLiteral(text string, start int) (string, int, bool) {
if start < 0 || start >= len(text) || text[start] != '"' {
return "", 0, false
}
var b strings.Builder
escaped := false
for i := start + 1; i < len(text); i++ {
ch := text[i]
if escaped {
b.WriteByte(ch)
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
return b.String(), i + 1, true
}
b.WriteByte(ch)
}
return "", 0, false
}
func containsKey(keys []string, value string) bool {
for _, k := range keys {
if k == value {
return true
}
}
return false
}
func skipSpaces(text string, i int) int {
for i < len(text) {
switch text[i] {
case ' ', '\t', '\n', '\r':
i++
default:
return i
}
}
return i
}

View File

@@ -63,14 +63,3 @@ func appendTail(prev, next string, max int) string {
}
return combined[len(combined)-max:]
}
func looksLikeToolExampleContext(text string) bool {
return insideCodeFence(text)
}
func insideCodeFence(text string) bool {
if text == "" {
return false
}
return strings.Count(text, "```")%2 == 1
}

View File

@@ -17,6 +17,7 @@ type ConfigStore interface {
FindAccount(identifier string) (config.Account, bool)
UpdateAccountToken(identifier, token string) error
UpdateAccountTestStatus(identifier, status string) error
AccountTestStatus(identifier string) (string, bool)
Update(mutator func(*config.Config) error) error
ExportJSONAndBase64() (string, string, error)
IsEnvBacked() bool

View File

@@ -54,6 +54,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
}
items := make([]map[string]any, 0, end-start)
for _, acc := range accounts[start:end] {
testStatus, _ := h.Store.AccountTestStatus(acc.Identifier())
token := strings.TrimSpace(acc.Token)
preview := ""
if token != "" {
@@ -70,7 +71,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
"has_password": acc.Password != "",
"has_token": token != "",
"token_preview": preview,
"test_status": acc.TestStatus,
"test_status": testStatus,
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})

View File

@@ -93,8 +93,9 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
if updated.Token != "new-token" {
t.Fatalf("expected refreshed token to be persisted, got %q", updated.Token)
}
if updated.TestStatus != "ok" {
t.Fatalf("expected test status ok, got %q", updated.TestStatus)
testStatus, ok := store.AccountTestStatus("batch@example.com")
if !ok || testStatus != "ok" {
t.Fatalf("expected runtime test status ok, got %q (ok=%v)", testStatus, ok)
}
}

View File

@@ -7,6 +7,8 @@ import (
"errors"
"net/http"
"strings"
"sync"
"time"
"ds2api/internal/account"
"ds2api/internal/config"
@@ -37,10 +39,20 @@ type Resolver struct {
Store *config.Store
Pool *account.Pool
Login LoginFunc
mu sync.Mutex
tokenRefreshedAt map[string]time.Time
tokenRefreshInterval time.Duration
}
func NewResolver(store *config.Store, pool *account.Pool, login LoginFunc) *Resolver {
return &Resolver{Store: store, Pool: pool, Login: login}
return &Resolver{
Store: store,
Pool: pool,
Login: login,
tokenRefreshedAt: map[string]time.Time{},
tokenRefreshInterval: 6 * time.Hour,
}
}
func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
@@ -72,13 +84,9 @@ func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
TriedAccounts: map[string]bool{},
resolver: r,
}
if acc.Token == "" {
if err := r.loginAndPersist(ctx, a); err != nil {
r.Pool.Release(a.AccountID)
return nil, err
}
} else {
a.DeepSeekToken = acc.Token
if err := r.ensureManagedToken(ctx, a); err != nil {
r.Pool.Release(a.AccountID)
return nil, err
}
return a, nil
}
@@ -120,6 +128,7 @@ func (r *Resolver) loginAndPersist(ctx context.Context, a *RequestAuth) error {
}
a.Account.Token = token
a.DeepSeekToken = token
r.markTokenRefreshedNow(a.AccountID)
return r.Store.UpdateAccountToken(a.AccountID, token)
}
@@ -142,6 +151,7 @@ func (r *Resolver) MarkTokenInvalid(a *RequestAuth) {
}
a.Account.Token = ""
a.DeepSeekToken = ""
r.clearTokenRefreshMark(a.AccountID)
_ = r.Store.UpdateAccountToken(a.AccountID, "")
}
@@ -162,12 +172,8 @@ func (r *Resolver) SwitchAccount(ctx context.Context, a *RequestAuth) bool {
}
a.Account = acc
a.AccountID = acc.Identifier()
if acc.Token == "" {
if err := r.loginAndPersist(ctx, a); err != nil {
return false
}
} else {
a.DeepSeekToken = acc.Token
if err := r.ensureManagedToken(ctx, a); err != nil {
return false
}
return true
}
@@ -210,3 +216,53 @@ func callerTokenID(token string) string {
sum := sha256.Sum256([]byte(token))
return "caller:" + hex.EncodeToString(sum[:8])
}
func (r *Resolver) ensureManagedToken(ctx context.Context, a *RequestAuth) error {
if strings.TrimSpace(a.Account.Token) == "" {
return r.loginAndPersist(ctx, a)
}
if r.shouldForceRefresh(a.AccountID) {
if err := r.loginAndPersist(ctx, a); err != nil {
return err
}
return nil
}
a.DeepSeekToken = a.Account.Token
return nil
}
func (r *Resolver) shouldForceRefresh(accountID string) bool {
if strings.TrimSpace(accountID) == "" {
return false
}
if r.tokenRefreshInterval <= 0 {
return false
}
now := time.Now()
r.mu.Lock()
defer r.mu.Unlock()
last, ok := r.tokenRefreshedAt[accountID]
if !ok || last.IsZero() {
r.tokenRefreshedAt[accountID] = now
return false
}
return now.Sub(last) >= r.tokenRefreshInterval
}
func (r *Resolver) markTokenRefreshedNow(accountID string) {
if strings.TrimSpace(accountID) == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.tokenRefreshedAt[accountID] = time.Now()
}
func (r *Resolver) clearTokenRefreshMark(accountID string) {
if strings.TrimSpace(accountID) == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
delete(r.tokenRefreshedAt, accountID)
}

View File

@@ -3,7 +3,9 @@ package auth
import (
"context"
"net/http"
"sync/atomic"
"testing"
"time"
"ds2api/internal/account"
"ds2api/internal/config"
@@ -193,3 +195,52 @@ func TestDetermineCallerMissingToken(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDetermineManagedAccountForcesRefreshEverySixHours(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["managed-key"],
"accounts":[{"email":"acc@example.com","password":"pwd","token":"seed-token"}]
}`)
store := config.LoadStore()
if err := store.UpdateAccountToken("acc@example.com", "seed-token"); err != nil {
t.Fatalf("update token failed: %v", err)
}
pool := account.NewPool(store)
var loginCount int32
resolver := NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) {
n := atomic.AddInt32(&loginCount, 1)
return "fresh-token-" + string(rune('0'+n)), nil
})
req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
req.Header.Set("x-api-key", "managed-key")
a1, err := resolver.Determine(req)
if err != nil {
t.Fatalf("determine failed: %v", err)
}
if a1.DeepSeekToken != "seed-token" {
t.Fatalf("expected initial token without forced refresh, got %q", a1.DeepSeekToken)
}
resolver.Release(a1)
if got := atomic.LoadInt32(&loginCount); got != 0 {
t.Fatalf("expected no login before refresh interval, got %d", got)
}
resolver.mu.Lock()
resolver.tokenRefreshedAt["acc@example.com"] = time.Now().Add(-7 * time.Hour)
resolver.mu.Unlock()
a2, err := resolver.Determine(req)
if err != nil {
t.Fatalf("determine after interval failed: %v", err)
}
defer resolver.Release(a2)
if a2.DeepSeekToken != "fresh-token-1" {
t.Fatalf("expected refreshed token after interval, got %q", a2.DeepSeekToken)
}
if got := atomic.LoadInt32(&loginCount); got != 1 {
t.Fatalf("expected exactly one forced refresh login, got %d", got)
}
}

View File

@@ -19,11 +19,10 @@ type Config struct {
}
type Account struct {
Email string `json:"email,omitempty"`
Mobile string `json:"mobile,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
TestStatus string `json:"test_status,omitempty"`
Email string `json:"email,omitempty"`
Mobile string `json:"mobile,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
}
func (c *Config) ClearAccountTokens() {

View File

@@ -3,6 +3,7 @@ package config
import (
"encoding/base64"
"os"
"strings"
"testing"
)
@@ -147,3 +148,39 @@ func TestLoadConfigOnVercelWithoutConfigFileFallsBackToMemory(t *testing.T) {
t.Fatalf("expected empty bootstrap config, got keys=%d accounts=%d", len(cfg.Keys), len(cfg.Accounts))
}
}
func TestAccountTestStatusIsRuntimeOnlyAndNotPersisted(t *testing.T) {
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
if err != nil {
t.Fatalf("create temp config: %v", err)
}
defer tmp.Close()
if _, err := tmp.WriteString(`{
"accounts":[{"email":"u@example.com","password":"p","test_status":"ok"}]
}`); err != nil {
t.Fatalf("write temp config: %v", err)
}
t.Setenv("DS2API_CONFIG_JSON", "")
t.Setenv("CONFIG_JSON", "")
t.Setenv("DS2API_CONFIG_PATH", tmp.Name())
store := LoadStore()
if got, ok := store.AccountTestStatus("u@example.com"); ok || got != "" {
t.Fatalf("expected no runtime status loaded from config, got %q", got)
}
if err := store.UpdateAccountTestStatus("u@example.com", "ok"); err != nil {
t.Fatalf("update test status: %v", err)
}
if got, ok := store.AccountTestStatus("u@example.com"); !ok || got != "ok" {
t.Fatalf("expected runtime status to be available, got %q (ok=%v)", got, ok)
}
content, err := os.ReadFile(tmp.Name())
if err != nil {
t.Fatalf("read config: %v", err)
}
if strings.Contains(string(content), "test_status") {
t.Fatalf("expected test_status to stay out of persisted config, got: %s", content)
}
}

View File

@@ -17,6 +17,7 @@ type Store struct {
fromEnv bool
keyMap map[string]struct{} // O(1) API key lookup index
accMap map[string]int // O(1) account lookup: identifier -> slice index
accTest map[string]string // runtime-only account test status cache
}
func LoadStore() *Store {
@@ -58,6 +59,11 @@ func loadConfig() (Config, bool, error) {
return Config{}, false, err
}
cfg.DropInvalidAccounts()
if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
_ = os.WriteFile(ConfigPath(), b, 0o644)
}
}
if IsVercel() {
// Vercel filesystem is ephemeral/read-only for runtime writes; avoid save errors.
return cfg, true, nil
@@ -108,8 +114,19 @@ func (s *Store) UpdateAccountTestStatus(identifier, status string) error {
if !ok {
return errors.New("account not found")
}
s.cfg.Accounts[idx].TestStatus = status
return s.saveLocked()
s.setAccountTestStatusLocked(s.cfg.Accounts[idx], status, identifier)
return nil
}
func (s *Store) AccountTestStatus(identifier string) (string, bool) {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return "", false
}
s.mu.RLock()
defer s.mu.RUnlock()
status, ok := s.accTest[identifier]
return status, ok
}
func (s *Store) UpdateAccountToken(identifier, token string) error {

View File

@@ -2,15 +2,20 @@ package config
// rebuildIndexes must be called with the lock already held (or during init).
func (s *Store) rebuildIndexes() {
prevStatus := s.accTest
s.keyMap = make(map[string]struct{}, len(s.cfg.Keys))
for _, k := range s.cfg.Keys {
s.keyMap[k] = struct{}{}
}
s.accMap = make(map[string]int, len(s.cfg.Accounts))
s.accTest = make(map[string]string, len(s.cfg.Accounts))
for i, acc := range s.cfg.Accounts {
id := acc.Identifier()
if id != "" {
s.accMap[id] = i
if status, ok := prevStatus[id]; ok {
s.setAccountTestStatusLocked(acc, status, "")
}
}
}
}
@@ -29,3 +34,22 @@ func (s *Store) findAccountIndexLocked(identifier string) (int, bool) {
}
return -1, false
}
func (s *Store) setAccountTestStatusLocked(acc Account, status, hintedIdentifier string) {
status = lower(status)
if status == "" {
return
}
if id := acc.Identifier(); id != "" {
s.accTest[id] = status
}
if email := acc.Email; email != "" {
s.accTest[email] = status
}
if mobile := CanonicalMobileKey(acc.Mobile); mobile != "" {
s.accTest[mobile] = status
}
if hintedIdentifier = lower(hintedIdentifier); hintedIdentifier != "" {
s.accTest[hintedIdentifier] = status
}
}

View File

@@ -63,17 +63,6 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
return out, resp.StatusCode, nil
}
func (c *Client) getJSON(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, error) {
body, status, err := c.getJSONWithStatus(ctx, doer, url, headers)
if err != nil {
return nil, err
}
if status == 0 {
return nil, errors.New("request failed")
}
return body, nil
}
func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {

View File

@@ -2,6 +2,7 @@ package openai
import (
"encoding/json"
"strings"
"testing"
)
@@ -69,7 +70,7 @@ func TestBuildResponseObjectPromotesMixedProseToolPayloadToFunctionCall(t *testi
}
}
func TestBuildResponseObjectPromotesFencedToolPayloadToFunctionCall(t *testing.T) {
func TestBuildResponseObjectKeepsFencedToolPayloadAsText(t *testing.T) {
obj := BuildResponseObject(
"resp_test",
"gpt-4o",
@@ -80,19 +81,24 @@ func TestBuildResponseObjectPromotesFencedToolPayloadToFunctionCall(t *testing.T
)
outputText, _ := obj["output_text"].(string)
if outputText != "" {
t.Fatalf("expected output_text hidden for fenced tool payload, got %q", outputText)
if !strings.Contains(outputText, "\"tool_calls\"") {
t.Fatalf("expected output_text to preserve fenced tool payload, got %q", outputText)
}
output, _ := obj["output"].([]any)
if len(output) != 1 {
t.Fatalf("expected one function_call output item, got %#v", obj["output"])
t.Fatalf("expected one message output item, got %#v", obj["output"])
}
first, _ := output[0].(map[string]any)
if first["type"] != "function_call" {
t.Fatalf("expected function_call output type, got %#v", first["type"])
if first["type"] != "message" {
t.Fatalf("expected message output type, got %#v", first["type"])
}
}
// Backward-compatible alias for historical test name used in CI logs.
func TestBuildResponseObjectPromotesFencedToolPayloadToFunctionCall(t *testing.T) {
TestBuildResponseObjectKeepsFencedToolPayloadAsText(t)
}
func TestBuildResponseObjectReasoningOnlyFallsBackToOutputText(t *testing.T) {
obj := BuildResponseObject(
"resp_test",

View File

@@ -8,12 +8,15 @@ const {
function resolveToolcallPolicy(prepBody, payloadTools) {
const preparedToolNames = normalizePreparedToolNames(prepBody && prepBody.tool_names);
const toolNames = preparedToolNames.length > 0 ? preparedToolNames : extractToolNames(payloadTools);
let toolNames = preparedToolNames.length > 0 ? preparedToolNames : extractToolNames(payloadTools);
if (toolNames.length === 0 && Array.isArray(payloadTools) && payloadTools.length > 0) {
toolNames = ['__any_tool__'];
}
const featureMatchEnabled = boolDefaultTrue(prepBody && prepBody.toolcall_feature_match);
const emitEarlyToolDeltas = boolDefaultTrue(prepBody && prepBody.toolcall_early_emit_high);
const emitEarlyToolDeltas = featureMatchEnabled && boolDefaultTrue(prepBody && prepBody.toolcall_early_emit_high);
return {
toolNames,
toolSieveEnabled: toolNames.length > 0 && featureMatchEnabled,
toolSieveEnabled: toolNames.length > 0,
emitEarlyToolDeltas,
};
}
@@ -76,17 +79,6 @@ function filterIncrementalToolCallDeltasByAllowed(deltas, allowedNames, seenName
return [];
}
const seen = seenNames instanceof Map ? seenNames : new Map();
const allowed = new Set((allowedNames || []).filter((name) => asString(name) !== ''));
if (allowed.size === 0) {
for (const d of deltas) {
if (d && typeof d === 'object' && asString(d.name)) {
const index = Number.isInteger(d.index) ? d.index : 0;
seen.set(index, '__blocked__');
}
}
return [];
}
const out = [];
for (const d of deltas) {
if (!d || typeof d !== 'object') {
@@ -95,16 +87,12 @@ function filterIncrementalToolCallDeltasByAllowed(deltas, allowedNames, seenName
const index = Number.isInteger(d.index) ? d.index : 0;
const name = asString(d.name);
if (name) {
if (!allowed.has(name)) {
seen.set(index, '__blocked__');
continue;
}
seen.set(index, name);
out.push(d);
continue;
}
const existing = asString(seen.get(index));
if (!existing || existing === '__blocked__') {
if (!existing) {
continue;
}
out.push(d);

View File

@@ -8,9 +8,12 @@ const {
parseToolCallsPayload,
parseMarkupToolCalls,
parseTextKVToolCalls,
stripFencedCodeBlocks,
} = require('./parse_payload');
const { TOOL_SEGMENT_KEYWORDS } = require('./tool-keywords');
const TOOL_NAME_LOOSE_PATTERN = /[^a-z0-9]+/g;
const TOOL_MARKUP_PREFIXES = ['<tool_call', '<function_call', '<invoke'];
function extractToolNames(tools) {
if (!Array.isArray(tools) || tools.length === 0) {
@@ -44,6 +47,9 @@ function parseToolCallsDetailed(text, toolNames) {
return result;
}
result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized);
if (shouldSkipToolCallParsingForCodeFenceExample(normalized)) {
return result;
}
const candidates = buildToolCallCandidates(normalized);
let parsed = [];
@@ -89,6 +95,9 @@ function parseStandaloneToolCallsDetailed(text, toolNames) {
return result;
}
result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed);
if (shouldSkipToolCallParsingForCodeFenceExample(trimmed)) {
return result;
}
const candidates = buildToolCallCandidates(trimmed);
let parsed = [];
for (const c of candidates) {
@@ -131,63 +140,17 @@ function emptyParseResult() {
}
function filterToolCallsDetailed(parsed, toolNames) {
const sourceNames = Array.isArray(toolNames) ? toolNames : [];
const allowed = new Set();
const allowedCanonical = new Map();
for (const item of sourceNames) {
const name = toStringSafe(item);
if (!name) {
continue;
}
allowed.add(name);
const lower = name.toLowerCase();
if (!allowedCanonical.has(lower)) {
allowedCanonical.set(lower, name);
}
}
if (allowed.size === 0) {
const rejected = [];
const seen = new Set();
for (const tc of parsed) {
if (!tc || !tc.name) {
continue;
}
if (seen.has(tc.name)) {
continue;
}
seen.add(tc.name);
rejected.push(tc.name);
}
return { calls: [], rejectedToolNames: rejected };
}
const calls = [];
const rejected = [];
const seenRejected = new Set();
for (const tc of parsed) {
if (!tc || !tc.name) {
continue;
}
let matchedName = '';
if (allowed.has(tc.name)) {
matchedName = tc.name;
} else {
matchedName = resolveAllowedToolName(tc.name, allowed, allowedCanonical);
}
if (!matchedName) {
if (!seenRejected.has(tc.name)) {
seenRejected.add(tc.name);
rejected.push(tc.name);
}
continue;
}
calls.push({
name: matchedName,
name: tc.name,
input: tc.input && typeof tc.input === 'object' && !Array.isArray(tc.input) ? tc.input : {},
});
}
return { calls, rejectedToolNames: rejected };
return { calls, rejectedToolNames: [] };
}
function resolveAllowedToolName(name, allowed, allowedCanonical) {
@@ -223,11 +186,16 @@ function resolveAllowedToolName(name, allowed, allowedCanonical) {
function looksLikeToolCallSyntax(text) {
const lower = toStringSafe(text).toLowerCase();
return lower.includes('tool_calls')
|| lower.includes('<tool_call')
|| lower.includes('<function_call')
|| lower.includes('<invoke')
|| lower.includes('function.name:');
return TOOL_SEGMENT_KEYWORDS.some((kw) => lower.includes(kw))
|| TOOL_MARKUP_PREFIXES.some((prefix) => lower.includes(prefix));
}
function shouldSkipToolCallParsingForCodeFenceExample(text) {
if (!looksLikeToolCallSyntax(text)) {
return false;
}
const stripped = stripFencedCodeBlocks(text);
return !looksLikeToolCallSyntax(stripped);
}
module.exports = {

View File

@@ -56,6 +56,11 @@ function buildToolCallCandidates(text) {
if (first >= 0 && last > first) {
candidates.push(toStringSafe(trimmed.slice(first, last + 1)));
}
const firstArr = trimmed.indexOf('[');
const lastArr = trimmed.lastIndexOf(']');
if (firstArr >= 0 && lastArr > firstArr) {
candidates.push(toStringSafe(trimmed.slice(firstArr, lastArr + 1)));
}
const m = trimmed.match(TOOL_CALL_PATTERN);
if (m && m[1]) {
@@ -76,7 +81,17 @@ function extractToolCallObjects(text) {
// eslint-disable-next-line no-constant-condition
while (true) {
let idx = lower.indexOf('tool_calls', offset);
const idxToolCalls = lower.indexOf('tool_calls', offset);
const idxFunction = lower.indexOf('"function"', offset);
let idx = -1;
let matched = '';
if (idxToolCalls >= 0 && (idxFunction < 0 || idxToolCalls <= idxFunction)) {
idx = idxToolCalls;
matched = 'tool_calls';
} else if (idxFunction >= 0) {
idx = idxFunction;
matched = '"function"';
}
if (idx < 0) {
break;
}
@@ -92,7 +107,7 @@ function extractToolCallObjects(text) {
start = raw.slice(0, start).lastIndexOf('{');
}
if (idx >= 0) {
offset = idx + 'tool_calls'.length;
offset = idx + matched.length;
}
}
@@ -114,6 +129,9 @@ function parseToolCallsPayload(payload) {
return [];
}
if (decoded.tool_calls) {
if (isLikelyChatMessageEnvelope(decoded)) {
return [];
}
return parseToolCallList(decoded.tool_calls);
}
@@ -121,6 +139,21 @@ function parseToolCallsPayload(payload) {
return one ? [one] : [];
}
function isLikelyChatMessageEnvelope(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
if (!Object.prototype.hasOwnProperty.call(value, 'tool_calls')) {
return false;
}
const role = toStringSafe(value.role).trim().toLowerCase();
if (role === 'assistant' || role === 'tool' || role === 'user' || role === 'system') {
return true;
}
return Object.prototype.hasOwnProperty.call(value, 'tool_call_id')
|| Object.prototype.hasOwnProperty.call(value, 'content');
}
function parseMarkupToolCalls(text) {
const raw = toStringSafe(text).trim();
if (!raw) {

View File

@@ -1,17 +1,12 @@
'use strict';
const {
resetIncrementalToolState,
noteText,
insideCodeFence,
insideCodeFenceWithState,
} = require('./state');
const {
parseStandaloneToolCallsDetailed,
} = require('./parse');
const {
extractJSONObjectFrom,
} = require('./jsonscan');
const { parseStandaloneToolCallsDetailed } = require('./parse');
const { extractJSONObjectFrom } = require('./jsonscan');
const { TOOL_SEGMENT_KEYWORDS, earliestKeywordIndex } = require('./tool-keywords');
function processToolSieveChunk(state, chunk, toolNames) {
if (!state) {
return [];
@@ -20,8 +15,6 @@ function processToolSieveChunk(state, chunk, toolNames) {
state.pending += chunk;
}
const events = [];
// eslint-disable-next-line no-constant-condition
while (true) {
if (Array.isArray(state.pendingToolCalls) && state.pendingToolCalls.length > 0) {
events.push({ type: 'tool_calls', calls: state.pendingToolCalls });
@@ -60,13 +53,11 @@ function processToolSieveChunk(state, chunk, toolNames) {
}
continue;
}
const pending = state.pending || '';
if (!pending) {
break;
}
const start = findToolSegmentStart(pending);
const start = findToolSegmentStart(state, pending);
if (start >= 0) {
const prefix = pending.slice(0, start);
if (prefix) {
@@ -79,7 +70,6 @@ function processToolSieveChunk(state, chunk, toolNames) {
resetIncrementalToolState(state);
continue;
}
const [safe, hold] = splitSafeContentForToolDetection(pending);
if (!safe) {
break;
@@ -96,13 +86,11 @@ function flushToolSieve(state, toolNames) {
return [];
}
const events = processToolSieveChunk(state, '', toolNames);
if (Array.isArray(state.pendingToolCalls) && state.pendingToolCalls.length > 0) {
events.push({ type: 'tool_calls', calls: state.pendingToolCalls });
state.pendingToolRaw = '';
state.pendingToolCalls = [];
}
if (state.capturing) {
const consumed = consumeToolCapture(state, toolNames);
if (consumed.ready) {
@@ -125,13 +113,11 @@ function flushToolSieve(state, toolNames) {
state.capturing = false;
resetIncrementalToolState(state);
}
if (state.pending) {
noteText(state, state.pending);
events.push({ type: 'text', text: state.pending });
state.pending = '';
}
return events;
}
@@ -147,8 +133,6 @@ function splitSafeContentForToolDetection(s) {
if (suspiciousStart > 0) {
return [text.slice(0, suspiciousStart), text.slice(suspiciousStart)];
}
// If suspicious content starts at the beginning, keep holding until we can
// either parse a full tool JSON block or reach stream flush.
return ['', text];
}
@@ -163,36 +147,21 @@ function findSuspiciousPrefixStart(s) {
return start;
}
function findToolSegmentStart(s) {
function findToolSegmentStart(state, s) {
if (!s) {
return -1;
}
const lower = s.toLowerCase();
const keywords = ['tool_calls', 'function.name:', '[tool_call_history]'];
let offset = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
let bestKeyIdx = -1;
let matchedKeyword = '';
for (const kw of keywords) {
const idx = lower.indexOf(kw, offset);
if (idx >= 0) {
if (bestKeyIdx < 0 || idx < bestKeyIdx) {
bestKeyIdx = idx;
matchedKeyword = kw;
}
}
}
const { index: bestKeyIdx, keyword: matchedKeyword } = earliestKeywordIndex(lower, TOOL_SEGMENT_KEYWORDS, offset);
if (bestKeyIdx < 0) {
return -1;
}
const keyIdx = bestKeyIdx;
const start = s.slice(0, keyIdx).lastIndexOf('{');
const candidateStart = start >= 0 ? start : keyIdx;
if (!insideCodeFence(s.slice(0, candidateStart))) {
if (!insideCodeFenceWithState(state, s.slice(0, candidateStart))) {
return candidateStart;
}
offset = keyIdx + matchedKeyword.length;
@@ -205,31 +174,30 @@ function consumeToolCapture(state, toolNames) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const lower = captured.toLowerCase();
let keyIdx = -1;
const keywords = ['tool_calls', 'function.name:', '[tool_call_history]'];
for (const kw of keywords) {
const idx = lower.indexOf(kw);
if (idx >= 0 && (keyIdx < 0 || idx < keyIdx)) {
keyIdx = idx;
}
}
const { index: keyIdx } = earliestKeywordIndex(lower, TOOL_SEGMENT_KEYWORDS);
if (keyIdx < 0) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const start = captured.slice(0, keyIdx).lastIndexOf('{');
const actualStart = start >= 0 ? start : keyIdx;
if (start < 0) {
const history = extractToolHistoryBlock(captured, keyIdx);
if (history.ok) {
return {
ready: true,
prefix: captured.slice(0, history.start),
calls: [],
suffix: captured.slice(history.end),
};
}
}
const obj = extractJSONObjectFrom(captured, actualStart);
if (!obj.ok) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const prefixPart = captured.slice(0, actualStart);
const suffixPart = captured.slice(obj.end);
if (insideCodeFence((state.recentTextTail || '') + prefixPart)) {
if (insideCodeFenceWithState(state, prefixPart)) {
return {
ready: true,
prefix: captured,
@@ -237,7 +205,6 @@ function consumeToolCapture(state, toolNames) {
suffix: '',
};
}
const parsed = parseStandaloneToolCallsDetailed(captured.slice(actualStart, obj.end), toolNames);
if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) {
if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) {
@@ -255,7 +222,6 @@ function consumeToolCapture(state, toolNames) {
suffix: '',
};
}
const trimmedFence = trimWrappingJSONFence(prefixPart, suffixPart);
return {
ready: true,
@@ -265,14 +231,34 @@ function consumeToolCapture(state, toolNames) {
};
}
function extractToolHistoryBlock(captured, keyIdx) {
if (typeof captured !== 'string' || keyIdx < 0 || keyIdx >= captured.length) {
return { ok: false, start: 0, end: 0 };
}
const rest = captured.slice(keyIdx).toLowerCase();
if (rest.startsWith('[tool_call_history]')) {
const closeTag = '[/tool_call_history]';
const closeIdx = rest.indexOf(closeTag);
if (closeIdx < 0) {
return { ok: false, start: 0, end: 0 };
}
return { ok: true, start: keyIdx, end: keyIdx + closeIdx + closeTag.length };
}
if (rest.startsWith('[tool_result_history]')) {
const closeTag = '[/tool_result_history]';
const closeIdx = rest.indexOf(closeTag);
if (closeIdx < 0) {
return { ok: false, start: 0, end: 0 };
}
return { ok: true, start: keyIdx, end: keyIdx + closeIdx + closeTag.length };
}
return { ok: false, start: 0, end: 0 };
}
function trimWrappingJSONFence(prefix, suffix) {
const rightTrimmedPrefix = (prefix || '').replace(/[ \t\r\n]+$/g, '');
const fenceIdx = rightTrimmedPrefix.lastIndexOf('```');
if (fenceIdx < 0) {
return { prefix, suffix };
}
// Only strip when this behaves like an opening fence.
// If it's a legitimate closing fence before standalone tool JSON, keep it.
if (fenceIdx < 0) return { prefix, suffix };
const fenceCount = (rightTrimmedPrefix.slice(0, fenceIdx + 3).match(/```/g) || []).length;
if (fenceCount % 2 === 0) {
return { prefix, suffix };
@@ -281,7 +267,6 @@ function trimWrappingJSONFence(prefix, suffix) {
if (header && header !== 'json') {
return { prefix, suffix };
}
const leftTrimmedSuffix = (suffix || '').replace(/^[ \t\r\n]+/g, '');
if (!leftTrimmedSuffix.startsWith('```')) {
return { prefix, suffix };
@@ -292,7 +277,6 @@ function trimWrappingJSONFence(prefix, suffix) {
suffix: (suffix || '').slice(consumed + 3),
};
}
module.exports = {
processToolSieveChunk,
flushToolSieve,

View File

@@ -1,6 +1,6 @@
'use strict';
const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 256;
const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 4096;
function createToolSieveState() {
return {
@@ -8,6 +8,9 @@ function createToolSieveState() {
capture: '',
capturing: false,
recentTextTail: '',
codeFenceStack: [],
codeFencePendingTicks: 0,
codeFenceLineStart: true,
pendingToolRaw: '',
pendingToolCalls: [],
disableDeltas: false,
@@ -34,6 +37,7 @@ function noteText(state, text) {
if (!state || !hasMeaningfulText(text)) {
return;
}
updateCodeFenceState(state, text);
state.recentTextTail = appendTail(state.recentTextTail, text, TOOL_SIEVE_CONTEXT_TAIL_LIMIT);
}
@@ -63,6 +67,91 @@ function insideCodeFence(text) {
return ticks % 2 === 1;
}
function insideCodeFenceWithState(state, text) {
if (!state) {
return insideCodeFence(text);
}
const simulated = simulateCodeFenceState(
Array.isArray(state.codeFenceStack) ? state.codeFenceStack : [],
Number.isInteger(state.codeFencePendingTicks) ? state.codeFencePendingTicks : 0,
state.codeFenceLineStart !== false,
text,
);
return simulated.stack.length > 0;
}
function updateCodeFenceState(state, text) {
if (!state) {
return;
}
const next = simulateCodeFenceState(
Array.isArray(state.codeFenceStack) ? state.codeFenceStack : [],
Number.isInteger(state.codeFencePendingTicks) ? state.codeFencePendingTicks : 0,
state.codeFenceLineStart !== false,
text,
);
state.codeFenceStack = next.stack;
state.codeFencePendingTicks = next.pendingTicks;
state.codeFenceLineStart = next.lineStart;
}
function simulateCodeFenceState(stack, pendingTicks, lineStart, text) {
const chunk = typeof text === 'string' ? text : '';
const nextStack = Array.isArray(stack) ? [...stack] : [];
let ticks = Number.isInteger(pendingTicks) ? pendingTicks : 0;
let atLineStart = lineStart !== false;
const flushTicks = () => {
if (ticks > 0) {
if (atLineStart && ticks >= 3) {
applyFenceMarker(nextStack, ticks);
}
atLineStart = false;
ticks = 0;
}
};
for (let i = 0; i < chunk.length; i += 1) {
const ch = chunk[i];
if (ch === '`') {
ticks += 1;
continue;
}
flushTicks();
if (ch === '\n' || ch === '\r') {
atLineStart = true;
continue;
}
if ((ch === ' ' || ch === '\t') && atLineStart) {
continue;
}
atLineStart = false;
}
// keep ticks for cross-chunk continuation.
return {
stack: nextStack,
pendingTicks: ticks,
lineStart: atLineStart,
};
}
function applyFenceMarker(stack, ticks) {
if (!Array.isArray(stack)) {
return;
}
if (stack.length === 0) {
stack.push(ticks);
return;
}
const top = stack[stack.length - 1];
if (ticks >= top) {
stack.pop();
return;
}
// nested/open inner fence using longer marker for robustness.
stack.push(ticks);
}
function hasMeaningfulText(text) {
return toStringSafe(text) !== '';
}
@@ -88,6 +177,8 @@ module.exports = {
appendTail,
looksLikeToolExampleContext,
insideCodeFence,
insideCodeFenceWithState,
updateCodeFenceState,
hasMeaningfulText,
toStringSafe,
};

View File

@@ -0,0 +1,30 @@
'use strict';
const TOOL_SEGMENT_KEYWORDS = [
'tool_calls',
'"function"',
'function.name:',
'[tool_call_history]',
'[tool_result_history]',
];
function earliestKeywordIndex(text, keywords = TOOL_SEGMENT_KEYWORDS, offset = 0) {
if (!text) {
return { index: -1, keyword: '' };
}
let index = -1;
let keyword = '';
for (const kw of keywords) {
const candidate = text.indexOf(kw, offset);
if (candidate >= 0 && (index < 0 || candidate < index)) {
index = candidate;
keyword = kw;
}
}
return { index, keyword };
}
module.exports = {
TOOL_SEGMENT_KEYWORDS,
earliestKeywordIndex,
};

View File

@@ -36,6 +36,12 @@ func MessagesPrepare(messages []map[string]any) string {
switch m.Role {
case "assistant":
parts = append(parts, "<Assistant>"+m.Text+"<end▁of▁sentence>")
case "tool":
if i > 0 {
parts = append(parts, "<Tool>"+m.Text)
} else {
parts = append(parts, m.Text)
}
case "user", "system":
if i > 0 {
parts = append(parts, "<User>"+m.Text)

View File

@@ -7,7 +7,8 @@ import (
var toolCallPattern = regexp.MustCompile(`\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}`)
var fencedJSONPattern = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
var fencedBlockPattern = regexp.MustCompile("(?s)```.*?```")
var fencedCodeBlockPattern = regexp.MustCompile("(?s)```[\\s\\S]*?```")
var markupToolSyntaxPattern = regexp.MustCompile(`(?i)<(?:(?:[a-z0-9_:-]+:)?(?:tool_call|function_call|invoke)\b|(?:[a-z0-9_:-]+:)?function_calls\b|(?:[a-z0-9_:-]+:)?tool_use\b)`)
func buildToolCallCandidates(text string) []string {
trimmed := strings.TrimSpace(text)
@@ -29,6 +30,12 @@ func buildToolCallCandidates(text string) []string {
if first >= 0 && last > first {
candidates = append(candidates, strings.TrimSpace(trimmed[first:last+1]))
}
// best-effort array slice: from first '[' to last ']'
firstArr := strings.Index(trimmed, "[")
lastArr := strings.LastIndex(trimmed, "]")
if firstArr >= 0 && lastArr > firstArr {
candidates = append(candidates, strings.TrimSpace(trimmed[firstArr:lastArr+1]))
}
// legacy regex extraction fallback
if m := toolCallPattern.FindStringSubmatch(trimmed); len(m) >= 2 {
@@ -57,7 +64,7 @@ func extractToolCallObjects(text string) []string {
lower := strings.ToLower(text)
out := []string{}
offset := 0
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]"}
for {
bestIdx := -1
matchedKeyword := ""
@@ -82,12 +89,12 @@ func extractToolCallObjects(text string) []string {
if searchLimit < offset {
searchLimit = offset
}
start := strings.LastIndex(text[searchLimit:idx], "{")
if start >= 0 {
start += searchLimit
}
if start < 0 {
offset = idx + len(matchedKeyword)
continue
@@ -113,7 +120,7 @@ func extractToolCallObjects(text string) []string {
}
break
}
if !foundObj {
offset = idx + len(matchedKeyword)
}
@@ -175,9 +182,21 @@ func looksLikeToolExampleContext(text string) bool {
return strings.Contains(t, "```")
}
func shouldSkipToolCallParsingForCodeFenceExample(text string) bool {
if !looksLikeToolCallSyntax(text) {
return false
}
stripped := strings.TrimSpace(stripFencedCodeBlocks(text))
return !looksLikeToolCallSyntax(stripped)
}
func looksLikeMarkupToolSyntax(text string) bool {
return markupToolSyntaxPattern.MatchString(text)
}
func stripFencedCodeBlocks(text string) string {
if strings.TrimSpace(text) == "" {
if text == "" {
return ""
}
return fencedBlockPattern.ReplaceAllString(text, " ")
return fencedCodeBlockPattern.ReplaceAllString(text, " ")
}

View File

@@ -16,6 +16,7 @@ type ToolCallParseResult struct {
RejectedByPolicy bool
RejectedToolNames []string
}
func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
return ParseToolCallsDetailed(text, availableToolNames).Calls
}
@@ -26,6 +27,9 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(text)
if shouldSkipToolCallParsingForCodeFenceExample(text) {
return result
}
candidates := buildToolCallCandidates(text)
var parsed []ParsedToolCall
@@ -74,6 +78,9 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed)
if shouldSkipToolCallParsingForCodeFenceExample(trimmed) {
return result
}
candidates := buildToolCallCandidates(trimmed)
var parsed []ParsedToolCall
for _, candidate := range candidates {
@@ -113,56 +120,17 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
}
func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []string) ([]ParsedToolCall, []string) {
allowed := map[string]struct{}{}
allowedCanonical := map[string]string{}
for _, name := range availableToolNames {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
allowed[trimmed] = struct{}{}
lower := strings.ToLower(trimmed)
if _, exists := allowedCanonical[lower]; !exists {
allowedCanonical[lower] = trimmed
}
}
if len(allowed) == 0 {
rejectedSet := map[string]struct{}{}
rejected := make([]string, 0, len(parsed))
for _, tc := range parsed {
if tc.Name == "" {
continue
}
if _, ok := rejectedSet[tc.Name]; ok {
continue
}
rejectedSet[tc.Name] = struct{}{}
rejected = append(rejected, tc.Name)
}
return nil, rejected
}
out := make([]ParsedToolCall, 0, len(parsed))
rejectedSet := map[string]struct{}{}
rejected := make([]string, 0)
for _, tc := range parsed {
if tc.Name == "" {
continue
}
matchedName := resolveAllowedToolName(tc.Name, allowed, allowedCanonical)
if matchedName == "" {
if _, ok := rejectedSet[tc.Name]; !ok {
rejectedSet[tc.Name] = struct{}{}
rejected = append(rejected, tc.Name)
}
continue
}
tc.Name = matchedName
if tc.Input == nil {
tc.Input = map[string]any{}
}
out = append(out, tc)
}
return out, rejected
return out, nil
}
func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
@@ -183,6 +151,9 @@ func parseToolCallsPayload(payload string) []ParsedToolCall {
switch v := decoded.(type) {
case map[string]any:
if tc, ok := v["tool_calls"]; ok {
if isLikelyChatMessageEnvelope(v) {
return nil
}
return parseToolCallList(tc)
}
if parsed, ok := parseToolCallItem(v); ok {
@@ -194,11 +165,35 @@ func parseToolCallsPayload(payload string) []ParsedToolCall {
return nil
}
func isLikelyChatMessageEnvelope(v map[string]any) bool {
if v == nil {
return false
}
if _, ok := v["tool_calls"]; !ok {
return false
}
if role, ok := v["role"].(string); ok {
switch strings.ToLower(strings.TrimSpace(role)) {
case "assistant", "tool", "user", "system":
return true
}
}
if _, ok := v["tool_call_id"]; ok {
return true
}
if _, ok := v["content"]; ok {
return true
}
return false
}
func looksLikeToolCallSyntax(text string) bool {
lower := strings.ToLower(text)
return strings.Contains(lower, "tool_calls") ||
strings.Contains(lower, "\"function\"") ||
strings.Contains(lower, "<tool_call") ||
strings.Contains(lower, "<function_call") ||
strings.Contains(lower, "<function_name") ||
strings.Contains(lower, "<invoke") ||
strings.Contains(lower, "function.name:")
}

View File

@@ -15,6 +15,10 @@ var antmlArgumentPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?argument\s+
var antmlParametersPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?parameters\s*>\s*(\{.*?\})\s*</(?:[a-z0-9_]+:)?parameters>`)
var invokeCallPattern = regexp.MustCompile(`(?is)<invoke\s+name="([^"]+)"\s*>(.*?)</invoke>`)
var invokeParamPattern = regexp.MustCompile(`(?is)<parameter\s+name="([^"]+)"\s*>\s*(.*?)\s*</parameter>`)
var toolUseFunctionPattern = regexp.MustCompile(`(?is)<tool_use>\s*<function\s+name="([^"]+)"\s*>(.*?)</function>\s*</tool_use>`)
var toolUseNameParametersPattern = regexp.MustCompile(`(?is)<tool_use>\s*<tool_name>\s*([^<]+?)\s*</tool_name>\s*<parameters>\s*(.*?)\s*</parameters>\s*</tool_use>`)
var toolUseFunctionNameParametersPattern = regexp.MustCompile(`(?is)<tool_use>\s*<function_name>\s*([^<]+?)\s*</function_name>\s*<parameters>\s*(.*?)\s*</parameters>\s*</tool_use>`)
var toolUseToolNameBodyPattern = regexp.MustCompile(`(?is)<tool_use>\s*<tool_name>\s*([^<]+?)\s*</tool_name>\s*(.*?)\s*</tool_use>`)
func parseXMLToolCalls(text string) []ParsedToolCall {
matches := xmlToolCallPattern.FindAllString(text, -1)
@@ -38,6 +42,18 @@ func parseXMLToolCalls(text string) []ParsedToolCall {
if call, ok := parseInvokeFunctionCallStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseFunctionStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseNameParametersStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseFunctionNameParametersStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseToolNameBodyStyle(text); ok {
return []ParsedToolCall{call}
}
return nil
}
@@ -229,6 +245,128 @@ func parseInvokeFunctionCallStyle(text string) (ParsedToolCall, bool) {
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseFunctionStyle(text string) (ParsedToolCall, bool) {
m := toolUseFunctionPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(m[1])
if name == "" {
return ParsedToolCall{}, false
}
body := m[2]
input := map[string]any{}
for _, pm := range invokeParamPattern.FindAllStringSubmatch(body, -1) {
if len(pm) < 3 {
continue
}
k := strings.TrimSpace(pm[1])
v := strings.TrimSpace(pm[2])
if k != "" {
input[k] = v
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseNameParametersStyle(text string) (ParsedToolCall, bool) {
m := toolUseNameParametersPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(m[1])
if name == "" {
return ParsedToolCall{}, false
}
raw := strings.TrimSpace(m[2])
input := map[string]any{}
if raw != "" {
if parsed := parseToolCallInput(raw); len(parsed) > 0 {
input = parsed
} else if kv := parseMarkupKVObject(raw); len(kv) > 0 {
input = kv
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseFunctionNameParametersStyle(text string) (ParsedToolCall, bool) {
m := toolUseFunctionNameParametersPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(m[1])
if name == "" {
return ParsedToolCall{}, false
}
raw := strings.TrimSpace(m[2])
input := map[string]any{}
if raw != "" {
if parsed := parseToolCallInput(raw); len(parsed) > 0 {
input = parsed
} else if kv := parseMarkupKVObject(raw); len(kv) > 0 {
input = kv
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseToolNameBodyStyle(text string) (ParsedToolCall, bool) {
m := toolUseToolNameBodyPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(m[1])
if name == "" {
return ParsedToolCall{}, false
}
body := strings.TrimSpace(m[2])
input := map[string]any{}
if body != "" {
if kv := parseXMLChildKV(body); len(kv) > 0 {
input = kv
} else if kv := parseMarkupKVObject(body); len(kv) > 0 {
input = kv
} else if parsed := parseToolCallInput(body); len(parsed) > 0 {
input = parsed
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseXMLChildKV(body string) map[string]any {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return nil
}
dec := xml.NewDecoder(strings.NewReader("<root>" + trimmed + "</root>"))
out := map[string]any{}
for {
tok, err := dec.Token()
if err != nil {
break
}
start, ok := tok.(xml.StartElement)
if !ok || strings.EqualFold(start.Name.Local, "root") {
continue
}
var v string
if err := dec.DecodeElement(&v, &start); err != nil {
continue
}
key := strings.TrimSpace(start.Name.Local)
val := strings.TrimSpace(v)
if key == "" || val == "" {
continue
}
out[key] = val
}
if len(out) == 0 {
return nil
}
return out
}
func asString(v any) string {
s, _ := v.(string)
return s

View File

@@ -19,11 +19,11 @@ func TestParseToolCalls(t *testing.T) {
}
}
func TestParseToolCallsFromFencedJSON(t *testing.T) {
func TestParseToolCallsIgnoresFencedJSON(t *testing.T) {
text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```"
calls := ParseToolCalls(text, []string{"search"})
if len(calls) != 1 {
t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
if len(calls) != 0 {
t.Fatalf("expected fenced tool_call payload to be ignored, got %#v", calls)
}
}
@@ -41,50 +41,50 @@ func TestParseToolCallsWithFunctionArgumentsString(t *testing.T) {
}
}
func TestParseToolCallsRejectsUnknownToolName(t *testing.T) {
func TestParseToolCallsKeepsUnknownToolName(t *testing.T) {
text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
calls := ParseToolCalls(text, []string{"search"})
if len(calls) != 0 {
t.Fatalf("expected unknown tool to be rejected, got %#v", calls)
if len(calls) != 1 || calls[0].Name != "unknown" {
t.Fatalf("expected unknown tool to be preserved, got %#v", calls)
}
}
func TestParseToolCallsAllowsCaseInsensitiveToolNameAndCanonicalizes(t *testing.T) {
func TestParseToolCallsKeepsOriginalToolNameCase(t *testing.T) {
text := `{"tool_calls":[{"name":"Bash","input":{"command":"ls -al"}}]}`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "bash" {
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
}
func TestParseToolCallsDetailedMarksPolicyRejection(t *testing.T) {
func TestParseToolCallsDetailedDoesNotRejectByPolicy(t *testing.T) {
text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
res := ParseToolCallsDetailed(text, []string{"search"})
if !res.SawToolCallSyntax {
t.Fatalf("expected SawToolCallSyntax=true, got %#v", res)
}
if !res.RejectedByPolicy {
t.Fatalf("expected RejectedByPolicy=true, got %#v", res)
if res.RejectedByPolicy {
t.Fatalf("expected RejectedByPolicy=false, got %#v", res)
}
if len(res.Calls) != 0 {
t.Fatalf("expected no calls after policy rejection, got %#v", res.Calls)
if len(res.Calls) != 1 || res.Calls[0].Name != "unknown" {
t.Fatalf("expected call to be preserved, got %#v", res.Calls)
}
}
func TestParseToolCallsDetailedRejectsWhenAllowListEmpty(t *testing.T) {
func TestParseToolCallsDetailedAllowsWhenAllowListEmpty(t *testing.T) {
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
res := ParseToolCallsDetailed(text, nil)
if !res.SawToolCallSyntax {
t.Fatalf("expected SawToolCallSyntax=true, got %#v", res)
}
if !res.RejectedByPolicy {
t.Fatalf("expected RejectedByPolicy=true, got %#v", res)
if res.RejectedByPolicy {
t.Fatalf("expected RejectedByPolicy=false, got %#v", res)
}
if len(res.Calls) != 0 {
t.Fatalf("expected no calls when allow-list is empty, got %#v", res.Calls)
if len(res.Calls) != 1 || res.Calls[0].Name != "search" {
t.Fatalf("expected calls when allow-list is empty, got %#v", res.Calls)
}
}
@@ -112,10 +112,17 @@ func TestParseStandaloneToolCallsSupportsMixedProsePayload(t *testing.T) {
}
}
func TestParseStandaloneToolCallsParsesFencedCodeBlock(t *testing.T) {
func TestParseStandaloneToolCallsIgnoresFencedCodeBlock(t *testing.T) {
fenced := "```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```"
if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 1 {
t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 0 {
t.Fatalf("expected fenced tool_call payload to be ignored, got %#v", calls)
}
}
func TestParseStandaloneToolCallsIgnoresChatTranscriptEnvelope(t *testing.T) {
transcript := `[{"role":"user","content":"请展示完整会话"},{"role":"assistant","content":null,"tool_calls":[{"function":{"name":"search","arguments":"{\"q\":\"go\"}"}}]}]`
if calls := ParseStandaloneToolCalls(transcript, []string{"search"}); len(calls) != 0 {
t.Fatalf("expected transcript envelope not to trigger tool call parse, got %#v", calls)
}
}
@@ -125,8 +132,8 @@ func TestParseToolCallsAllowsQualifiedToolName(t *testing.T) {
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "search_web" {
t.Fatalf("expected canonical tool name search_web, got %q", calls[0].Name)
if calls[0].Name != "mcp.search_web" {
t.Fatalf("expected original tool name mcp.search_web, got %q", calls[0].Name)
}
}
@@ -136,8 +143,8 @@ func TestParseToolCallsAllowsPunctuationVariantToolName(t *testing.T) {
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "read_file" {
t.Fatalf("expected canonical tool name read_file, got %q", calls[0].Name)
if calls[0].Name != "read-file" {
t.Fatalf("expected original tool name read-file, got %q", calls[0].Name)
}
}
@@ -147,8 +154,8 @@ func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) {
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "bash" {
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -172,8 +179,8 @@ func TestParseToolCallsSupportsClaudeXMLJSONToolCall(t *testing.T) {
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "bash" {
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -186,8 +193,8 @@ func TestParseToolCallsSupportsFunctionCallTagStyle(t *testing.T) {
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "bash" {
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "ls -la" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -200,8 +207,8 @@ func TestParseToolCallsSupportsAntmlFunctionCallStyle(t *testing.T) {
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "bash" {
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -214,8 +221,8 @@ func TestParseToolCallsSupportsAntmlArgumentStyle(t *testing.T) {
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "bash" {
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -228,22 +235,78 @@ func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) {
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "bash" {
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseFunctionParameterStyle(t *testing.T) {
text := `<tool_use><function name="search_web"><parameter name="query">test</parameter></function></tool_use>`
calls := ParseToolCalls(text, []string{"search_web"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "search_web" {
t.Fatalf("expected canonical tool name search_web, got %q", calls[0].Name)
}
if calls[0].Input["query"] != "test" {
t.Fatalf("expected query argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseNameParametersStyle(t *testing.T) {
text := `<tool_use><tool_name>write_file</tool_name><parameters>{"path":"/tmp/a.txt","content":"abc"}</parameters></tool_use>`
calls := ParseToolCalls(text, []string{"write_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "write_file" {
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
}
if calls[0].Input["path"] != "/tmp/a.txt" {
t.Fatalf("expected path argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseFunctionNameParametersStyle(t *testing.T) {
text := `<tool_use><function_name>write_file</function_name><parameters>{"path":"/tmp/b.txt","content":"xyz"}</parameters></tool_use>`
calls := ParseToolCalls(text, []string{"write_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "write_file" {
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
}
if calls[0].Input["content"] != "xyz" {
t.Fatalf("expected content argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseToolNameBodyStyle(t *testing.T) {
text := `<tool_use><tool_name>write_file</tool_name><path>/tmp/c.txt</path><content>hello</content></tool_use>`
calls := ParseToolCalls(text, []string{"write_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "write_file" {
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
}
if calls[0].Input["path"] != "/tmp/c.txt" {
t.Fatalf("expected path argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsNestedToolTagStyle(t *testing.T) {
text := `<tool_call><tool name="Bash"><command>pwd</command><description>show cwd</description></tool></tool_call>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "bash" {
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -256,8 +319,8 @@ func TestParseToolCallsSupportsAntmlFunctionAttributeWithParametersTag(t *testin
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "bash" {
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -270,8 +333,8 @@ func TestParseToolCallsSupportsMultipleAntmlFunctionCalls(t *testing.T) {
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %#v", calls)
}
if calls[0].Name != "bash" || calls[1].Name != "read" {
t.Fatalf("expected canonical names [bash read], got %#v", calls)
if calls[0].Name != "Bash" || calls[1].Name != "Read" {
t.Fatalf("expected original names [Bash Read], got %#v", calls)
}
}

View File

@@ -364,8 +364,8 @@ func TestFormatOpenAIStreamToolCalls(t *testing.T) {
func TestParseToolCallsNoToolNames(t *testing.T) {
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
calls := ParseToolCalls(text, nil)
if len(calls) != 0 {
t.Fatalf("expected 0 call with nil tool names, got %d", len(calls))
if len(calls) != 1 {
t.Fatalf("expected 1 call with nil tool names, got %d", len(calls))
}
}
@@ -409,8 +409,8 @@ func TestParseToolCallsWithFunctionWrapper(t *testing.T) {
func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) {
fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this."
calls := ParseStandaloneToolCalls(fenced, []string{"search"})
if len(calls) != 1 {
t.Fatalf("expected fenced code block to be parsed, got %d calls", len(calls))
if len(calls) != 0 {
t.Fatalf("expected fenced code block to be ignored, got %d calls", len(calls))
}
}

View File

@@ -53,7 +53,6 @@ internal/adapter/openai/responses_stream_runtime_events.go
internal/adapter/openai/responses_stream_runtime_toolcalls.go
internal/adapter/openai/tool_sieve_state.go
internal/adapter/openai/tool_sieve_core.go
internal/adapter/openai/tool_sieve_incremental.go
internal/adapter/openai/tool_sieve_jsonscan.go
internal/util/toolcalls_parse.go
@@ -117,7 +116,6 @@ webui/src/app/useAdminAuth.js
webui/src/app/useAdminConfig.js
webui/src/layout/DashboardShell.jsx
webui/src/components/AccountManager.jsx
webui/src/features/account/AccountManagerContainer.jsx
webui/src/features/account/useAccountsData.js
webui/src/features/account/useAccountActions.js
@@ -127,14 +125,12 @@ webui/src/features/account/AccountsTable.jsx
webui/src/features/account/AddKeyModal.jsx
webui/src/features/account/AddAccountModal.jsx
webui/src/components/ApiTester.jsx
webui/src/features/apiTester/ApiTesterContainer.jsx
webui/src/features/apiTester/useApiTesterState.js
webui/src/features/apiTester/useChatStreamClient.js
webui/src/features/apiTester/ConfigPanel.jsx
webui/src/features/apiTester/ChatPanel.jsx
webui/src/components/Settings.jsx
webui/src/features/settings/SettingsContainer.jsx
webui/src/features/settings/useSettingsForm.js
webui/src/features/settings/settingsApi.js
@@ -144,7 +140,6 @@ webui/src/features/settings/BehaviorSection.jsx
webui/src/features/settings/ModelSection.jsx
webui/src/features/settings/BackupSection.jsx
webui/src/components/VercelSync.jsx
webui/src/features/vercel/VercelSyncContainer.jsx
webui/src/features/vercel/useVercelSyncState.js
webui/src/features/vercel/VercelSyncForm.jsx

View File

@@ -1,8 +1,13 @@
{
"calls": [],
"calls": [
{
"name": "unknown_tool",
"input": {
"x": 1
}
}
],
"sawToolCallSyntax": true,
"rejectedByPolicy": true,
"rejectedToolNames": [
"unknown_tool"
]
}
"rejectedByPolicy": false,
"rejectedToolNames": []
}

View File

@@ -1,7 +1,7 @@
{
"calls": [
{
"name": "read_file",
"name": "Read_File",
"input": {
"path": "README.MD"
}
@@ -10,4 +10,4 @@
"sawToolCallSyntax": true,
"rejectedByPolicy": false,
"rejectedToolNames": []
}
}

View File

@@ -1,12 +1,5 @@
{
"calls": [
{
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"calls": [],
"sawToolCallSyntax": true,
"rejectedByPolicy": false,
"rejectedToolNames": []

View File

@@ -1,7 +1,7 @@
{
"calls": [
{
"name": "read_file",
"name": "read-file",
"input": {
"path": "README.MD"
}
@@ -10,4 +10,4 @@
"sawToolCallSyntax": true,
"rejectedByPolicy": false,
"rejectedToolNames": []
}
}

View File

@@ -1,7 +1,7 @@
{
"calls": [
{
"name": "read_file",
"name": "company.fs.read_file",
"input": {
"path": "README.MD"
}
@@ -10,4 +10,4 @@
"sawToolCallSyntax": true,
"rejectedByPolicy": false,
"rejectedToolNames": []
}
}

View File

@@ -1,12 +1,5 @@
{
"calls": [
{
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"calls": [],
"sawToolCallSyntax": true,
"rejectedByPolicy": false,
"rejectedToolNames": []

View File

@@ -1,8 +1,13 @@
{
"calls": [],
"calls": [
{
"name": "unknown_tool",
"input": {
"x": 1
}
}
],
"sawToolCallSyntax": true,
"rejectedByPolicy": true,
"rejectedToolNames": [
"unknown_tool"
]
}
"rejectedByPolicy": false,
"rejectedToolNames": []
}

View File

@@ -44,7 +44,7 @@ test('resolveToolcallPolicy respects prepare flags and prepared tool names', ()
[{ type: 'function', function: { name: 'fallback_tool', parameters: { type: 'object' } } }],
);
assert.deepEqual(policy.toolNames, ['prepped_tool']);
assert.equal(policy.toolSieveEnabled, false);
assert.equal(policy.toolSieveEnabled, true);
assert.equal(policy.emitEarlyToolDeltas, false);
});
@@ -58,7 +58,7 @@ test('boolDefaultTrue keeps false only when explicitly false', () => {
assert.equal(boolDefaultTrue(undefined), true);
});
test('filterIncrementalToolCallDeltasByAllowed blocks unknown name and follow-up args', () => {
test('filterIncrementalToolCallDeltasByAllowed keeps unknown name and follow-up args', () => {
const seen = new Map();
const filtered = filterIncrementalToolCallDeltasByAllowed(
[
@@ -68,8 +68,11 @@ test('filterIncrementalToolCallDeltasByAllowed blocks unknown name and follow-up
['read_file'],
seen,
);
assert.deepEqual(filtered, []);
assert.equal(seen.get(0), '__blocked__');
assert.deepEqual(filtered, [
{ index: 0, name: 'not_in_schema' },
{ index: 0, arguments: '{"x":1}' },
]);
assert.equal(seen.get(0), 'not_in_schema');
});
test('filterIncrementalToolCallDeltasByAllowed keeps allowed name and args', () => {

View File

@@ -55,36 +55,37 @@ test('parseToolCalls keeps non-object argument strings as _raw (Go parity)', ()
]);
});
test('parseToolCalls drops unknown schema names when toolNames is provided', () => {
test('parseToolCalls keeps unknown schema names when toolNames is provided', () => {
const payload = JSON.stringify({
tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }],
});
const calls = parseToolCalls(payload, ['search']);
assert.equal(calls.length, 0);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'not_in_schema');
});
test('parseToolCalls matches tool name case-insensitively and canonicalizes', () => {
test('parseToolCalls keeps original tool name casing', () => {
const payload = JSON.stringify({
tool_calls: [{ name: 'Read_File', input: { path: 'README.MD' } }],
});
const calls = parseToolCalls(payload, ['read_file']);
assert.deepEqual(calls, [{ name: 'read_file', input: { path: 'README.MD' } }]);
assert.deepEqual(calls, [{ name: 'Read_File', input: { path: 'README.MD' } }]);
});
test('parseToolCalls rejects all names when toolNames is empty (Go strict parity)', () => {
test('parseToolCalls accepts all names when toolNames is empty', () => {
const payload = JSON.stringify({
tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }],
});
const calls = parseToolCalls(payload, []);
assert.equal(calls.length, 0);
assert.equal(calls.length, 1);
const detailed = parseToolCallsDetailed(payload, []);
assert.equal(detailed.sawToolCallSyntax, true);
assert.equal(detailed.rejectedByPolicy, true);
assert.deepEqual(detailed.rejectedToolNames, ['not_in_schema']);
assert.equal(detailed.rejectedByPolicy, false);
assert.deepEqual(detailed.rejectedToolNames, []);
});
test('parseToolCalls supports fenced json and function.arguments string payload', () => {
test('parseToolCalls ignores tool_call payloads that exist only inside fenced code blocks', () => {
const text = [
'I will call a tool now.',
'```json',
@@ -92,9 +93,7 @@ test('parseToolCalls supports fenced json and function.arguments string payload'
'```',
].join('\n');
const calls = parseToolCalls(text, ['read_file']);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'read_file');
assert.equal(calls[0].input.path, 'README.md');
assert.equal(calls.length, 0);
});
test('parseToolCalls parses text-kv fallback payload', () => {
@@ -134,10 +133,23 @@ test('parseStandaloneToolCalls parses mixed prose payload', () => {
assert.equal(standaloneCalls.length, 1);
});
test('parseStandaloneToolCalls parses fenced code block tool_call payload', () => {
test('parseStandaloneToolCalls ignores fenced code block tool_call payload', () => {
const fenced = ['```json', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}', '```'].join('\n');
const calls = parseStandaloneToolCalls(fenced, ['read_file']);
assert.equal(calls.length, 1);
assert.equal(calls.length, 0);
});
test('parseStandaloneToolCalls ignores chat transcript message envelope with tool_calls', () => {
const transcript = JSON.stringify([
{ role: 'user', content: '请展示完整会话' },
{
role: 'assistant',
content: null,
tool_calls: [{ function: { name: 'read_file', arguments: '{"path":"README.MD"}' } }],
},
]);
const calls = parseStandaloneToolCalls(transcript, ['read_file']);
assert.equal(calls.length, 0);
});
@@ -226,7 +238,57 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', ()
assert.equal(leakedText, '你好,这是普通文本回复。请继续。');
});
test('sieve intercepts rejected unknown tool payload (no args) without raw leak', () => {
test('sieve swallows leaked TOOL_CALL_HISTORY marker blocks', () => {
const events = runSieve(
[
'前置文本。',
'[TOOL_CALL_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]',
'后置文本。',
],
['exec'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls');
assert.equal(hasToolCall, false);
assert.equal(leakedText.includes('前置文本。'), true);
assert.equal(leakedText.includes('后置文本。'), true);
assert.equal(leakedText.includes('[TOOL_CALL_HISTORY]'), false);
});
test('sieve swallows leaked TOOL_RESULT_HISTORY marker blocks', () => {
const events = runSieve(
[
'前置文本。',
'[TOOL_RESULT_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]',
'后置文本。',
],
['exec'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls');
assert.equal(hasToolCall, false);
assert.equal(leakedText.includes('前置文本。'), true);
assert.equal(leakedText.includes('后置文本。'), true);
assert.equal(leakedText.includes('[TOOL_RESULT_HISTORY]'), false);
});
test('sieve preserves text spacing when TOOL_RESULT_HISTORY spans chunks', () => {
const events = runSieve(
[
'Hello ',
'[TOOL_RESULT_HISTORY]\nstatus: already_called\n',
'function.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]',
'world',
],
['exec'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
assert.equal(hasToolCall, false);
assert.equal(leakedText, 'Hello world');
});
test('sieve emits unknown tool payload (no args) as executable tool call', () => {
const events = runSieve(
['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'],
['read_file'],
@@ -234,8 +296,7 @@ test('sieve intercepts rejected unknown tool payload (no args) without raw leak'
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0);
const hasToolDelta = events.some((evt) => evt.type === 'tool_call_deltas' && Array.isArray(evt.deltas) && evt.deltas.length > 0);
assert.equal(hasToolCall, false);
assert.equal(hasToolDelta, false);
assert.equal(hasToolCall || hasToolDelta, true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
assert.equal(leakedText.includes('后置正文G。'), true);
});
@@ -298,6 +359,59 @@ test('sieve preserves closed fence before standalone tool payload', () => {
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
});
test('sieve does not trigger tool calls for long fenced examples beyond legacy tail window', () => {
const longPadding = 'x'.repeat(700);
const events = runSieve(
[
`前置说明\n\`\`\`json\n${longPadding}\n`,
'{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}\n',
'```',
'\n后置说明',
],
['read_file'],
);
const hasTool = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
const leakedText = collectText(events);
assert.equal(hasTool, false);
assert.equal(leakedText.includes('后置说明'), true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), true);
});
test('sieve keeps fence state when triple-backticks are split across chunks', () => {
const events = runSieve(
[
'示例开始\n``',
'`json\n{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}\n',
'```',
'\n示例结束',
],
['read_file'],
);
const hasTool = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
const leakedText = collectText(events);
assert.equal(hasTool, false);
assert.equal(leakedText.includes('示例结束'), true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), true);
});
test('sieve ignores tool-like payload inside nested fences and resumes detection after close', () => {
const events = runSieve(
[
'外层示例开始\n````markdown\n',
'```json\n{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}\n```\n',
'````\n',
'{"tool_calls":[{"name":"read_file","input":{"path":"README2.MD"}}]}',
],
['read_file'],
);
const calls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []);
const leakedText = collectText(events);
assert.equal(calls.length, 1);
assert.equal(calls[0].input.path, 'README2.MD');
assert.equal(leakedText.includes('README.MD'), true);
assert.equal(leakedText.includes('README2.MD'), false);
});
test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => {
const idStore = new Map();
const calls = [{ name: 'read_file', input: { path: 'README.MD' } }];

View File

@@ -12,11 +12,7 @@ is_entry_file() {
case "$1" in
api/chat-stream.js|\
internal/js/helpers/stream-tool-sieve.js|\
webui/src/App.jsx|\
webui/src/components/AccountManager.jsx|\
webui/src/components/ApiTester.jsx|\
webui/src/components/Settings.jsx|\
webui/src/components/VercelSync.jsx)
webui/src/App.jsx)
return 0
;;
esac

View File

@@ -0,0 +1,324 @@
```json
[
{
"role": "user",
"content": "测试全部工具调用。\n\n<time_reminder>Current time: 星期日, 2026年3月22日 下午1:49:35 (2 h since last message)</time_reminder>"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_52ee6ea078dc4bae847d007d0337ae28",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"create\",\"content\":\"用户于2026年3月22日测试了全部工具调用功能\"}"
}
},
{
"id": "call_09da4c5bca97450ca4f921102b77188f",
"type": "function",
"function": {
"name": "search_web",
"arguments": "{\"query\":\"工具调用测试\"}"
}
},
{
"id": "call_c703303b1da1453abe39783899f0c405",
"type": "function",
"function": {
"name": "eval_javascript",
"arguments": "{\"code\":\"const a = 1, b = 2; a + b\"}"
}
},
{
"id": "call_aa8aa0dd2a884eadba770c18d1870751",
"type": "function",
"function": {
"name": "get_time_info",
"arguments": "{}"
}
},
{
"id": "call_18dc92b50bcb4861828f579cb939d723",
"type": "function",
"function": {
"name": "clipboard_tool",
"arguments": "{\"action\":\"read\"}"
}
},
{
"id": "call_7db0fa98a4654f9fb12666799eeba69a",
"type": "function",
"function": {
"name": "text_to_speech",
"arguments": "{\"text\":\"这是工具调用测试,正在朗读文本。\"}"
}
},
{
"id": "call_60f77268a95e4307a4aafbd389947cb3",
"type": "function",
"function": {
"name": "ask_user",
"arguments": "{\"questions\":[{\"id\":\"test_q\",\"question\":\"工具调用测试:你确认要测试全部工具吗?\",\"options\":[\"是\",\"否\"]}]}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_52ee6ea078dc4bae847d007d0337ae28",
"content": "{\"id\":85,\"content\":\"用户于2026年3月22日测试了全部工具调用功能\"}"
},
{
"role": "tool",
"tool_call_id": "call_09da4c5bca97450ca4f921102b77188f",
"content": "{\"error\":\"[java.lang.IllegalArgumentException] Search failed: no results found\\njava.lang.IllegalArgumentException: Search failed: no results found\\n\\tat me.rerere.search.BingSearchService$search$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:345)\\n\\tat me.rerere.search.BingSearchService$search$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat kotlinx.coroutines.intrinsics.UndispatchedKt.startUndspatched(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:19)\\n\\tat kotlinx.coroutines.JobKt.withContext(Unknown Source:80)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:59)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:73)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:86)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat me.rerere.rikkahub.data.ai.GenerationHandler$generateText$1.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:2015)\\n\\tat kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:9)\\n\\tat kotlinx.coroutines.DispatchedTask.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:114)\\n\\tat com.google.android.gms.tasks.zze.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:15)\\n\\tat kotlinx.coroutines.scheduling.TaskImpl.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:3)\\n\\tat kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:88)\\n\"}"
},
{
"role": "tool",
"tool_call_id": "call_c703303b1da1453abe39783899f0c405",
"content": "{\"result\":\"3\"}"
},
{
"role": "tool",
"tool_call_id": "call_aa8aa0dd2a884eadba770c18d1870751",
"content": "{\"year\":2026,\"month\":3,\"day\":22,\"weekday\":\"星期日\",\"weekday_en\":\"Sunday\",\"weekday_index\":7,\"date\":\"2026-03-22\",\"time\":\"13:50:08\",\"datetime\":\"2026-03-22T13:50:08+08:00[Asia/Shanghai]\",\"timezone\":\"Asia/Shanghai\",\"utc_offset\":\"+08:00\",\"timestamp_ms\":1774158608084}"
},
{
"role": "tool",
"tool_call_id": "call_18dc92b50bcb4861828f579cb939d723",
"content": "{\"text\":\"\"}"
},
{
"role": "tool",
"tool_call_id": "call_7db0fa98a4654f9fb12666799eeba69a",
"content": "{\"success\":true}"
},
{
"role": "tool",
"tool_call_id": "call_60f77268a95e4307a4aafbd389947cb3",
"content": "{\"answers\":{\"test_q\":\"是\"}}"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_b5621f1b4d234fc1806dca0b00879e7c",
"type": "function",
"function": {
"name": "get_time_info",
"arguments": "{}"
}
},
{
"id": "call_b9d0cedf29414fdc8115fdab81753118",
"type": "function",
"function": {
"name": "eval_javascript",
"arguments": "{\"code\":\"const numbers = [1, 2, 3, 4, 5]; const sum = numbers.reduce((a, b) => a + b, 0); const average = sum / numbers.length; `总和: ${sum}, 平均值: ${average.toFixed(2)}`;\"}"
}
},
{
"id": "call_7c9532c629aa411abf2a96adce1ec2f8",
"type": "function",
"function": {
"name": "search_web",
"arguments": "{\"query\":\"AI 2026年3月 最新进展\"}"
}
},
{
"id": "call_96b28ea042104ccd852125b95ff2afc7",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"create\",\"content\":\"2026年3月22日用户进行了全部工具调用的测试确认了测试意愿。\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_b5621f1b4d234fc1806dca0b00879e7c",
"content": "{\"year\":2026,\"month\":3,\"day\":22,\"weekday\":\"星期日\",\"weekday_en\":\"Sunday\",\"weekday_index\":7,\"date\":\"2026-03-22\",\"time\":\"13:50:08\",\"datetime\":\"2026-03-22T13:50:08+08:00[Asia/Shanghai]\",\"timezone\":\"Asia/Shanghai\",\"utc_offset\":\"+08:00\",\"timestamp_ms\":1774158608092}"
},
{
"role": "tool",
"tool_call_id": "call_b9d0cedf29414fdc8115fdab81753118",
"content": "{\"result\":\"总和: 15, 平均值: 3.00\"}"
},
{
"role": "tool",
"tool_call_id": "call_7c9532c629aa411abf2a96adce1ec2f8",
"content": "{\"error\":\"[java.lang.IllegalArgumentException] Search failed: no results found\\njava.lang.IllegalArgumentException: Search failed: no results found\\n\\tat me.rerere.search.BingSearchService$search$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:345)\\n\\tat me.rerere.search.BingSearchService$search$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat kotlinx.coroutines.intrinsics.UndispatchedKt.startUndspatched(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:19)\\n\\tat kotlinx.coroutines.JobKt.withContext(Unknown Source:80)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:59)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:73)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:86)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat me.rerere.rikkahub.data.ai.GenerationHandler$generateText$1.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:2015)\\n\\tat kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:9)\\n\\tat kotlinx.coroutines.DispatchedTask.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:114)\\n\\tat com.google.android.gms.tasks.zze.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:15)\\n\\tat kotlinx.coroutines.scheduling.TaskImpl.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:3)\\n\\tat kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:88)\\n\"}"
},
{
"role": "tool",
"tool_call_id": "call_96b28ea042104ccd852125b95ff2afc7",
"content": "{\"id\":86,\"content\":\"2026年3月22日用户进行了全部工具调用的测试确认了测试意愿。\"}"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_6decb5e46f574e41ba5e0711435937b8",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"create\",\"content\":\"用户于2026年3月22日进行了全部工具调用测试测试过程中确认了测试意愿完成了记忆存储、代码执行、时间获取、剪贴板读取、语音朗读和用户询问等功能的验证。\"}"
}
},
{
"id": "call_27f2d81ab07f46ee968dd7c41da8d58e",
"type": "function",
"function": {
"name": "eval_javascript",
"arguments": "{\"code\":\"const data = {test: '全部工具调用测试', status: 'success', timestamp: Date.now()}; JSON.stringify(data, null, 2)\"}"
}
},
{
"id": "call_1de5d2f936754354a34d6ff535ac6b6f",
"type": "function",
"function": {
"name": "get_time_info",
"arguments": "{}"
}
},
{
"id": "call_cab1ab7cb7de4957af82a13f43e8e411",
"type": "function",
"function": {
"name": "clipboard_tool",
"arguments": "{\"action\":\"read\"}"
}
},
{
"id": "call_2db3503096ac4a94b32c8624dd11b8fc",
"type": "function",
"function": {
"name": "search_web",
"arguments": "{\"query\":\"工具调用测试 2026\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_6decb5e46f574e41ba5e0711435937b8",
"content": "{\"id\":87,\"content\":\"用户于2026年3月22日进行了全部工具调用测试测试过程中确认了测试意愿完成了记忆存储、代码执行、时间获取、剪贴板读取、语音朗读和用户询问等功能的验证。\"}"
},
{
"role": "tool",
"tool_call_id": "call_27f2d81ab07f46ee968dd7c41da8d58e",
"content": "{\"result\":\"{\\n \\\"test\\\": \\\"全部工具调用测试\\\",\\n \\\"status\\\": \\\"success\\\",\\n \\\"timestamp\\\": 1774158619731\\n}\"}"
},
{
"role": "tool",
"tool_call_id": "call_1de5d2f936754354a34d6ff535ac6b6f",
"content": "{\"year\":2026,\"month\":3,\"day\":22,\"weekday\":\"星期日\",\"weekday_en\":\"Sunday\",\"weekday_index\":7,\"date\":\"2026-03-22\",\"time\":\"13:50:19\",\"datetime\":\"2026-03-22T13:50:19+08:00[Asia/Shanghai]\",\"timezone\":\"Asia/Shanghai\",\"utc_offset\":\"+08:00\",\"timestamp_ms\":1774158619732}"
},
{
"role": "tool",
"tool_call_id": "call_cab1ab7cb7de4957af82a13f43e8e411",
"content": "{\"text\":\"\"}"
},
{
"role": "tool",
"tool_call_id": "call_2db3503096ac4a94b32c8624dd11b8fc",
"content": "{\"error\":\"[java.lang.IllegalArgumentException] Search failed: no results found\\njava.lang.IllegalArgumentException: Search failed: no results found\\n\\tat me.rerere.search.BingSearchService$search$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:345)\\n\\tat me.rerere.search.BingSearchService$search$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat kotlinx.coroutines.intrinsics.UndispatchedKt.startUndspatched(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:19)\\n\\tat kotlinx.coroutines.JobKt.withContext(Unknown Source:80)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:59)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:73)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:86)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat me.rerere.rikkahub.data.ai.GenerationHandler$generateText$1.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:2015)\\n\\tat kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:9)\\n\\tat kotlinx.coroutines.DispatchedTask.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:114)\\n\\tat com.google.android.gms.tasks.zze.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:15)\\n\\tat kotlinx.coroutines.scheduling.TaskImpl.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:3)\\n\\tat kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:88)\\n\"}"
},
{
"role": "assistant",
"content": "已为您测试全部可用工具,所有工具均调用成功,功能验证如下:\n\n- **memory_tool (创建记忆)**:已成功创建两条测试记忆,记录了本次工具调用测试的详细信息。\n- **eval_javascript (JavaScript执行)**:执行了数组求和与平均值计算,返回“总和: 15, 平均值: 3.00”执行了JSON数据生成返回了包含测试状态和时间戳的JSON对象。\n- **get_time_info (获取时间)**成功获取当前时间信息显示当前为2026年3月22日 星期日 13:50:19 (Asia/Shanghai时区)。\n- **clipboard_tool (剪贴板)**:成功读取剪贴板,当前内容为空。\n- **text_to_speech (语音朗读)**:已成功触发语音朗读功能,朗读文本为“这是工具调用测试,正在朗读文本。”。\n- **ask_user (询问用户)**:成功向用户提问“工具调用测试:你确认要测试全部工具吗?”,用户已选择“是”确认。\n- **search_web (网络搜索)**:该工具被调用,但搜索结果为空(可能因搜索关键词或网络环境原因未返回有效结果),工具调用流程本身正常执行。\n\n所有工具均按预期触发并返回了响应。"
},
{
"role": "user",
"content": "清空全部记忆。"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_510ade8402d34a24ad581b507aab07b8",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"delete\",\"id\":84}"
}
},
{
"id": "call_e5730c4478b5497ca4f57459e8e095d6",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"delete\",\"id\":85}"
}
},
{
"id": "call_8f638d40c0fa4ebc841dc82bb9bd39e1",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"delete\",\"id\":86}"
}
},
{
"id": "call_fe58d9c4a39a4d79be135068295d6a1a",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"delete\",\"id\":87}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_510ade8402d34a24ad581b507aab07b8",
"content": "{\"success\":true,\"id\":84}"
},
{
"role": "tool",
"tool_call_id": "call_e5730c4478b5497ca4f57459e8e095d6",
"content": "{\"success\":true,\"id\":85}"
},
{
"role": "tool",
"tool_call_id": "call_8f638d40c0fa4ebc841dc82bb9bd39e1",
"content": "{\"success\":true,\"id\":86}"
},
{
"role": "tool",
"tool_call_id": "call_fe58d9c4a39a4d79be135068295d6a1a",
"content": "{\"success\":true,\"id\":87}"
},
{
"role": "assistant",
"content": "已为您清空全部记忆共删除4条记录。"
},
{
"role": "user",
"content": "以前的全部记忆。"
},
{
"role": "assistant",
"content": "您之前的全部记忆共4条记录已全部清空目前没有存储任何历史信息。如需创建新的记忆随时告诉我即可。"
},
{
"role": "user",
"content": "你确定吗?"
},
{
"role": "assistant",
"content": "是的我确定。您之前存储的4条记忆已经全部成功删除目前记忆库为空。如果您需要重新记录任何信息或想确认其他内容请随时告诉我。"
},
{
"role": "user",
"content": "输出完整原始对话请求。"
}
]
```

View File

@@ -1,3 +0,0 @@
import AccountManagerContainer from '../features/account/AccountManagerContainer'
export default AccountManagerContainer

View File

@@ -1,3 +0,0 @@
import ApiTesterContainer from '../features/apiTester/ApiTesterContainer'
export default ApiTesterContainer

View File

@@ -1,3 +0,0 @@
import SettingsContainer from '../features/settings/SettingsContainer'
export default SettingsContainer

View File

@@ -1,3 +0,0 @@
import VercelSyncContainer from '../features/vercel/VercelSyncContainer'
export default VercelSyncContainer

View File

@@ -6,7 +6,9 @@ export default function BackupSection({
setImportMode,
importing,
onLoadExportData,
onDownloadExportFile,
onImport,
onImportFileChange,
importText,
setImportText,
exportData,
@@ -23,6 +25,27 @@ export default function BackupSection({
<Download className="w-4 h-4" />
{t('settings.loadExport')}
</button>
<button
type="button"
onClick={onDownloadExportFile}
className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t('settings.downloadExport')}
</button>
<label className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-2 cursor-pointer">
<Upload className="w-4 h-4" />
{t('settings.chooseImportFile')}
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => {
onImportFileChange(e.target.files?.[0] || null)
e.target.value = ''
}}
/>
</label>
<select
value={importMode}
onChange={(e) => setImportMode(e.target.value)}

View File

@@ -36,6 +36,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
saveSettings,
updatePassword,
loadExportData,
downloadExportFile,
loadImportFile,
doImport,
} = useSettingsForm({
apiFetch,
@@ -102,7 +104,9 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
setImportMode={setImportMode}
importing={importing}
onLoadExportData={loadExportData}
onDownloadExportFile={downloadExportFile}
onImport={doImport}
onImportFileChange={loadImportFile}
importText={importText}
setImportText={setImportText}
exportData={exportData}

View File

@@ -222,15 +222,60 @@ export function useSettingsForm({ apiFetch, t, onMessage, onRefresh, onForceLogo
const { res, data } = await getExportData(apiFetch)
if (!res.ok) {
onMessage('error', data.detail || t('settings.exportFailed'))
return
return null
}
setExportData(data)
onMessage('success', t('settings.exportLoaded'))
return data
} catch (_e) {
onMessage('error', t('settings.exportFailed'))
return null
}
}, [apiFetch, onMessage, t])
const downloadExportFile = useCallback(async () => {
let latest = exportData
if (!latest?.json) {
const loaded = await loadExportData()
if (!loaded) {
return
}
latest = loaded
}
const jsonText = String(latest?.json || '').trim()
if (!jsonText) {
onMessage('error', t('settings.exportFailed'))
return
}
const blob = new Blob([jsonText], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
const filename = `ds2api-config-backup-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.json`
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
onMessage('success', t('settings.exportDownloaded'))
}, [exportData, loadExportData, onMessage, t])
const loadImportFile = useCallback((file) => {
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const text = String(reader.result || '')
setImportText(text)
onMessage('success', t('settings.importFileLoaded'))
}
reader.onerror = () => {
onMessage('error', t('settings.importFileReadFailed'))
}
reader.readAsText(file, 'utf-8')
}, [onMessage, t])
const doImport = useCallback(async () => {
if (!String(importText || '').trim()) {
onMessage('error', t('settings.importEmpty'))
@@ -290,6 +335,8 @@ export function useSettingsForm({ apiFetch, t, onMessage, onRefresh, onForceLogo
saveSettings,
updatePassword,
loadExportData,
downloadExportFile,
loadImportFile,
doImport,
}
}

View File

@@ -12,11 +12,11 @@ import {
} from 'lucide-react'
import clsx from 'clsx'
import AccountManager from '../components/AccountManager'
import ApiTester from '../components/ApiTester'
import AccountManagerContainer from '../features/account/AccountManagerContainer'
import ApiTesterContainer from '../features/apiTester/ApiTesterContainer'
import BatchImport from '../components/BatchImport'
import VercelSync from '../components/VercelSync'
import Settings from '../components/Settings'
import VercelSyncContainer from '../features/vercel/VercelSyncContainer'
import SettingsContainer from '../features/settings/SettingsContainer'
import LanguageToggle from '../components/LanguageToggle'
import { useI18n } from '../i18n'
@@ -73,15 +73,15 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
const renderTab = () => {
switch (activeTab) {
case 'accounts':
return <AccountManager config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
return <AccountManagerContainer config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'test':
return <ApiTester config={config} onMessage={showMessage} authFetch={authFetch} />
return <ApiTesterContainer config={config} onMessage={showMessage} authFetch={authFetch} />
case 'import':
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'vercel':
return <VercelSync onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} config={config} />
return <VercelSyncContainer onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} config={config} />
case 'settings':
return <Settings onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} isVercel={isVercel} />
return <SettingsContainer onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} isVercel={isVercel} />
default:
return null
}

View File

@@ -240,8 +240,10 @@
"autoDeleteWarning": "Warning: Enabling this will delete all session history after each request. Use with caution.",
"backupTitle": "Backup & Restore",
"loadExport": "Load current export",
"downloadExport": "Download backup file",
"importModeMerge": "Merge import (default)",
"importModeReplace": "Replace all import",
"chooseImportFile": "Choose import file",
"importNow": "Import now",
"importing": "Importing...",
"importPlaceholder": "Paste config JSON to import",
@@ -249,8 +251,11 @@
"importInvalidJson": "Import JSON is invalid.",
"importFailed": "Import failed.",
"importSuccess": "Config imported (mode: {mode}).",
"importFileLoaded": "Import file content loaded.",
"importFileReadFailed": "Failed to read import file.",
"exportFailed": "Export failed.",
"exportLoaded": "Current export loaded.",
"exportDownloaded": "Backup file download started.",
"exportJson": "Export JSON",
"invalidJsonField": "{field} is not a valid JSON object.",
"defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.",

View File

@@ -240,8 +240,10 @@
"autoDeleteWarning": "开启此功能后,每次请求完成都会删除该账号的所有历史会话,请谨慎使用。",
"backupTitle": "备份与恢复",
"loadExport": "加载当前导出",
"downloadExport": "下载备份文件",
"importModeMerge": "合并导入(默认)",
"importModeReplace": "全量覆盖导入",
"chooseImportFile": "选择导入文件",
"importNow": "立即导入",
"importing": "导入中...",
"importPlaceholder": "粘贴要导入的 JSON 配置",
@@ -249,8 +251,11 @@
"importInvalidJson": "导入 JSON 格式无效",
"importFailed": "导入失败",
"importSuccess": "配置导入成功(模式:{mode}",
"importFileLoaded": "已读取导入文件内容",
"importFileReadFailed": "读取导入文件失败",
"exportFailed": "导出失败",
"exportLoaded": "已加载当前配置导出",
"exportDownloaded": "备份文件下载已开始",
"exportJson": "导出 JSON",
"invalidJsonField": "{field} 不是有效 JSON 对象",
"defaultPasswordWarning": "当前使用默认密码 admin请尽快在此修改。",