Skip to content

Commit 8732f06

Browse files
james-6-23ImogeneOctaviap794
authored andcommitted
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 james-6-23#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 0901435 commit 8732f06

1 file changed

Lines changed: 81 additions & 28 deletions

File tree

proxy/anthropic.go

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,9 @@ func appendAssistantBlocks(input []any, blocks []anthropicContentBlock) []any {
359359
}
360360
args := "{}"
361361
if len(b.Input) > 0 {
362-
args = string(b.Input)
362+
if cleaned := sanitizeToolInputJSON(string(b.Input)); cleaned != "" {
363+
args = cleaned
364+
}
363365
}
364366
input = append(input, map[string]any{
365367
"type": "function_call",
@@ -489,18 +491,19 @@ func convertAnthropicToolChoice(raw json.RawMessage) any {
489491

490492
// anthropicStreamTranslator 有状态的流式响应翻译器(Codex → Anthropic)
491493
type anthropicStreamTranslator struct {
492-
model string
493-
responseID string
494-
messageStartSent bool
495-
contentBlockIndex int
496-
contentBlockOpen bool
497-
currentBlockType string // "text" | "thinking" | "tool_use"
498-
currentToolUseID string
499-
currentToolUseName string
500-
hasToolUse bool
501-
inputTokens int
502-
outputTokens int
503-
cachedTokens int
494+
model string
495+
responseID string
496+
messageStartSent bool
497+
contentBlockIndex int
498+
contentBlockOpen bool
499+
currentBlockType string // "text" | "thinking" | "tool_use"
500+
currentToolUseID string
501+
currentToolUseName string
502+
currentToolInputBuffer strings.Builder
503+
hasToolUse bool
504+
inputTokens int
505+
outputTokens int
506+
cachedTokens int
504507
}
505508

506509
// newAnthropicStreamTranslator 创建流式翻译器
@@ -708,22 +711,17 @@ func (t *anthropicStreamTranslator) handleThinkingDelta(data []byte) []anthropic
708711
return events
709712
}
710713

711-
// handleToolInputDelta 处理工具调用参数增量
714+
// handleToolInputDelta 缓冲工具调用参数增量。
715+
// 不直接转发为 input_json_delta:上游模型偶尔会塞入空可选字段(如 gpt-5.5
716+
// 给 Read 工具加 "pages":""),逐片透传后下游会看到污染后的入参。统一在
717+
// closeCurrentBlock 时整段清洗后一次性下发。
712718
func (t *anthropicStreamTranslator) handleToolInputDelta(data []byte) []anthropicStreamEvent {
713719
delta := gjson.GetBytes(data, "delta").String()
714720
if delta == "" {
715721
return nil
716722
}
717-
718-
idx := t.contentBlockIndex - 1
719-
return []anthropicStreamEvent{{
720-
Type: "content_block_delta",
721-
Index: &idx,
722-
Delta: &anthropicDelta{
723-
Type: "input_json_delta",
724-
PartialJSON: delta,
725-
},
726-
}}
723+
t.currentToolInputBuffer.WriteString(delta)
724+
return nil
727725
}
728726

729727
// handleContentDone 处理内容完成(文本/推理块)
@@ -803,17 +801,70 @@ func (t *anthropicStreamTranslator) handleFailed() []anthropicStreamEvent {
803801
return events
804802
}
805803

806-
// closeCurrentBlock 关闭当前打开的 content block
804+
// closeCurrentBlock 关闭当前打开的 content block。
805+
// 关闭 tool_use 块时会先把累积的 arguments JSON 整段清洗(删除空字符串/null
806+
// 的可选字段),再作为单次 input_json_delta 下发。
807807
func (t *anthropicStreamTranslator) closeCurrentBlock() []anthropicStreamEvent {
808808
if !t.contentBlockOpen {
809809
return nil
810810
}
811811
t.contentBlockOpen = false
812812
idx := t.contentBlockIndex - 1
813-
return []anthropicStreamEvent{{
813+
814+
var events []anthropicStreamEvent
815+
if t.currentBlockType == "tool_use" && t.currentToolInputBuffer.Len() > 0 {
816+
cleaned := sanitizeToolInputJSON(t.currentToolInputBuffer.String())
817+
if cleaned != "" {
818+
events = append(events, anthropicStreamEvent{
819+
Type: "content_block_delta",
820+
Index: &idx,
821+
Delta: &anthropicDelta{
822+
Type: "input_json_delta",
823+
PartialJSON: cleaned,
824+
},
825+
})
826+
}
827+
t.currentToolInputBuffer.Reset()
828+
}
829+
830+
events = append(events, anthropicStreamEvent{
814831
Type: "content_block_stop",
815832
Index: &idx,
816-
}}
833+
})
834+
return events
835+
}
836+
837+
// sanitizeToolInputJSON 清洗工具调用 arguments JSON:
838+
// 仅删除顶层值为空字符串("")或 null 的字段。
839+
// 不动空对象 {} / 空数组 [](部分工具语义上允许这两者)。
840+
// 上游 gpt-5.5 偶尔会给 Read 工具加 "pages":"",导致 claudecode 看到的入参
841+
// 带上无效空字段,模型在后续轮次里反复纠结"工具层带入了空 pages"。在代理
842+
// 层统一清掉,比让客户端去兼容更简单。
843+
func sanitizeToolInputJSON(raw string) string {
844+
raw = strings.TrimSpace(raw)
845+
if raw == "" {
846+
return raw
847+
}
848+
var obj map[string]json.RawMessage
849+
if err := json.Unmarshal([]byte(raw), &obj); err != nil {
850+
return raw
851+
}
852+
changed := false
853+
for k, v := range obj {
854+
s := strings.TrimSpace(string(v))
855+
if s == `""` || s == "null" {
856+
delete(obj, k)
857+
changed = true
858+
}
859+
}
860+
if !changed {
861+
return raw
862+
}
863+
out, err := json.Marshal(obj)
864+
if err != nil {
865+
return raw
866+
}
867+
return string(out)
817868
}
818869

819870
// finalize 在流结束时补齐缺失的事件
@@ -907,7 +958,9 @@ func buildAnthropicResponseFromCompleted(completedData []byte, model string) *an
907958
callID := fromCodexCallID(item.Get("call_id").String())
908959
name := item.Get("name").String()
909960
args := item.Get("arguments").String()
910-
if args == "" {
961+
if cleaned := sanitizeToolInputJSON(args); cleaned != "" {
962+
args = cleaned
963+
} else {
911964
args = "{}"
912965
}
913966
content = append(content, anthropicContentBlock{

0 commit comments

Comments
 (0)