Skip to content

Commit 4bd3c03

Browse files
committed
fix(proxy): strip empty optional fields from streamed tool inputs
gpt-5.5 occasionally emits unused optional fields (e.g. "pages":"" on the Read tool) when generating function calls. The proxy was forwarding each input_json_delta as-is, so the polluted input reached claudecode and the model in subsequent turns kept rationalizing "the tool layer brought in empty pages" — issue #95. Buffer function_call_arguments deltas until output_item.done, drop top-level keys whose value is "" or null, then emit the cleaned JSON as a single input_json_delta before content_block_stop. Also apply the same cleanup when reconstructing tool_use blocks from non-streaming response.completed payloads and when re-encoding historical assistant tool_use blocks back into upstream function_call items. Empty objects/arrays and whitespace strings are preserved (some tools treat those as meaningful). Trade-off: claudecode no longer sees tool arguments stream char-by-char, but tool JSON is small enough that the UX delta is imperceptible.
1 parent d9de404 commit 4bd3c03

2 files changed

Lines changed: 234 additions & 28 deletions

File tree

proxy/anthropic.go

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,9 @@ func appendAssistantBlocks(input []any, blocks []anthropicContentBlock) []any {
410410
}
411411
args := "{}"
412412
if len(b.Input) > 0 {
413-
args = string(b.Input)
413+
if cleaned := sanitizeToolInputJSON(string(b.Input)); cleaned != "" {
414+
args = cleaned
415+
}
414416
}
415417
input = append(input, map[string]any{
416418
"type": "function_call",
@@ -530,18 +532,19 @@ func convertAnthropicToolChoice(raw json.RawMessage) any {
530532

531533
// anthropicStreamTranslator 有状态的流式响应翻译器(Codex → Anthropic)
532534
type anthropicStreamTranslator struct {
533-
model string
534-
responseID string
535-
messageStartSent bool
536-
contentBlockIndex int
537-
contentBlockOpen bool
538-
currentBlockType string // "text" | "thinking" | "tool_use"
539-
currentToolUseID string
540-
currentToolUseName string
541-
hasToolUse bool
542-
inputTokens int
543-
outputTokens int
544-
cachedTokens int
535+
model string
536+
responseID string
537+
messageStartSent bool
538+
contentBlockIndex int
539+
contentBlockOpen bool
540+
currentBlockType string // "text" | "thinking" | "tool_use"
541+
currentToolUseID string
542+
currentToolUseName string
543+
currentToolInputBuffer strings.Builder
544+
hasToolUse bool
545+
inputTokens int
546+
outputTokens int
547+
cachedTokens int
545548
}
546549

547550
// newAnthropicStreamTranslator 创建流式翻译器
@@ -749,22 +752,17 @@ func (t *anthropicStreamTranslator) handleThinkingDelta(data []byte) []anthropic
749752
return events
750753
}
751754

752-
// handleToolInputDelta 处理工具调用参数增量
755+
// handleToolInputDelta 缓冲工具调用参数增量。
756+
// 不直接转发为 input_json_delta:上游模型偶尔会塞入空可选字段(如 gpt-5.5
757+
// 给 Read 工具加 "pages":""),逐片透传后下游会看到污染后的入参。统一在
758+
// closeCurrentBlock 时整段清洗后一次性下发。
753759
func (t *anthropicStreamTranslator) handleToolInputDelta(data []byte) []anthropicStreamEvent {
754760
delta := gjson.GetBytes(data, "delta").String()
755761
if delta == "" {
756762
return nil
757763
}
758-
759-
idx := t.contentBlockIndex - 1
760-
return []anthropicStreamEvent{{
761-
Type: "content_block_delta",
762-
Index: &idx,
763-
Delta: &anthropicDelta{
764-
Type: "input_json_delta",
765-
PartialJSON: delta,
766-
},
767-
}}
764+
t.currentToolInputBuffer.WriteString(delta)
765+
return nil
768766
}
769767

770768
// handleContentDone 处理内容完成(文本/推理块)
@@ -844,17 +842,70 @@ func (t *anthropicStreamTranslator) handleFailed() []anthropicStreamEvent {
844842
return events
845843
}
846844

847-
// closeCurrentBlock 关闭当前打开的 content block
845+
// closeCurrentBlock 关闭当前打开的 content block。
846+
// 关闭 tool_use 块时会先把累积的 arguments JSON 整段清洗(删除空字符串/null
847+
// 的可选字段),再作为单次 input_json_delta 下发。
848848
func (t *anthropicStreamTranslator) closeCurrentBlock() []anthropicStreamEvent {
849849
if !t.contentBlockOpen {
850850
return nil
851851
}
852852
t.contentBlockOpen = false
853853
idx := t.contentBlockIndex - 1
854-
return []anthropicStreamEvent{{
854+
855+
var events []anthropicStreamEvent
856+
if t.currentBlockType == "tool_use" && t.currentToolInputBuffer.Len() > 0 {
857+
cleaned := sanitizeToolInputJSON(t.currentToolInputBuffer.String())
858+
if cleaned != "" {
859+
events = append(events, anthropicStreamEvent{
860+
Type: "content_block_delta",
861+
Index: &idx,
862+
Delta: &anthropicDelta{
863+
Type: "input_json_delta",
864+
PartialJSON: cleaned,
865+
},
866+
})
867+
}
868+
t.currentToolInputBuffer.Reset()
869+
}
870+
871+
events = append(events, anthropicStreamEvent{
855872
Type: "content_block_stop",
856873
Index: &idx,
857-
}}
874+
})
875+
return events
876+
}
877+
878+
// sanitizeToolInputJSON 清洗工具调用 arguments JSON:
879+
// 仅删除顶层值为空字符串("")或 null 的字段。
880+
// 不动空对象 {} / 空数组 [](部分工具语义上允许这两者)。
881+
// 上游 gpt-5.5 偶尔会给 Read 工具加 "pages":"",导致 claudecode 看到的入参
882+
// 带上无效空字段,模型在后续轮次里反复纠结"工具层带入了空 pages"。在代理
883+
// 层统一清掉,比让客户端去兼容更简单。
884+
func sanitizeToolInputJSON(raw string) string {
885+
raw = strings.TrimSpace(raw)
886+
if raw == "" {
887+
return raw
888+
}
889+
var obj map[string]json.RawMessage
890+
if err := json.Unmarshal([]byte(raw), &obj); err != nil {
891+
return raw
892+
}
893+
changed := false
894+
for k, v := range obj {
895+
s := strings.TrimSpace(string(v))
896+
if s == `""` || s == "null" {
897+
delete(obj, k)
898+
changed = true
899+
}
900+
}
901+
if !changed {
902+
return raw
903+
}
904+
out, err := json.Marshal(obj)
905+
if err != nil {
906+
return raw
907+
}
908+
return string(out)
858909
}
859910

860911
// finalize 在流结束时补齐缺失的事件
@@ -948,7 +999,9 @@ func buildAnthropicResponseFromCompleted(completedData []byte, model string) *an
948999
callID := fromCodexCallID(item.Get("call_id").String())
9491000
name := item.Get("name").String()
9501001
args := item.Get("arguments").String()
951-
if args == "" {
1002+
if cleaned := sanitizeToolInputJSON(args); cleaned != "" {
1003+
args = cleaned
1004+
} else {
9521005
args = "{}"
9531006
}
9541007
content = append(content, anthropicContentBlock{

proxy/anthropic_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,156 @@ func TestTranslateAnthropicToCodexDoesNotCanonicalizeDisabledModelAlias(t *testi
129129
t.Fatalf("translated model = %q, want gpt5-4", out.Model)
130130
}
131131
}
132+
133+
func TestSanitizeToolInputJSON(t *testing.T) {
134+
tests := []struct {
135+
name string
136+
in string
137+
want string
138+
}{
139+
{
140+
name: "drops empty string optional field",
141+
in: `{"file_path":"/etc/hosts","pages":""}`,
142+
want: `{"file_path":"/etc/hosts"}`,
143+
},
144+
{
145+
name: "drops null field",
146+
in: `{"file_path":"/etc/hosts","limit":null}`,
147+
want: `{"file_path":"/etc/hosts"}`,
148+
},
149+
{
150+
name: "drops multiple empties",
151+
in: `{"file_path":"/x","pages":"","limit":null,"offset":0}`,
152+
want: `{"file_path":"/x","offset":0}`,
153+
},
154+
{
155+
name: "preserves empty object",
156+
in: `{"options":{}}`,
157+
want: `{"options":{}}`,
158+
},
159+
{
160+
name: "preserves empty array",
161+
in: `{"items":[]}`,
162+
want: `{"items":[]}`,
163+
},
164+
{
165+
name: "preserves whitespace strings",
166+
in: `{"sep":" "}`,
167+
want: `{"sep":" "}`,
168+
},
169+
{
170+
name: "no-op when nothing to drop",
171+
in: `{"file_path":"/etc/hosts"}`,
172+
want: `{"file_path":"/etc/hosts"}`,
173+
},
174+
{
175+
name: "invalid JSON returned as-is",
176+
in: `{"file_path":`,
177+
want: `{"file_path":`,
178+
},
179+
{
180+
name: "empty input returned as-is",
181+
in: ``,
182+
want: ``,
183+
},
184+
}
185+
for _, tc := range tests {
186+
t.Run(tc.name, func(t *testing.T) {
187+
got := sanitizeToolInputJSON(tc.in)
188+
// Compare as JSON to ignore key ordering.
189+
if !jsonEqual(t, got, tc.want) {
190+
t.Fatalf("sanitizeToolInputJSON(%q) = %q, want equivalent to %q",
191+
tc.in, got, tc.want)
192+
}
193+
})
194+
}
195+
}
196+
197+
func jsonEqual(t *testing.T, a, b string) bool {
198+
t.Helper()
199+
if a == b {
200+
return true
201+
}
202+
var av, bv any
203+
if err := json.Unmarshal([]byte(a), &av); err != nil {
204+
return a == b
205+
}
206+
if err := json.Unmarshal([]byte(b), &bv); err != nil {
207+
return a == b
208+
}
209+
ab, _ := json.Marshal(av)
210+
bb, _ := json.Marshal(bv)
211+
return string(ab) == string(bb)
212+
}
213+
214+
// TestAnthropicStreamTranslator_ToolInputBufferedAndCleaned 模拟 gpt-5.5 把
215+
// "pages":"" 拆成多片 SSE 推送:translator 应缓冲到 tool_use 块关闭时再
216+
// 整段清洗,并以单次 input_json_delta 发出,下游收到的 JSON 不含空 pages。
217+
func TestAnthropicStreamTranslator_ToolInputBufferedAndCleaned(t *testing.T) {
218+
tr := newAnthropicStreamTranslator("claude-sonnet-4-5")
219+
220+
// response.created
221+
tr.translateEvent([]byte(`{"type":"response.created"}`))
222+
// output_item.added — 启动 tool_use 块
223+
tr.translateEvent([]byte(`{
224+
"type":"response.output_item.added",
225+
"output_index":0,
226+
"item":{"type":"function_call","call_id":"call_abc","name":"Read"}
227+
}`))
228+
229+
// 三片 function_call_arguments.delta,分别是开头/中段/结尾
230+
deltas := []string{
231+
`{"file_path":"/etc/hosts"`,
232+
`,"pages":""`,
233+
`}`,
234+
}
235+
var streamed []anthropicStreamEvent
236+
for _, d := range deltas {
237+
evt := []byte(`{"type":"response.function_call_arguments.delta","delta":` +
238+
mustJSONString(d) + `}`)
239+
streamed = append(streamed, tr.translateEvent(evt)...)
240+
}
241+
242+
// delta 阶段不应该泄漏任何 input_json_delta
243+
for _, evt := range streamed {
244+
if evt.Type == "content_block_delta" {
245+
t.Fatalf("expected no content_block_delta during streaming, got %+v", evt)
246+
}
247+
}
248+
249+
// output_item.done 触发 closeCurrentBlock,整段清洗
250+
closing := tr.translateEvent([]byte(`{"type":"response.output_item.done"}`))
251+
252+
var sawDelta bool
253+
var sawStop bool
254+
for _, evt := range closing {
255+
if evt.Type == "content_block_delta" {
256+
sawDelta = true
257+
if evt.Delta == nil || evt.Delta.Type != "input_json_delta" {
258+
t.Fatalf("expected input_json_delta, got %+v", evt.Delta)
259+
}
260+
want := `{"file_path":"/etc/hosts"}`
261+
if !jsonEqual(t, evt.Delta.PartialJSON, want) {
262+
t.Fatalf("cleaned tool input = %q, want equivalent to %q",
263+
evt.Delta.PartialJSON, want)
264+
}
265+
}
266+
if evt.Type == "content_block_stop" {
267+
sawStop = true
268+
}
269+
}
270+
if !sawDelta {
271+
t.Fatalf("expected one content_block_delta with cleaned input on close")
272+
}
273+
if !sawStop {
274+
t.Fatalf("expected content_block_stop on close")
275+
}
276+
}
277+
278+
func mustJSONString(s string) string {
279+
b, err := json.Marshal(s)
280+
if err != nil {
281+
panic(err)
282+
}
283+
return string(b)
284+
}

0 commit comments

Comments
 (0)