From a550de30af77d54d36b77bed7a30ab3232fea318 Mon Sep 17 00:00:00 2001 From: shern-point Date: Wed, 29 Apr 2026 01:59:05 +0800 Subject: [PATCH 1/5] fix: expand shared tool schema extraction --- internal/promptcompat/tool_prompt.go | 8 +-- .../toolcall/toolcalls_schema_normalize.go | 40 ++++++++++----- .../toolcalls_schema_normalize_test.go | 49 +++++++++++++++++++ 3 files changed, 78 insertions(+), 19 deletions(-) diff --git a/internal/promptcompat/tool_prompt.go b/internal/promptcompat/tool_prompt.go index ba5f2cf..95d2f8b 100644 --- a/internal/promptcompat/tool_prompt.go +++ b/internal/promptcompat/tool_prompt.go @@ -30,13 +30,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy ToolChoiceP if !ok { continue } - fn, _ := tool["function"].(map[string]any) - if len(fn) == 0 { - fn = tool - } - name, _ := fn["name"].(string) - desc, _ := fn["description"].(string) - schema, _ := fn["parameters"].(map[string]any) + name, desc, schema := toolcall.ExtractToolMeta(tool) name = strings.TrimSpace(name) if !isAllowed(name) { continue diff --git a/internal/toolcall/toolcalls_schema_normalize.go b/internal/toolcall/toolcalls_schema_normalize.go index 44c772a..65a27c2 100644 --- a/internal/toolcall/toolcalls_schema_normalize.go +++ b/internal/toolcall/toolcalls_schema_normalize.go @@ -48,7 +48,7 @@ func buildToolSchemaIndex(toolsRaw any) map[string]any { if !ok { continue } - name, schema := extractToolNameAndSchema(tool) + name, _, schema := ExtractToolMeta(tool) if name == "" || schema == nil { continue } @@ -60,24 +60,31 @@ func buildToolSchemaIndex(toolsRaw any) map[string]any { return out } -func extractToolNameAndSchema(tool map[string]any) (string, any) { +func ExtractToolMeta(tool map[string]any) (string, string, any) { name := strings.TrimSpace(asStringValue(tool["name"])) - schema := tool["parameters"] - if schema == nil { - schema = tool["input_schema"] - } + desc := strings.TrimSpace(asStringValue(tool["description"])) + schema := firstNonNil( + tool["parameters"], + tool["input_schema"], + tool["inputSchema"], + tool["schema"], + ) if fn, ok := tool["function"].(map[string]any); ok { if name == "" { name = strings.TrimSpace(asStringValue(fn["name"])) } - if schema == nil { - schema = fn["parameters"] - } - if schema == nil { - schema = fn["input_schema"] + if desc == "" { + desc = strings.TrimSpace(asStringValue(fn["description"])) } + schema = firstNonNil( + schema, + fn["parameters"], + fn["input_schema"], + fn["inputSchema"], + fn["schema"], + ) } - return name, schema + return name, desc, schema } func normalizeToolValueWithSchema(value any, schema any) (any, bool) { @@ -264,3 +271,12 @@ func asStringValue(v any) string { } return "" } + +func firstNonNil(values ...any) any { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} diff --git a/internal/toolcall/toolcalls_schema_normalize_test.go b/internal/toolcall/toolcalls_schema_normalize_test.go index 7807c3f..7dac106 100644 --- a/internal/toolcall/toolcalls_schema_normalize_test.go +++ b/internal/toolcall/toolcalls_schema_normalize_test.go @@ -110,3 +110,52 @@ func TestNormalizeParsedToolCallsForSchemasLeavesAmbiguousUnionUnchanged(t *test t.Fatalf("expected ambiguous union to stay unchanged, got %#v", got[0].Input["taskId"]) } } + +func TestNormalizeParsedToolCallsForSchemasSupportsCamelCaseInputSchema(t *testing.T) { + toolsRaw := []any{ + map[string]any{ + "name": "Write", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{"type": "string"}, + }, + }, + }, + } + calls := []ParsedToolCall{{Name: "Write", Input: map[string]any{"content": map[string]any{"message": "hi"}}}} + got := NormalizeParsedToolCallsForSchemas(calls, toolsRaw) + if got[0].Input["content"] != `{"message":"hi"}` { + t.Fatalf("expected camelCase inputSchema content coercion, got %#v", got[0].Input["content"]) + } +} + +func TestNormalizeParsedToolCallsForSchemasPreservesArrayWhenSchemaSaysArray(t *testing.T) { + toolsRaw := []any{ + map[string]any{ + "name": "todowrite", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "todos": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "priority": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + } + todos := []any{map[string]any{"content": "x", "status": "pending", "priority": "high"}} + calls := []ParsedToolCall{{Name: "todowrite", Input: map[string]any{"todos": todos}}} + got := NormalizeParsedToolCallsForSchemas(calls, toolsRaw) + if !reflect.DeepEqual(got[0].Input["todos"], todos) { + t.Fatalf("expected todos array preserved, got %#v want %#v", got[0].Input["todos"], todos) + } +} From 48c4f0df9f803486bc2c74a34e9ca52d058b6c52 Mon Sep 17 00:00:00 2001 From: shern-point Date: Wed, 29 Apr 2026 01:59:24 +0800 Subject: [PATCH 2/5] fix: preserve runtime tool schemas in Claude tool output --- .../httpapi/claude/handler_helpers_misc.go | 28 +++---------------- internal/httpapi/claude/handler_messages.go | 3 +- internal/httpapi/claude/standard_request.go | 1 + .../httpapi/claude/stream_runtime_core.go | 3 ++ .../httpapi/claude/stream_runtime_finalize.go | 1 + 5 files changed, 11 insertions(+), 25 deletions(-) diff --git a/internal/httpapi/claude/handler_helpers_misc.go b/internal/httpapi/claude/handler_helpers_misc.go index 7b89734..6062dc6 100644 --- a/internal/httpapi/claude/handler_helpers_misc.go +++ b/internal/httpapi/claude/handler_helpers_misc.go @@ -1,6 +1,7 @@ package claude import ( + "ds2api/internal/toolcall" "fmt" "strings" ) @@ -31,30 +32,9 @@ func extractClaudeToolNames(tools []any) []string { } func extractClaudeToolMeta(m map[string]any) (string, string, any) { - name, _ := m["name"].(string) - desc, _ := m["description"].(string) - schemaObj := m["input_schema"] - if schemaObj == nil { - schemaObj = m["parameters"] - } - - if fn, ok := m["function"].(map[string]any); ok { - if strings.TrimSpace(name) == "" { - name, _ = fn["name"].(string) - } - if strings.TrimSpace(desc) == "" { - desc, _ = fn["description"].(string) - } - if schemaObj == nil { - if v, ok := fn["input_schema"]; ok { - schemaObj = v - } - } - if schemaObj == nil { - if v, ok := fn["parameters"]; ok { - schemaObj = v - } - } + name, desc, schemaObj := toolcall.ExtractToolMeta(m) + if strings.TrimSpace(desc) == "" { + desc = "No description available" } return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj } diff --git a/internal/httpapi/claude/handler_messages.go b/internal/httpapi/claude/handler_messages.go index ad8f54e..de47d28 100644 --- a/internal/httpapi/claude/handler_messages.go +++ b/internal/httpapi/claude/handler_messages.go @@ -177,7 +177,7 @@ func stripClaudeThinkingBlocks(raw []byte) []byte { return out } -func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Request, resp *http.Response, model string, messages []any, thinkingEnabled, searchEnabled bool, toolNames []string) { +func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Request, resp *http.Response, model string, messages []any, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any) { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) @@ -205,6 +205,7 @@ func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Requ searchEnabled, h.compatStripReferenceMarkers(), toolNames, + toolsRaw, ) streamRuntime.sendMessageStart() diff --git a/internal/httpapi/claude/standard_request.go b/internal/httpapi/claude/standard_request.go index 3f3e238..3f10723 100644 --- a/internal/httpapi/claude/standard_request.go +++ b/internal/httpapi/claude/standard_request.go @@ -53,6 +53,7 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma ResolvedModel: dsModel, ResponseModel: strings.TrimSpace(model), Messages: payload["messages"].([]any), + ToolsRaw: toolsRequested, FinalPrompt: finalPrompt, ToolNames: toolNames, Stream: util.ToBool(req["stream"]), diff --git a/internal/httpapi/claude/stream_runtime_core.go b/internal/httpapi/claude/stream_runtime_core.go index beb2d40..49fde53 100644 --- a/internal/httpapi/claude/stream_runtime_core.go +++ b/internal/httpapi/claude/stream_runtime_core.go @@ -18,6 +18,7 @@ type claudeStreamRuntime struct { model string toolNames []string messages []any + toolsRaw any thinkingEnabled bool searchEnabled bool @@ -47,6 +48,7 @@ func newClaudeStreamRuntime( searchEnabled bool, stripReferenceMarkers bool, toolNames []string, + toolsRaw any, ) *claudeStreamRuntime { return &claudeStreamRuntime{ w: w, @@ -59,6 +61,7 @@ func newClaudeStreamRuntime( bufferToolContent: len(toolNames) > 0, stripReferenceMarkers: stripReferenceMarkers, toolNames: toolNames, + toolsRaw: toolsRaw, messageID: fmt.Sprintf("msg_%d", time.Now().UnixNano()), thinkingBlockIndex: -1, textBlockIndex: -1, diff --git a/internal/httpapi/claude/stream_runtime_finalize.go b/internal/httpapi/claude/stream_runtime_finalize.go index 241ff7a..32e9b5f 100644 --- a/internal/httpapi/claude/stream_runtime_finalize.go +++ b/internal/httpapi/claude/stream_runtime_finalize.go @@ -52,6 +52,7 @@ func (s *claudeStreamRuntime) finalize(stopReason string) { detected = toolcall.ParseStandaloneToolCalls(finalThinking, s.toolNames) } if len(detected) > 0 { + detected = toolcall.NormalizeParsedToolCallsForSchemas(detected, s.toolsRaw) stopReason = "tool_use" for i, tc := range detected { idx := s.nextBlockIndex + i From 6e21714e23b25a1338df1ba406a8396c624257a4 Mon Sep 17 00:00:00 2001 From: shern-point Date: Wed, 29 Apr 2026 01:59:42 +0800 Subject: [PATCH 3/5] test: cover Claude schema-aware tool normalization --- .../httpapi/claude/handler_stream_test.go | 61 ++++++++++++++++--- .../httpapi/claude/standard_request_test.go | 28 +++++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/internal/httpapi/claude/handler_stream_test.go b/internal/httpapi/claude/handler_stream_test.go index 354ed89..16b5dde 100644 --- a/internal/httpapi/claude/handler_stream_test.go +++ b/internal/httpapi/claude/handler_stream_test.go @@ -81,7 +81,7 @@ func TestHandleClaudeStreamRealtimeTextIncrementsWithEventHeaders(t *testing.T) 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": "hi"}}, false, false, nil) + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil, nil) body := rec.Body.String() if !strings.Contains(body, "event: message_start") { @@ -122,7 +122,7 @@ func TestHandleClaudeStreamRealtimeThinkingDelta(t *testing.T) { 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": "hi"}}, true, false, nil) + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, true, false, nil, nil) frames := parseClaudeFrames(t, rec.Body.String()) foundThinkingDelta := false @@ -149,7 +149,7 @@ func TestHandleClaudeStreamRealtimeSkipsThinkingFallbackWhenFinalTextExists(t *t 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"}}, true, false, []string{"search"}) + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"}, nil) frames := parseClaudeFrames(t, rec.Body.String()) for _, f := range findClaudeFrames(frames, "content_block_start") { @@ -180,7 +180,7 @@ func TestHandleClaudeStreamRealtimeUpstreamErrorEvent(t *testing.T) { 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": "hi"}}, false, false, nil) + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil, nil) frames := parseClaudeFrames(t, rec.Body.String()) errFrames := findClaudeFrames(frames, "error") @@ -217,7 +217,7 @@ func TestHandleClaudeStreamRealtimePingEvent(t *testing.T) { 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": "hi"}}, false, false, nil) + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil, nil) frames := parseClaudeFrames(t, rec.Body.String()) if len(findClaudeFrames(frames, "ping")) == 0 { @@ -271,7 +271,7 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing. 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{"Bash"}) + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"Bash"}, nil) frames := parseClaudeFrames(t, rec.Body.String()) foundToolUse := false @@ -299,7 +299,7 @@ func TestHandleClaudeStreamRealtimeDetectsToolUseWithLeadingProse(t *testing.T) 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"}) + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"write_file"}, nil) frames := parseClaudeFrames(t, rec.Body.String()) foundToolUse := false @@ -333,7 +333,7 @@ func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T 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": "show example only"}}, false, false, []string{"Bash"}) + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"}, nil) frames := parseClaudeFrames(t, rec.Body.String()) foundToolUse := false @@ -365,3 +365,48 @@ func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.T) { TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t) } + +func TestHandleClaudeStreamRealtimeNormalizesToolInputBySchema(t *testing.T) { + h := &Handler{} + resp := makeClaudeSSEHTTPResponse( + `data: {"p":"response/content","v":"{\"input\":{\"content\":{\"message\":\"hi\"},\"taskId\":1}}"}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil) + toolsRaw := []any{ + map[string]any{ + "name": "Write", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{"type": "string"}, + "taskId": map[string]any{"type": "string"}, + }, + }, + }, + } + + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "write"}}, false, false, []string{"Write"}, toolsRaw) + + frames := parseClaudeFrames(t, rec.Body.String()) + for _, f := range findClaudeFrames(frames, "content_block_delta") { + delta, _ := f.Payload["delta"].(map[string]any) + if delta["type"] != "input_json_delta" { + continue + } + partial := asString(delta["partial_json"]) + var args map[string]any + if err := json.Unmarshal([]byte(partial), &args); err != nil { + t.Fatalf("decode partial_json failed: %v payload=%s", err, partial) + } + if args["content"] != `{"message":"hi"}` { + t.Fatalf("expected content normalized to string, got %#v", args["content"]) + } + if args["taskId"] != "1" { + t.Fatalf("expected taskId normalized to string, got %#v", args["taskId"]) + } + return + } + t.Fatalf("expected input_json_delta frame, body=%s", rec.Body.String()) +} diff --git a/internal/httpapi/claude/standard_request_test.go b/internal/httpapi/claude/standard_request_test.go index 6110124..244b2ac 100644 --- a/internal/httpapi/claude/standard_request_test.go +++ b/internal/httpapi/claude/standard_request_test.go @@ -32,11 +32,39 @@ func TestNormalizeClaudeRequest(t *testing.T) { if len(norm.Standard.ToolNames) == 0 { t.Fatalf("expected tool names") } + if norm.Standard.ToolsRaw == nil { + t.Fatalf("expected ToolsRaw preserved for downstream normalization") + } if norm.Standard.FinalPrompt == "" { t.Fatalf("expected non-empty final prompt") } } +func TestNormalizeClaudeRequestSupportsCamelCaseInputSchemaPromptInjection(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{}`) + store := config.LoadStore() + req := map[string]any{ + "model": "claude-sonnet-4-5", + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + "tools": []any{ + map[string]any{ + "name": "todowrite", + "description": "Write todos", + "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"todos": map[string]any{"type": "array"}}}, + }, + }, + } + norm, err := normalizeClaudeRequest(store, req) + if err != nil { + t.Fatalf("normalize failed: %v", err) + } + if !containsStr(norm.Standard.FinalPrompt, `"type":"array"`) { + t.Fatalf("expected inputSchema to be injected into prompt, got=%q", norm.Standard.FinalPrompt) + } +} + func TestNormalizeClaudeRequestInjectsToolsIntoExistingSystemMessage(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{}`) store := config.LoadStore() From f1926a6ced4bb5dbbaed9c94b1ac74a8dfb1c6bc Mon Sep 17 00:00:00 2001 From: shern-point Date: Wed, 29 Apr 2026 02:00:01 +0800 Subject: [PATCH 4/5] fix: normalize Vercel stream tool arguments by schema --- internal/js/chat-stream/vercel_stream_impl.js | 8 +- .../js/helpers/stream-tool-sieve/format.js | 193 +++++++++++++++++- tests/node/stream-tool-sieve.test.js | 24 +++ 3 files changed, 219 insertions(+), 6 deletions(-) diff --git a/internal/js/chat-stream/vercel_stream_impl.js b/internal/js/chat-stream/vercel_stream_impl.js index dfd6aad..8a68ab1 100644 --- a/internal/js/chat-stream/vercel_stream_impl.js +++ b/internal/js/chat-stream/vercel_stream_impl.js @@ -205,14 +205,14 @@ async function handleVercelStream(req, res, rawBody, payload) { if (detected.length > 0 && !toolCallsDoneEmitted) { toolCallsEmitted = true; toolCallsDoneEmitted = true; - sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected, streamToolCallIDs) }); + sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected, streamToolCallIDs, payload.tools) }); } else if (toolSieveEnabled) { const tailEvents = flushToolSieve(toolSieveState, toolNames); for (const evt of tailEvents) { if (evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0) { toolCallsEmitted = true; toolCallsDoneEmitted = true; - sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) }); + sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs, payload.tools) }); resetStreamToolCallState(streamToolCallIDs, streamToolNames); continue; } @@ -352,14 +352,14 @@ async function handleVercelStream(req, res, rawBody, payload) { const formatted = formatIncrementalToolCallDeltas(filtered, streamToolCallIDs); if (formatted.length > 0) { toolCallsEmitted = true; - sendDeltaFrame({ tool_calls: formatted }); + sendDeltaFrame({ tool_calls: formatted }); } continue; } if (evt.type === 'tool_calls') { toolCallsEmitted = true; toolCallsDoneEmitted = true; - sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) }); + sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs, payload.tools) }); resetStreamToolCallState(streamToolCallIDs, streamToolNames); continue; } diff --git a/internal/js/helpers/stream-tool-sieve/format.js b/internal/js/helpers/stream-tool-sieve/format.js index 74da078..88d7271 100644 --- a/internal/js/helpers/stream-tool-sieve/format.js +++ b/internal/js/helpers/stream-tool-sieve/format.js @@ -2,11 +2,12 @@ const crypto = require('crypto'); -function formatOpenAIStreamToolCalls(calls, idStore) { +function formatOpenAIStreamToolCalls(calls, idStore, toolsRaw) { if (!Array.isArray(calls) || calls.length === 0) { return []; } - return calls.map((c, idx) => ({ + const normalized = normalizeParsedToolCallsForSchemas(calls, toolsRaw); + return normalized.map((c, idx) => ({ index: idx, id: ensureStreamToolCallID(idStore, idx), type: 'function', @@ -17,6 +18,194 @@ function formatOpenAIStreamToolCalls(calls, idStore) { })); } +function normalizeParsedToolCallsForSchemas(calls, toolsRaw) { + if (!Array.isArray(calls) || calls.length === 0) { + return calls; + } + const schemas = buildToolSchemaIndex(toolsRaw); + if (!schemas) { + return calls; + } + let changedAny = false; + const out = calls.map((call) => { + const name = String(call && call.name || '').trim().toLowerCase(); + const schema = schemas[name]; + if (!schema || !call || !call.input || typeof call.input !== 'object' || Array.isArray(call.input)) { + return call; + } + const [normalized, changed] = normalizeToolValueWithSchema(call.input, schema); + if (!changed || !normalized || typeof normalized !== 'object' || Array.isArray(normalized)) { + return call; + } + changedAny = true; + return { ...call, input: normalized }; + }); + return changedAny ? out : calls; +} + +function buildToolSchemaIndex(toolsRaw) { + if (!Array.isArray(toolsRaw) || toolsRaw.length === 0) { + return null; + } + const out = {}; + for (const item of toolsRaw) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + continue; + } + const [name, schema] = extractToolNameAndSchema(item); + if (!name || !schema || typeof schema !== 'object' || Array.isArray(schema)) { + continue; + } + out[name.toLowerCase()] = schema; + } + return Object.keys(out).length > 0 ? out : null; +} + +function extractToolNameAndSchema(tool) { + const fn = tool && typeof tool.function === 'object' && !Array.isArray(tool.function) ? tool.function : null; + const name = firstNonEmptyString(tool.name, fn && fn.name); + const schema = firstNonNil( + tool.parameters, + tool.input_schema, + tool.inputSchema, + tool.schema, + fn && fn.parameters, + fn && fn.input_schema, + fn && fn.inputSchema, + fn && fn.schema, + ); + return [name, schema]; +} + +function normalizeToolValueWithSchema(value, schema) { + if (value == null || !schema || typeof schema !== 'object' || Array.isArray(schema)) { + return [value, false]; + } + if (shouldCoerceSchemaToString(schema)) { + return stringifySchemaValue(value); + } + if (looksLikeObjectSchema(schema)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return [value, false]; + } + const properties = schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties) ? schema.properties : null; + const additional = schema.additionalProperties; + let changed = false; + const out = {}; + for (const [key, current] of Object.entries(value)) { + let next = current; + let fieldChanged = false; + if (properties && Object.prototype.hasOwnProperty.call(properties, key)) { + [next, fieldChanged] = normalizeToolValueWithSchema(current, properties[key]); + } else if (additional != null) { + [next, fieldChanged] = normalizeToolValueWithSchema(current, additional); + } + out[key] = next; + changed = changed || fieldChanged; + } + return changed ? [out, true] : [value, false]; + } + if (looksLikeArraySchema(schema)) { + if (!Array.isArray(value) || value.length === 0 || schema.items == null) { + return [value, false]; + } + let changed = false; + const out = value.map((item, idx) => { + const itemSchema = Array.isArray(schema.items) ? schema.items[idx] : schema.items; + if (itemSchema == null) { + return item; + } + const [next, itemChanged] = normalizeToolValueWithSchema(item, itemSchema); + changed = changed || itemChanged; + return next; + }); + return changed ? [out, true] : [value, false]; + } + return [value, false]; +} + +function shouldCoerceSchemaToString(schema) { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { + return false; + } + if (typeof schema.const === 'string') { + return true; + } + if (Array.isArray(schema.enum) && schema.enum.length > 0 && schema.enum.every((item) => typeof item === 'string')) { + return true; + } + if (typeof schema.type === 'string') { + return schema.type.trim().toLowerCase() === 'string'; + } + if (Array.isArray(schema.type) && schema.type.length > 0) { + let hasString = false; + for (const item of schema.type) { + if (typeof item !== 'string') { + return false; + } + const typ = item.trim().toLowerCase(); + if (typ === 'string') { + hasString = true; + } else if (typ !== 'null') { + return false; + } + } + return hasString; + } + return false; +} + +function looksLikeObjectSchema(schema) { + return !!schema && typeof schema === 'object' && !Array.isArray(schema) && ( + (typeof schema.type === 'string' && schema.type.trim().toLowerCase() === 'object') || + (schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties)) || + schema.additionalProperties != null + ); +} + +function looksLikeArraySchema(schema) { + return !!schema && typeof schema === 'object' && !Array.isArray(schema) && ( + (typeof schema.type === 'string' && schema.type.trim().toLowerCase() === 'array') || + schema.items != null + ); +} + +function stringifySchemaValue(value) { + if (value == null) { + return [value, false]; + } + if (typeof value === 'string') { + return [value, false]; + } + try { + return [JSON.stringify(value), true]; + } catch { + return [value, false]; + } +} + +function firstNonNil(...values) { + for (const value of values) { + if (value != null) { + return value; + } + } + return null; +} + +function firstNonEmptyString(...values) { + for (const value of values) { + if (typeof value !== 'string') { + continue; + } + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + } + return ''; +} + function ensureStreamToolCallID(idStore, index) { if (!(idStore instanceof Map)) { return `call_${newCallID()}`; diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index d26b8ca..f8265f7 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -188,6 +188,30 @@ test('parseToolCalls treats single-item CDATA body as array', () => { assert.deepEqual(calls[0].input.todos, ['one']); }); +test('formatOpenAIStreamToolCalls normalizes camelCase inputSchema string fields', () => { + const formatted = formatOpenAIStreamToolCalls([ + { name: 'Write', input: { content: { message: 'hi' }, taskId: 1 } }, + ], new Map(), [ + { name: 'Write', inputSchema: { type: 'object', properties: { content: { type: 'string' }, taskId: { type: 'string' } } } }, + ]); + assert.equal(formatted.length, 1); + const args = JSON.parse(formatted[0].function.arguments); + assert.equal(args.content, '{"message":"hi"}'); + assert.equal(args.taskId, '1'); +}); + +test('formatOpenAIStreamToolCalls preserves arrays when schema says array', () => { + const todos = [{ content: 'x', status: 'pending', priority: 'high' }]; + const formatted = formatOpenAIStreamToolCalls([ + { name: 'todowrite', input: { todos } }, + ], new Map(), [ + { name: 'todowrite', inputSchema: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object' } } } } }, + ]); + assert.equal(formatted.length, 1); + const args = JSON.parse(formatted[0].function.arguments); + assert.deepEqual(args.todos, todos); +}); + test('parseToolCalls treats CDATA object fragment as object', () => { const fragment = ''; const payload = ``; From 52558838ef7caf468c2e18f1c90c923ce1928b1a Mon Sep 17 00:00:00 2001 From: shern-point Date: Wed, 29 Apr 2026 02:00:20 +0800 Subject: [PATCH 5/5] docs: document request-scoped tool schema authority --- docs/prompt-compatibility.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index 16cf38c..4b9403b 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -154,6 +154,7 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认 兼容层仍接受旧式纯 `` wrapper,但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现;DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。 数组参数使用 `...` 子节点表示;当某个参数体只包含 item 子节点时,Go / Node 解析器会把它还原成数组,避免 `questions` / `options` 这类 schema 中要求 array 的参数被误解析成 `{ "item": ... }` 对象。若模型把完整结构化 XML fragment 误包进 CDATA,兼容层会在保护 `content` / `command` 等原文字段的前提下,尝试把非原文字段中的 CDATA XML fragment 还原成 object / array。不过,如果 CDATA 只是单个平面的 XML/HTML 标签,例如 `urgent` 这种行内标记,兼容层会保留原始字符串,不会强行升成 object / array;只有明显表示结构的 CDATA 片段,例如多兄弟节点、嵌套子节点或 `item` 列表,才会触发结构化恢复。 在 assistant 最终回包阶段,如果某个 tool 参数在声明 schema 中明确是 `string`,兼容层会在把解析后的 `tool_calls` / `function_call` 重新序列化成 OpenAI / Responses / Claude 可见参数前,递归把该路径上的 number / bool / object / array 统一转成字符串;其中 object / array 会压成紧凑 JSON 字符串。这个保护只对 schema 明确声明为 string 的路径生效,不会改写本来就是 `number` / `boolean` / `object` / `array` 的参数。这样可以兼容 DeepSeek 输出了结构化片段、但上游客户端工具 schema 又严格要求字符串参数的场景(例如 `content`、`prompt`、`path`、`taskId` 等)。 +工具 schema 的权威来源始终是**当前请求实际携带的 schema**,而不是同名工具在其他 runtime(Claude Code / OpenCode / Codex 等)里的默认印象。兼容层现在会同时兼容 OpenAI 风格 `function.parameters`、直接工具对象上的 `parameters` / `input_schema`、以及 camelCase 的 `inputSchema` / `schema`,并在最终输出阶段按这份请求内 schema 决定是保留 array/object,还是仅对明确声明为 `string` 的路径做字符串化。该规则同样适用于 Claude 的流式收尾和 Vercel Node 流式 tool-call formatter,避免不同 runtime 因 schema shape 差异而出现同名工具参数类型漂移。 正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。 对执行类工具,脚本内容必须进入执行参数本身:`Bash` / `execute_command` 使用 `command`,`exec_command` 使用 `cmd`;不要把脚本示范成 `path` / `content` 文件写入参数。