feat: improve OpenAI messages compatibility for Claude Code

This commit is contained in:
lyen1688
2026-05-05 19:32:41 +08:00
parent 94e494319a
commit 0584305e5a
21 changed files with 2525 additions and 136 deletions
@@ -32,7 +32,13 @@ func TestAnthropicToResponses_BasicText(t *testing.T) {
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(resp.Input, &items))
require.Len(t, items, 1)
assert.Equal(t, "message", items[0].Type)
assert.Equal(t, "user", items[0].Role)
var parts []ResponsesContentPart
require.NoError(t, json.Unmarshal(items[0].Content, &parts))
require.Len(t, parts, 1)
assert.Equal(t, "input_text", parts[0].Type)
assert.Equal(t, "Hello", parts[0].Text)
}
func TestAnthropicToResponses_SystemPrompt(t *testing.T) {
@@ -49,7 +55,12 @@ func TestAnthropicToResponses_SystemPrompt(t *testing.T) {
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(resp.Input, &items))
require.Len(t, items, 2)
assert.Equal(t, "system", items[0].Role)
assert.Equal(t, "developer", items[0].Role)
var parts []ResponsesContentPart
require.NoError(t, json.Unmarshal(items[0].Content, &parts))
require.Len(t, parts, 1)
assert.Equal(t, "input_text", parts[0].Type)
assert.Equal(t, "You are helpful.", parts[0].Text)
})
t.Run("array", func(t *testing.T) {
@@ -65,11 +76,33 @@ func TestAnthropicToResponses_SystemPrompt(t *testing.T) {
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(resp.Input, &items))
require.Len(t, items, 2)
assert.Equal(t, "system", items[0].Role)
// System text should be joined with double newline.
var text string
require.NoError(t, json.Unmarshal(items[0].Content, &text))
assert.Equal(t, "Part 1\n\nPart 2", text)
assert.Equal(t, "developer", items[0].Role)
var parts []ResponsesContentPart
require.NoError(t, json.Unmarshal(items[0].Content, &parts))
require.Len(t, parts, 2)
assert.Equal(t, "input_text", parts[0].Type)
assert.Equal(t, "Part 1", parts[0].Text)
assert.Equal(t, "input_text", parts[1].Type)
assert.Equal(t, "Part 2", parts[1].Text)
})
t.Run("billing header skipped", func(t *testing.T) {
req := &AnthropicRequest{
Model: "gpt-5.2",
MaxTokens: 100,
System: json.RawMessage(`[{"type":"text","text":"x-anthropic-billing-header: cc_version=1;"},{"type":"text","text":"Project prompt"}]`),
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
}
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(resp.Input, &items))
require.Len(t, items, 2)
var parts []ResponsesContentPart
require.NoError(t, json.Unmarshal(items[0].Content, &parts))
require.Len(t, parts, 1)
assert.Equal(t, "Project prompt", parts[0].Text)
})
}
@@ -94,6 +127,8 @@ func TestAnthropicToResponses_ToolUse(t *testing.T) {
require.Len(t, resp.Tools, 1)
assert.Equal(t, "function", resp.Tools[0].Type)
assert.Equal(t, "get_weather", resp.Tools[0].Name)
require.NotNil(t, resp.Tools[0].Strict)
assert.False(t, *resp.Tools[0].Strict)
// Check input items
var items []ResponsesInputItem
@@ -104,10 +139,10 @@ func TestAnthropicToResponses_ToolUse(t *testing.T) {
assert.Equal(t, "user", items[0].Role)
assert.Equal(t, "assistant", items[1].Role)
assert.Equal(t, "function_call", items[2].Type)
assert.Equal(t, "fc_call_1", items[2].CallID)
assert.Equal(t, "call_1", items[2].CallID)
assert.Empty(t, items[2].ID)
assert.Equal(t, "function_call_output", items[3].Type)
assert.Equal(t, "fc_call_1", items[3].CallID)
assert.Equal(t, "call_1", items[3].CallID)
assert.Equal(t, "Sunny, 72°F", items[3].Output)
}
@@ -261,6 +296,34 @@ func TestResponsesToAnthropic_ToolUse(t *testing.T) {
assert.JSONEq(t, `{"city":"NYC"}`, string(anth.Content[1].Input))
}
func TestResponsesToAnthropic_ToolUseStopReasonDoesNotDependOnLastBlock(t *testing.T) {
resp := &ResponsesResponse{
ID: "resp_tool_then_text",
Model: "gpt-5.5",
Status: "completed",
Output: []ResponsesOutput{
{
Type: "function_call",
CallID: "call_todo",
Name: "TodoWrite",
Arguments: `{"todos":[{"content":"review changes","status":"in_progress"}]}`,
},
{
Type: "message",
Content: []ResponsesContentPart{
{Type: "output_text", Text: "Task list updated."},
},
},
},
}
anth := ResponsesToAnthropic(resp, "claude-opus-4-6")
assert.Equal(t, "tool_use", anth.StopReason)
require.Len(t, anth.Content, 2)
assert.Equal(t, "tool_use", anth.Content[0].Type)
assert.Equal(t, "text", anth.Content[1].Type)
}
func TestResponsesToAnthropic_ReadToolDropsEmptyPages(t *testing.T) {
resp := &ResponsesResponse{
ID: "resp_read",
@@ -553,6 +616,81 @@ func TestStreamingToolCall(t *testing.T) {
assert.Equal(t, "tool_use", events[0].Delta.StopReason)
}
func TestStreamingToolCallStopReasonSurvivesLaterText(t *testing.T) {
state := NewResponsesEventToAnthropicState()
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_tool_then_text", Model: "gpt-5.5"},
}, state)
events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_todo", Name: "TodoWrite"},
}, state)
require.Len(t, events, 1)
assert.Equal(t, "content_block_start", events[0].Type)
events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"todos":[{"content":"review changes","status":"in_progress","activeForm":"reviewing changes"}]}`,
}, state)
require.Len(t, events, 2)
assert.Equal(t, "content_block_delta", events[0].Type)
assert.Equal(t, "content_block_stop", events[1].Type)
events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.output_text.delta",
OutputIndex: 1,
Delta: "I will continue after the task list updates.",
}, state)
require.Len(t, events, 2)
assert.Equal(t, "content_block_start", events[0].Type)
assert.Equal(t, "content_block_delta", events[1].Type)
events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.completed",
Response: &ResponsesResponse{
Status: "completed",
Usage: &ResponsesUsage{InputTokens: 20, OutputTokens: 10},
},
}, state)
require.Len(t, events, 3)
assert.Equal(t, "content_block_stop", events[0].Type)
assert.Equal(t, "tool_use", events[1].Delta.StopReason)
assert.Equal(t, "message_stop", events[2].Type)
}
func TestStreamingToolCallDoneWithoutDeltaEmitsArguments(t *testing.T) {
state := NewResponsesEventToAnthropicState()
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_bash", Model: "gpt-5.5"},
}, state)
events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_bash", Name: "Bash"},
}, state)
require.Len(t, events, 1)
assert.Equal(t, "content_block_start", events[0].Type)
events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"command":"git -C \"/mnt/d/nodejs/other/edmt\" status --short --ignored"}`,
}, state)
require.Len(t, events, 2)
assert.Equal(t, "content_block_delta", events[0].Type)
assert.Equal(t, "input_json_delta", events[0].Delta.Type)
assert.JSONEq(t, `{"command":"git -C \"/mnt/d/nodejs/other/edmt\" status --short --ignored"}`, events[0].Delta.PartialJSON)
assert.Equal(t, "content_block_stop", events[1].Type)
}
func TestStreamingReadToolDropsEmptyPages(t *testing.T) {
state := NewResponsesEventToAnthropicState()
@@ -692,6 +830,27 @@ func TestFinalizeStream_AbnormalTermination(t *testing.T) {
assert.Equal(t, "message_stop", events[2].Type)
}
func TestFinalizeStream_ToolCallAbnormalTerminationKeepsToolUseStopReason(t *testing.T) {
state := NewResponsesEventToAnthropicState()
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_tool_interrupted", Model: "gpt-5.5"},
}, state)
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_todo", Name: "TodoWrite"},
}, state)
events := FinalizeResponsesAnthropicStream(state)
require.Len(t, events, 3)
assert.Equal(t, "content_block_stop", events[0].Type)
assert.Equal(t, "message_delta", events[1].Type)
assert.Equal(t, "tool_use", events[1].Delta.StopReason)
assert.Equal(t, "message_stop", events[2].Type)
}
func TestStreamingEmptyResponse(t *testing.T) {
state := NewResponsesEventToAnthropicState()
@@ -827,8 +986,8 @@ func TestAnthropicToResponses_ThinkingEnabled(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Reasoning)
// thinking.type is ignored for effort; default high applies.
assert.Equal(t, "high", resp.Reasoning.Effort)
// thinking.type is ignored for effort; Codex bridge default medium applies.
assert.Equal(t, "medium", resp.Reasoning.Effort)
assert.Equal(t, "auto", resp.Reasoning.Summary)
assert.Contains(t, resp.Include, "reasoning.encrypted_content")
assert.NotContains(t, resp.Include, "reasoning.summary")
@@ -845,8 +1004,8 @@ func TestAnthropicToResponses_ThinkingAdaptive(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Reasoning)
// thinking.type is ignored for effort; default high applies.
assert.Equal(t, "high", resp.Reasoning.Effort)
// thinking.type is ignored for effort; Codex bridge default medium applies.
assert.Equal(t, "medium", resp.Reasoning.Effort)
assert.Equal(t, "auto", resp.Reasoning.Summary)
assert.NotContains(t, resp.Include, "reasoning.summary")
}
@@ -861,9 +1020,9 @@ func TestAnthropicToResponses_ThinkingDisabled(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
// Default effort applies (high → high) even when thinking is disabled.
// Default effort applies (medium) even when thinking is disabled.
require.NotNil(t, resp.Reasoning)
assert.Equal(t, "high", resp.Reasoning.Effort)
assert.Equal(t, "medium", resp.Reasoning.Effort)
}
func TestAnthropicToResponses_NoThinking(t *testing.T) {
@@ -875,9 +1034,9 @@ func TestAnthropicToResponses_NoThinking(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
// Default effort applies (high → high) when no thinking/output_config is set.
// Default effort applies (medium) when no thinking/output_config is set.
require.NotNil(t, resp.Reasoning)
assert.Equal(t, "high", resp.Reasoning.Effort)
assert.Equal(t, "medium", resp.Reasoning.Effort)
}
// ---------------------------------------------------------------------------
@@ -885,7 +1044,7 @@ func TestAnthropicToResponses_NoThinking(t *testing.T) {
// ---------------------------------------------------------------------------
func TestAnthropicToResponses_OutputConfigOverridesDefault(t *testing.T) {
// Default is high, but output_config.effort="low" overrides. low→low after mapping.
// Default is medium, but output_config.effort="low" overrides. low→low after mapping.
req := &AnthropicRequest{
Model: "gpt-5.2",
MaxTokens: 1024,
@@ -919,7 +1078,7 @@ func TestAnthropicToResponses_OutputConfigWithoutThinking(t *testing.T) {
}
func TestAnthropicToResponses_OutputConfigHigh(t *testing.T) {
// output_config.effort="high" → mapped to "high" (1:1, both sides' default).
// output_config.effort="high" → mapped to "high" (1:1).
req := &AnthropicRequest{
Model: "gpt-5.2",
MaxTokens: 1024,
@@ -951,7 +1110,7 @@ func TestAnthropicToResponses_OutputConfigMax(t *testing.T) {
}
func TestAnthropicToResponses_NoOutputConfig(t *testing.T) {
// No output_config → default high regardless of thinking.type.
// No output_config → default medium regardless of thinking.type.
req := &AnthropicRequest{
Model: "gpt-5.2",
MaxTokens: 1024,
@@ -962,11 +1121,11 @@ func TestAnthropicToResponses_NoOutputConfig(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Reasoning)
assert.Equal(t, "high", resp.Reasoning.Effort)
assert.Equal(t, "medium", resp.Reasoning.Effort)
}
func TestAnthropicToResponses_OutputConfigWithoutEffort(t *testing.T) {
// output_config present but effort empty (e.g. only format set) → default high.
// output_config present but effort empty (e.g. only format set) → default medium.
req := &AnthropicRequest{
Model: "gpt-5.2",
MaxTokens: 1024,
@@ -977,7 +1136,7 @@ func TestAnthropicToResponses_OutputConfigWithoutEffort(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Reasoning)
assert.Equal(t, "high", resp.Reasoning.Effort)
assert.Equal(t, "medium", resp.Reasoning.Effort)
}
// ---------------------------------------------------------------------------
@@ -1149,7 +1308,7 @@ func TestAnthropicToResponses_ToolResultWithImage(t *testing.T) {
// function_call_output should have text-only output (no image).
assert.Equal(t, "function_call_output", items[2].Type)
assert.Equal(t, "fc_toolu_1", items[2].CallID)
assert.Equal(t, "toolu_1", items[2].CallID)
assert.Equal(t, "(empty)", items[2].Output)
// Image should be in a separate user message.
@@ -32,6 +32,9 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
storeFalse := false
out.Store = &storeFalse
parallelToolCalls := true
out.ParallelToolCalls = &parallelToolCalls
out.Text = &ResponsesText{Verbosity: "medium"}
if req.MaxTokens > 0 {
v := req.MaxTokens
@@ -46,10 +49,10 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
}
// Determine reasoning effort: only output_config.effort controls the
// level; thinking.type is ignored. Default is high when unset (both
// Anthropic and OpenAI default to high).
// level; thinking.type is ignored. Default follows Codex CLI / airgate's
// Anthropic bridge shape, which uses medium when unset.
// Anthropic levels map 1:1 to OpenAI: low→low, medium→medium, high→high, max→xhigh.
effort := "high" // default → both sides' default
effort := "medium"
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort = req.OutputConfig.Effort
}
@@ -108,16 +111,19 @@ func convertAnthropicToolChoiceToResponses(raw json.RawMessage) (json.RawMessage
func convertAnthropicToResponsesInput(system json.RawMessage, msgs []AnthropicMessage) ([]ResponsesInputItem, error) {
var out []ResponsesInputItem
// System prompt → system role input item.
// System prompt → developer role input item. ChatGPT Codex SSE behaves like
// Codex CLI here: keeping Anthropic system text in input preserves the
// conversation/cache shape better than moving it into instructions.
if len(system) > 0 {
sysText, err := parseAnthropicSystemPrompt(system)
sysParts, err := parseAnthropicSystemContentParts(system)
if err != nil {
return nil, err
}
if sysText != "" {
content, _ := json.Marshal(sysText)
if len(sysParts) > 0 {
content, _ := json.Marshal(sysParts)
out = append(out, ResponsesInputItem{
Role: "system",
Type: "message",
Role: "developer",
Content: content,
})
}
@@ -133,24 +139,32 @@ func convertAnthropicToResponsesInput(system json.RawMessage, msgs []AnthropicMe
return out, nil
}
// parseAnthropicSystemPrompt handles the Anthropic system field which can be
// a plain string or an array of text blocks.
func parseAnthropicSystemPrompt(raw json.RawMessage) (string, error) {
// parseAnthropicSystemContentParts handles the Anthropic system field which can
// be a plain string or an array of text blocks. Claude Code may include an
// x-anthropic-billing-header block; airgate drops it before sending to Codex.
func parseAnthropicSystemContentParts(raw json.RawMessage) ([]ResponsesContentPart, error) {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s, nil
if isAnthropicBillingHeaderText(s) || s == "" {
return nil, nil
}
return []ResponsesContentPart{{Type: "input_text", Text: s}}, nil
}
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return "", err
return nil, err
}
var parts []string
var parts []ResponsesContentPart
for _, b := range blocks {
if b.Type == "text" && b.Text != "" {
parts = append(parts, b.Text)
if b.Type == "text" && b.Text != "" && !isAnthropicBillingHeaderText(b.Text) {
parts = append(parts, ResponsesContentPart{Type: "input_text", Text: b.Text})
}
}
return strings.Join(parts, "\n\n"), nil
return parts, nil
}
func isAnthropicBillingHeaderText(text string) bool {
return strings.HasPrefix(text, "x-anthropic-billing-header: ")
}
// anthropicMsgToResponsesItems converts a single Anthropic message into one
@@ -173,8 +187,12 @@ func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error)
// Try plain string.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
content, _ := json.Marshal(s)
return []ResponsesInputItem{{Role: "user", Content: content}}, nil
parts := []ResponsesContentPart{{Type: "input_text", Text: s}}
partsJSON, err := json.Marshal(parts)
if err != nil {
return nil, err
}
return []ResponsesInputItem{{Type: "message", Role: "user", Content: partsJSON}}, nil
}
var blocks []AnthropicContentBlock
@@ -223,7 +241,7 @@ func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error)
if err != nil {
return nil, err
}
out = append(out, ResponsesInputItem{Role: "user", Content: content})
out = append(out, ResponsesInputItem{Type: "message", Role: "user", Content: content})
}
return out, nil
@@ -242,7 +260,7 @@ func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, e
if err != nil {
return nil, err
}
return []ResponsesInputItem{{Role: "assistant", Content: partsJSON}}, nil
return []ResponsesInputItem{{Type: "message", Role: "assistant", Content: partsJSON}}, nil
}
var blocks []AnthropicContentBlock
@@ -260,7 +278,7 @@ func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, e
if err != nil {
return nil, err
}
items = append(items, ResponsesInputItem{Role: "assistant", Content: partsJSON})
items = append(items, ResponsesInputItem{Type: "message", Role: "assistant", Content: partsJSON})
}
// tool_use → function_call items.
@@ -284,17 +302,14 @@ func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, e
return items, nil
}
// toResponsesCallID converts an Anthropic tool ID (toolu_xxx / call_xxx) to a
// Responses API function_call ID that starts with "fc_".
// toResponsesCallID preserves Anthropic tool IDs as Responses call_id values.
// Claude Code sends tool_result.tool_use_id back verbatim, and ChatGPT Codex
// continuation expects that call_id to match the original tool_use id.
func toResponsesCallID(id string) string {
if strings.HasPrefix(id, "fc_") {
return id
}
return "fc_" + id
return id
}
// fromResponsesCallID reverses toResponsesCallID, stripping the "fc_" prefix
// that was added during request conversion.
// fromResponsesCallID reverses old prefixed IDs while preserving current IDs.
func fromResponsesCallID(id string) string {
if after, ok := strings.CutPrefix(id, "fc_"); ok {
// Only strip if the remainder doesn't look like it was already "fc_" prefixed.
@@ -412,11 +427,16 @@ func convertAnthropicToolsToResponses(tools []AnthropicTool) []ResponsesTool {
Name: t.Name,
Description: t.Description,
Parameters: normalizeToolParameters(t.InputSchema),
Strict: boolPtr(false),
})
}
return out
}
func boolPtr(v bool) *bool {
return &v
}
// normalizeToolParameters ensures the tool parameter schema is valid for
// OpenAI's Responses API, which requires "properties" on object schemas.
//
@@ -120,7 +120,7 @@ func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncom
}
return "end_turn"
case "completed":
if len(blocks) > 0 && blocks[len(blocks)-1].Type == "tool_use" {
if containsAnthropicToolUseBlock(blocks) {
return "tool_use"
}
return "end_turn"
@@ -129,6 +129,15 @@ func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncom
}
}
func containsAnthropicToolUseBlock(blocks []AnthropicContentBlock) bool {
for _, block := range blocks {
if block.Type == "tool_use" {
return true
}
}
return false
}
func sanitizeAnthropicToolUseInput(name string, raw string) json.RawMessage {
if name != "Read" || raw == "" {
return json.RawMessage(raw)
@@ -161,11 +170,13 @@ type ResponsesEventToAnthropicState struct {
MessageStartSent bool
MessageStopSent bool
ContentBlockIndex int
ContentBlockOpen bool
CurrentBlockType string // "text" | "thinking" | "tool_use"
CurrentToolName string
CurrentToolArgs string
ContentBlockIndex int
ContentBlockOpen bool
CurrentBlockType string // "text" | "thinking" | "tool_use"
CurrentToolName string
CurrentToolArgs string
CurrentToolHadDelta bool
HasToolCall bool
// OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index.
OutputIndexToBlockIdx map[int]int
@@ -231,11 +242,16 @@ func FinalizeResponsesAnthropicStream(state *ResponsesEventToAnthropicState) []A
var events []AnthropicStreamEvent
events = append(events, closeCurrentBlock(state)...)
stopReason := "end_turn"
if state.HasToolCall {
stopReason = "tool_use"
}
events = append(events,
AnthropicStreamEvent{
Type: "message_delta",
Delta: &AnthropicDelta{
StopReason: "end_turn",
StopReason: stopReason,
},
Usage: &AnthropicUsage{
InputTokens: state.InputTokens,
@@ -306,6 +322,8 @@ func resToAnthHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesE
state.CurrentBlockType = "tool_use"
state.CurrentToolName = evt.Item.Name
state.CurrentToolArgs = ""
state.CurrentToolHadDelta = false
state.HasToolCall = true
events = append(events, AnthropicStreamEvent{
Type: "content_block_start",
@@ -390,6 +408,9 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
state.CurrentToolArgs += evt.Delta
return nil
}
if state.CurrentBlockType == "tool_use" {
state.CurrentToolHadDelta = true
}
blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex]
if !ok {
@@ -407,7 +428,7 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
}
func resToAnthHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if state.CurrentBlockType != "tool_use" || state.CurrentToolName != "Read" {
if state.CurrentBlockType != "tool_use" {
return resToAnthHandleBlockDone(state)
}
@@ -415,10 +436,16 @@ func resToAnthHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEven
if raw == "" {
raw = state.CurrentToolArgs
}
sanitized := sanitizeAnthropicToolUseInput(state.CurrentToolName, raw)
if len(sanitized) == 0 {
if raw == "" || state.CurrentToolHadDelta {
return closeCurrentBlock(state)
}
if state.CurrentToolName == "Read" {
sanitized := sanitizeAnthropicToolUseInput(state.CurrentToolName, raw)
if len(sanitized) == 0 {
return closeCurrentBlock(state)
}
raw = string(sanitized)
}
idx := state.ContentBlockIndex
events := []AnthropicStreamEvent{{
@@ -426,7 +453,7 @@ func resToAnthHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEven
Index: &idx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: string(sanitized),
PartialJSON: raw,
},
}}
events = append(events, closeCurrentBlock(state)...)
@@ -553,7 +580,7 @@ func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo
stopReason = "max_tokens"
}
case "completed":
if state.ContentBlockIndex > 0 && state.CurrentBlockType == "tool_use" {
if state.HasToolCall {
stopReason = "tool_use"
}
}
@@ -586,6 +613,7 @@ func closeCurrentBlock(state *ResponsesEventToAnthropicState) []AnthropicStreamE
state.ContentBlockIndex++
state.CurrentToolName = ""
state.CurrentToolArgs = ""
state.CurrentToolHadDelta = false
return []AnthropicStreamEvent{{
Type: "content_block_stop",
Index: &idx,
+25 -14
View File
@@ -53,6 +53,8 @@ type AnthropicMessage struct {
type AnthropicContentBlock struct {
Type string `json:"type"`
CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"`
// type=text
Text string `json:"text,omitempty"`
@@ -165,19 +167,23 @@ type AnthropicDelta struct {
// ResponsesRequest is the request body for POST /v1/responses.
type ResponsesRequest struct {
Model string `json:"model"`
Instructions string `json:"instructions,omitempty"`
Input json.RawMessage `json:"input"` // string or []ResponsesInputItem
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []ResponsesTool `json:"tools,omitempty"`
Include []string `json:"include,omitempty"`
Store *bool `json:"store,omitempty"`
Reasoning *ResponsesReasoning `json:"reasoning,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Model string `json:"model"`
Instructions string `json:"instructions,omitempty"`
Input json.RawMessage `json:"input"` // string or []ResponsesInputItem
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []ResponsesTool `json:"tools,omitempty"`
Include []string `json:"include,omitempty"`
Store *bool `json:"store,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
Reasoning *ResponsesReasoning `json:"reasoning,omitempty"`
Text *ResponsesText `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
}
// ResponsesReasoning configures reasoning effort in the Responses API.
@@ -186,13 +192,18 @@ type ResponsesReasoning struct {
Summary string `json:"summary,omitempty"` // "auto" | "concise" | "detailed"
}
// ResponsesText configures text output options in the Responses API.
type ResponsesText struct {
Verbosity string `json:"verbosity,omitempty"` // "low" | "medium" | "high"
}
// ResponsesInputItem is one item in the Responses API input array.
// The Type field determines which other fields are populated.
type ResponsesInputItem struct {
// Common
Type string `json:"type,omitempty"` // "" for role-based messages
// Role-based messages (system/user/assistant)
// Role-based messages (developer/system/user/assistant)
Role string `json:"role,omitempty"`
Content json.RawMessage `json:"content,omitempty"` // string or []ResponsesContentPart