Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion backend/internal/handler/gateway_handler_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
if parsedReq == nil {
parsedReq = &service.ParsedRequest{Model: reqModel, Stream: reqStream, Body: bodyRef}
}
setOpsRequestContext(c, reqModel, reqStream, body, parsedReq.EstimatedInputTokens)
parsedReq.SessionContext = &service.SessionContext{
ClientIP: ip.GetClientIP(c),
UserAgent: c.GetHeader("User-Agent"),
Expand Down Expand Up @@ -223,6 +224,9 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
)
if err != nil {
reqLog.Warn("gateway.responses.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
if fs.HandleAccountSlotExhausted(c.Request.Context(), account.ID) == FailoverContinue {
continue
}
h.handleConcurrencyError(c, err, "account", streamStarted)
return
}
Expand All @@ -231,6 +235,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {

// 5. Forward request
writerSizeBeforeForward := c.Writer.Size()
writerWrittenBeforeForward := c.Writer.Written()
forwardBody := body
if channelMapping.Mapped {
forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
Expand All @@ -245,7 +250,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
// Can't failover if streaming content already sent
if c.Writer.Size() != writerSizeBeforeForward {
if c.Writer.Written() != writerWrittenBeforeForward || c.Writer.Size() != writerSizeBeforeForward {
h.handleResponsesFailoverExhausted(c, failoverErr, true)
return
}
Expand Down
233 changes: 229 additions & 4 deletions backend/internal/pkg/apicompat/anthropic_responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
// AnthropicToResponses tests
// ---------------------------------------------------------------------------

func intPtr(v int) *int { return &v }

func TestAnthropicToResponses_BasicText(t *testing.T) {
req := &AnthropicRequest{
Model: "gpt-5.2",
Expand Down Expand Up @@ -143,7 +145,7 @@ func TestAnthropicToResponses_ToolUse(t *testing.T) {
assert.Empty(t, items[2].ID)
assert.Equal(t, "function_call_output", items[3].Type)
assert.Equal(t, "call_1", items[3].CallID)
assert.Equal(t, "Sunny, 72°F", items[3].Output)
assert.Equal(t, `"Sunny, 72°F"`, string(items[3].Output))
}

func TestAnthropicToResponses_ThinkingIgnored(t *testing.T) {
Expand Down Expand Up @@ -794,6 +796,70 @@ func TestStreamingReasoning(t *testing.T) {
assert.Equal(t, "content_block_stop", events[0].Type)
}

func TestAnthropicEventToResponses_ReasoningDoneCarriesFullSummary(t *testing.T) {
state := NewAnthropicEventToResponsesState()

AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_start",
Message: &AnthropicResponse{
ID: "msg_reasoning_done",
Model: "claude-sonnet-4-5-20250929",
},
}, state)

idx := 0
events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_start",
Index: &idx,
ContentBlock: &AnthropicContentBlock{
Type: "thinking",
ID: "think_1",
},
}, state)
require.Len(t, events, 1)
assert.Equal(t, "response.output_item.added", events[0].Type)

AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_delta",
Index: &idx,
Delta: &AnthropicDelta{
Type: "thinking_delta",
Thinking: "step one",
},
}, state)
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_delta",
Index: &idx,
Delta: &AnthropicDelta{
Type: "thinking_delta",
Thinking: " and step two",
},
}, state)

events = AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_stop",
Index: &idx,
}, state)

var reasoningDone, itemDone *ResponsesStreamEvent
for i := range events {
switch events[i].Type {
case "response.reasoning_summary_text.done":
reasoningDone = &events[i]
case "response.output_item.done":
itemDone = &events[i]
}
}
require.NotNil(t, reasoningDone, "reasoning_summary_text.done missing")
assert.Equal(t, "step one and step two", reasoningDone.Text)
require.NotNil(t, itemDone, "output_item.done missing")
require.NotNil(t, itemDone.Item)
assert.Equal(t, "reasoning", itemDone.Item.Type)
require.Len(t, itemDone.Item.Summary, 1)
assert.Equal(t, "summary_text", itemDone.Item.Summary[0].Type)
assert.Equal(t, "step one and step two", itemDone.Item.Summary[0].Text)
}

func TestStreamingIncomplete(t *testing.T) {
state := NewResponsesEventToAnthropicState()

Expand Down Expand Up @@ -1256,6 +1322,37 @@ func TestResponsesToAnthropicRequest_ToolChoiceLegacyFunctionName(t *testing.T)
assert.Equal(t, "get_weather", tc["name"])
}

func TestResponsesToAnthropicRequest_ReasoningUsesAdaptiveThinking(t *testing.T) {
req := &ResponsesRequest{
Model: "gpt-5.5",
Input: json.RawMessage(`[{"role":"user","content":"Hello"}]`),
Reasoning: &ResponsesReasoning{
Effort: "medium",
},
}

resp, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err)
require.NotNil(t, resp.OutputConfig)
assert.Equal(t, "medium", resp.OutputConfig.Effort)
require.NotNil(t, resp.Thinking)
assert.Equal(t, "adaptive", resp.Thinking.Type)
assert.Zero(t, resp.Thinking.BudgetTokens)
}

func TestResponsesToAnthropicRequest_EmptyUserContentGetsPlaceholder(t *testing.T) {
req := &ResponsesRequest{
Model: "gpt-5.5",
Input: json.RawMessage(`[{"role":"user","content":[]}]`),
}

resp, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err)
require.Len(t, resp.Messages, 1)
assert.Equal(t, "user", resp.Messages[0].Role)
assert.JSONEq(t, `"(empty)"`, string(resp.Messages[0].Content))
}

// ---------------------------------------------------------------------------
// Image content block conversion tests
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1340,7 +1437,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, "toolu_1", items[2].CallID)
assert.Equal(t, "(empty)", items[2].Output)
assert.Equal(t, `"(empty)"`, string(items[2].Output))

// Image should be in a separate user message.
assert.Equal(t, "user", items[3].Role)
Expand Down Expand Up @@ -1377,7 +1474,7 @@ func TestAnthropicToResponses_ToolResultMixed(t *testing.T) {

// function_call_output should have text-only output.
assert.Equal(t, "function_call_output", items[2].Type)
assert.Equal(t, "File metadata: 800x600 PNG", items[2].Output)
assert.Equal(t, `"File metadata: 800x600 PNG"`, string(items[2].Output))

// Image should be in a separate user message.
assert.Equal(t, "user", items[3].Role)
Expand Down Expand Up @@ -1412,7 +1509,7 @@ func TestAnthropicToResponses_TextOnlyToolResultBackwardCompat(t *testing.T) {
require.Len(t, items, 3)

// Text-only tool_result should produce a plain string.
assert.Equal(t, "Sunny, 72°F", items[2].Output)
assert.Equal(t, `"Sunny, 72°F"`, string(items[2].Output))
}

func TestAnthropicToResponses_ImageEmptyMediaType(t *testing.T) {
Expand Down Expand Up @@ -1733,3 +1830,131 @@ func TestAnthropicEventToResponses_CacheTokensFromMessageDelta(t *testing.T) {
require.NotNil(t, completed.Response.Usage.InputTokensDetails)
assert.Equal(t, 11, completed.Response.Usage.InputTokensDetails.CachedTokens)
}

func TestAnthropicEventToResponses_ToolCallDoneCarriesFullArguments(t *testing.T) {
state := NewAnthropicEventToResponsesState()

AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_start",
Message: &AnthropicResponse{
ID: "msg_tool",
Model: "claude-sonnet-4-5-20250929",
},
}, state)

idx := 0
events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_start",
Index: &idx,
ContentBlock: &AnthropicContentBlock{
Type: "tool_use",
ID: "toolu_123",
Name: "run_shell",
},
}, state)
require.Len(t, events, 1)
require.Equal(t, "response.output_item.added", events[0].Type)

AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_delta",
Index: &idx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: `{"cmd":`,
},
}, state)
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_delta",
Index: &idx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: `"ls"}`,
},
}, state)

events = AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_stop",
Index: &idx,
}, state)

var argsDone, itemDone *ResponsesStreamEvent
for i := range events {
switch events[i].Type {
case "response.function_call_arguments.done":
argsDone = &events[i]
case "response.output_item.done":
itemDone = &events[i]
}
}
require.NotNil(t, argsDone, "function_call_arguments.done missing")
assert.Equal(t, `{"cmd":"ls"}`, argsDone.Arguments)
require.NotNil(t, itemDone, "function_call output_item.done missing")
require.NotNil(t, itemDone.Item)
assert.Equal(t, "function_call", itemDone.Item.Type)
assert.Equal(t, "toolu_123", itemDone.Item.CallID)
assert.Equal(t, "run_shell", itemDone.Item.Name)
assert.Equal(t, `{"cmd":"ls"}`, itemDone.Item.Arguments)
}

func TestAnthropicEventToResponses_ToolUseStartEmptyInputDoesNotPrefixDeltaArguments(t *testing.T) {
state := NewAnthropicEventToResponsesState()

AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_start",
Message: &AnthropicResponse{
ID: "msg_tool_empty_start",
Model: "claude-sonnet-4-5-20250929",
},
}, state)

idx := 0
events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_start",
Index: &idx,
ContentBlock: &AnthropicContentBlock{
Type: "tool_use",
ID: "toolu_empty_start",
Name: "get_weather",
Input: json.RawMessage(`{}`),
},
}, state)
require.Len(t, events, 1)
require.Equal(t, "response.output_item.added", events[0].Type)

AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_delta",
Index: &idx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: `{"city":"Beijing"`,
},
}, state)
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_delta",
Index: &idx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: `,"unit":"celsius"}`,
},
}, state)

events = AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_stop",
Index: &idx,
}, state)

var argsDone, itemDone *ResponsesStreamEvent
for i := range events {
switch events[i].Type {
case "response.function_call_arguments.done":
argsDone = &events[i]
case "response.output_item.done":
itemDone = &events[i]
}
}
require.NotNil(t, argsDone, "function_call_arguments.done missing")
require.JSONEq(t, `{"city":"Beijing","unit":"celsius"}`, argsDone.Arguments)
require.NotNil(t, itemDone, "function_call output_item.done missing")
require.NotNil(t, itemDone.Item)
require.JSONEq(t, `{"city":"Beijing","unit":"celsius"}`, itemDone.Item.Arguments)
}
4 changes: 2 additions & 2 deletions backend/internal/pkg/apicompat/anthropic_to_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error)
out = append(out, ResponsesInputItem{
Type: "function_call_output",
CallID: toResponsesCallID(b.ToolUseID),
Output: outputText,
Output: jsonRawString(outputText),
})
toolResultImageParts = append(toolResultImageParts, imageParts...)
}
Expand Down Expand Up @@ -302,7 +302,7 @@ func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, e
Type: "function_call",
CallID: fcID,
Name: b.Name,
Arguments: args,
Arguments: jsonRawString(args),
})
}

Expand Down
Loading
Loading