From 01288fae029bfd6aa63067a7506dd2c8ef94d0d3 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 26 Mar 2026 11:28:10 +0100 Subject: [PATCH 1/6] feat(anthropic): preserve thinking signatures in compat APIs --- internal/core/json_fields.go | 44 +++ internal/core/json_fields_test.go | 23 ++ internal/core/responses.go | 8 +- internal/core/responses_json.go | 24 ++ internal/core/responses_json_test.go | 71 +++++ internal/providers/anthropic/anthropic.go | 293 ++++++++++++++---- .../providers/anthropic/anthropic_test.go | 281 +++++++++++++++++ .../providers/anthropic/reasoning_compat.go | 210 +++++++++++++ .../anthropic/request_translation.go | 29 +- internal/providers/responses_adapter.go | 180 +++++++++++ internal/providers/responses_adapter_test.go | 211 +++++++++++++ 11 files changed, 1308 insertions(+), 66 deletions(-) create mode 100644 internal/providers/anthropic/reasoning_compat.go diff --git a/internal/core/json_fields.go b/internal/core/json_fields.go index c32032ff..421100f4 100644 --- a/internal/core/json_fields.go +++ b/internal/core/json_fields.go @@ -29,6 +29,50 @@ func CloneUnknownJSONFields(fields UnknownJSONFields) UnknownJSONFields { return UnknownJSONFields{raw: CloneRawJSON(fields.raw)} } +// MergeUnknownJSONFields combines multiple unknown-field objects into one. +// Later field sets override earlier ones for duplicate keys. +func MergeUnknownJSONFields(fields ...UnknownJSONFields) UnknownJSONFields { + if len(fields) == 0 { + return UnknownJSONFields{} + } + + var merged map[string]json.RawMessage + for _, fieldSet := range fields { + if fieldSet.IsEmpty() { + continue + } + + dec := json.NewDecoder(bytes.NewReader(fieldSet.raw)) + tok, err := dec.Token() + if err != nil { + continue + } + delim, ok := tok.(json.Delim) + if !ok || delim != '{' { + continue + } + + for dec.More() { + key, ok := readJSONObjectKey(dec) + if !ok { + return UnknownJSONFields{} + } + + var value json.RawMessage + if err := dec.Decode(&value); err != nil { + return UnknownJSONFields{} + } + + if merged == nil { + merged = make(map[string]json.RawMessage) + } + merged[key] = CloneRawJSON(value) + } + } + + return UnknownJSONFieldsFromMap(merged) +} + // UnknownJSONFieldsFromMap converts a raw field map into a compact JSON object. func UnknownJSONFieldsFromMap(fields map[string]json.RawMessage) UnknownJSONFields { if len(fields) == 0 { diff --git a/internal/core/json_fields_test.go b/internal/core/json_fields_test.go index 3100f5ab..76ef85e2 100644 --- a/internal/core/json_fields_test.go +++ b/internal/core/json_fields_test.go @@ -70,6 +70,29 @@ func TestUnknownJSONFieldsFromMap_EmptyRawValueEncodesAsNull(t *testing.T) { } } +func TestMergeUnknownJSONFields(t *testing.T) { + first := UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "x_first": json.RawMessage(`true`), + "x_shared": json.RawMessage(`"first"`), + }) + second := UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "x_second": json.RawMessage(`{"ok":true}`), + "x_shared": json.RawMessage(`"second"`), + }) + + merged := MergeUnknownJSONFields(first, second) + + if got := merged.Lookup("x_first"); !bytes.Equal(got, []byte("true")) { + t.Fatalf("x_first = %s, want true", got) + } + if got := merged.Lookup("x_second"); !bytes.Equal(got, []byte(`{"ok":true}`)) { + t.Fatalf("x_second = %s, want object", got) + } + if got := merged.Lookup("x_shared"); !bytes.Equal(got, []byte(`"second"`)) { + t.Fatalf("x_shared = %s, want later override", got) + } +} + func TestExtractUnknownJSONFieldsObjectByScan_RejectsInvalidJSONSyntax(t *testing.T) { tests := []struct { name string diff --git a/internal/core/responses.go b/internal/core/responses.go index 54fa1c78..0fd321d4 100644 --- a/internal/core/responses.go +++ b/internal/core/responses.go @@ -45,6 +45,7 @@ func (r *ResponsesRequest) WithStreaming() *ResponsesRequest { // ResponsesInputElement represents a single item in the Responses API input array. // It is a discriminated union keyed on Type: // - "" or "message": a chat-style message with Role and Content +// - "reasoning": a reasoning item with Content // - "function_call": a tool invocation with CallID, Name, and Arguments // - "function_call_output": a tool result with CallID and Output // @@ -53,7 +54,7 @@ func (r *ResponsesRequest) WithStreaming() *ResponsesRequest { // extensions can round-trip; Swagger ignores ExtraFields, and typed fields // should be preferred when available. type ResponsesInputElement struct { - Type string `json:"type,omitempty"` // "message", "function_call", "function_call_output" + Type string `json:"type,omitempty"` // "message", "reasoning", "function_call", "function_call_output" // Message fields (type="" or "message") Role string `json:"role,omitempty"` @@ -88,7 +89,7 @@ type ResponsesResponse struct { // ResponsesOutputItem represents an item in the output array. type ResponsesOutputItem struct { ID string `json:"id"` - Type string `json:"type"` // "message", "function_call", etc. + Type string `json:"type"` // "message", "reasoning", "function_call", etc. Role string `json:"role,omitempty"` Status string `json:"status,omitempty"` CallID string `json:"call_id,omitempty"` @@ -99,8 +100,9 @@ type ResponsesOutputItem struct { // ResponsesContentItem represents a content item in the output. type ResponsesContentItem struct { - Type string `json:"type"` // "output_text", "input_image", "input_audio", etc. + Type string `json:"type"` // "output_text", "reasoning_text", "input_image", "input_audio", etc. Text string `json:"text,omitempty"` + Signature string `json:"signature,omitempty"` ImageURL *ImageURLContent `json:"image_url,omitempty"` InputAudio *InputAudioContent `json:"input_audio,omitempty"` // Providers can return structured annotation objects here (for example diff --git a/internal/core/responses_json.go b/internal/core/responses_json.go index f2a4a645..576801ff 100644 --- a/internal/core/responses_json.go +++ b/internal/core/responses_json.go @@ -151,6 +151,18 @@ func (e *ResponsesInputElement) UnmarshalJSON(data []byte) error { if v, ok := raw["output"]; ok { e.Output = stringifyRawValue(v) } + case "reasoning": + if v, ok := raw["status"]; ok { + _ = json.Unmarshal(v, &e.Status) + } + if v, ok := raw["content"]; ok { + trimmed := bytes.TrimSpace(v) + if len(trimmed) != 0 && !bytes.Equal(trimmed, []byte("null")) { + var content any + _ = json.Unmarshal(trimmed, &content) + e.Content = content + } + } default: // message (type="" or "message") if v, ok := raw["role"]; ok { _ = json.Unmarshal(v, &e.Role) @@ -174,6 +186,8 @@ func (e *ResponsesInputElement) UnmarshalJSON(data []byte) error { knownFields = append(knownFields, "call_id", "id", "name", "arguments", "status") case "function_call_output": knownFields = append(knownFields, "call_id", "status", "output") + case "reasoning": + knownFields = append(knownFields, "status", "content") default: knownFields = append(knownFields, "role", "status", "content") } @@ -216,6 +230,16 @@ func (e ResponsesInputElement) MarshalJSON() ([]byte, error) { Output: e.Output, Status: e.Status, }, e.ExtraFields) + case "reasoning": + return marshalWithUnknownJSONFields(struct { + Type string `json:"type"` + Content any `json:"content,omitempty"` + Status string `json:"status,omitempty"` + }{ + Type: "reasoning", + Content: e.Content, + Status: e.Status, + }, e.ExtraFields) default: // message type msg struct { Type string `json:"type,omitempty"` diff --git a/internal/core/responses_json_test.go b/internal/core/responses_json_test.go index 5976c730..d6bf357b 100644 --- a/internal/core/responses_json_test.go +++ b/internal/core/responses_json_test.go @@ -61,6 +61,35 @@ func TestResponsesRequestUnmarshalJSON_ArrayInputFunctionCall(t *testing.T) { } } +func TestResponsesRequestUnmarshalJSON_ArrayInputReasoning(t *testing.T) { + var req ResponsesRequest + if err := json.Unmarshal([]byte(`{"model":"gpt-4o-mini","input":[ + {"type":"reasoning","status":"completed","content":[{"type":"reasoning_text","text":"Let me think.","signature":"sig_123"}]}, + {"type":"message","role":"assistant","content":"Hello"} + ]}`), &req); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + input, ok := req.Input.([]ResponsesInputElement) + if !ok || len(input) != 2 { + t.Fatalf("Input = %#v, want []ResponsesInputElement len=2", req.Input) + } + if input[0].Type != "reasoning" || input[0].Status != "completed" { + t.Fatalf("Input[0] = %+v, want reasoning/completed", input[0]) + } + content, ok := input[0].Content.([]any) + if !ok || len(content) != 1 { + t.Fatalf("Input[0].Content = %#v, want []any len=1", input[0].Content) + } + part, ok := content[0].(map[string]any) + if !ok { + t.Fatalf("Input[0].Content[0] = %#v, want object", content[0]) + } + if part["signature"] != "sig_123" { + t.Fatalf("Input[0].Content[0].signature = %#v, want sig_123", part["signature"]) + } +} + func TestResponsesRequestUnmarshalJSON_FunctionCallAcceptsIDField(t *testing.T) { var req ResponsesRequest if err := json.Unmarshal([]byte(`{"model":"gpt-4o-mini","input":[ @@ -515,6 +544,48 @@ func TestResponsesInputElementMarshalJSON_FunctionCallOutput(t *testing.T) { } } +func TestResponsesInputElementMarshalJSON_Reasoning(t *testing.T) { + elem := ResponsesInputElement{ + Type: "reasoning", + Content: []any{ + map[string]any{ + "type": "reasoning_text", + "text": "Let me think.", + "signature": "sig_123", + }, + }, + Status: "completed", + } + + body, err := json.Marshal(elem) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded["type"] != "reasoning" { + t.Fatalf("type = %v, want reasoning", decoded["type"]) + } + if decoded["status"] != "completed" { + t.Fatalf("status = %v, want completed", decoded["status"]) + } + content, ok := decoded["content"].([]any) + if !ok || len(content) != 1 { + t.Fatalf("content = %#v, want []any len=1", decoded["content"]) + } + part, ok := content[0].(map[string]any) + if !ok { + t.Fatalf("content[0] = %#v, want object", content[0]) + } + if part["signature"] != "sig_123" { + t.Fatalf("content[0].signature = %#v, want sig_123", part["signature"]) + } +} + func TestResponsesInputElementRoundTrip(t *testing.T) { original := `{"model":"gpt-4o-mini","input":[ {"role":"user","content":"What is the weather?"}, diff --git a/internal/providers/anthropic/anthropic.go b/internal/providers/anthropic/anthropic.go index ed9d8e63..5154574a 100644 --- a/internal/providers/anthropic/anthropic.go +++ b/internal/providers/anthropic/anthropic.go @@ -255,6 +255,8 @@ type anthropicMessage struct { type anthropicContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` + Signature string `json:"signature,omitempty"` ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Input any `json:"input,omitempty"` @@ -401,7 +403,7 @@ func normalizeEffort(effort string) string { // convertFromAnthropicResponse converts Anthropic response to core.ChatResponse func convertFromAnthropicResponse(resp *anthropicResponse) *core.ChatResponse { content := extractTextContent(resp.Content) - thinking := extractThinkingContent(resp.Content) + reasoningDetails := extractAnthropicReasoningDetails(resp.Content) toolCalls := extractToolCalls(resp.Content) finishReason := normalizeAnthropicStopReason(resp.StopReason) @@ -426,14 +428,8 @@ func convertFromAnthropicResponse(resp *anthropicResponse) *core.ChatResponse { ToolCalls: toolCalls, } - // Surface thinking content as reasoning_content (OpenAI-compatible format). - if thinking != "" { - raw, err := json.Marshal(thinking) - if err == nil { - msg.ExtraFields = core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ - "reasoning_content": raw, - }) - } + if extraFields := buildAnthropicReasoningExtraFields(reasoningDetails); !extraFields.IsEmpty() { + msg.ExtraFields = extraFields } return &core.ChatResponse{ @@ -495,18 +491,19 @@ func (p *Provider) StreamChatCompletion(ctx context.Context, req *core.ChatReque // streamConverter wraps an Anthropic stream and converts it to OpenAI format type streamConverter struct { - reader *bufio.Reader - body io.ReadCloser - model string - msgID string - nextToolCallIndex int - toolCalls map[int]*streamToolCallState - thinkingBlocks map[int]bool // tracks which content block indices are thinking blocks - usage anthropicUsage - hasUsage bool - buffer []byte - closed bool - emittedToolCalls bool + reader *bufio.Reader + body io.ReadCloser + model string + emitReasoningSignatures bool + msgID string + nextToolCallIndex int + toolCalls map[int]*streamToolCallState + thinkingBlocks map[int]bool // tracks which content block indices are thinking blocks + usage anthropicUsage + hasUsage bool + buffer []byte + closed bool + emittedToolCalls bool } type streamToolCallState struct { @@ -520,12 +517,13 @@ type streamToolCallState struct { func newStreamConverter(body io.ReadCloser, model string) *streamConverter { return &streamConverter{ - reader: bufio.NewReader(body), - body: body, - model: model, - toolCalls: make(map[int]*streamToolCallState), - thinkingBlocks: make(map[int]bool), - buffer: make([]byte, 0, 1024), + reader: bufio.NewReader(body), + body: body, + model: model, + emitReasoningSignatures: anthropicThinkingSignaturesCompatEnabled(), + toolCalls: make(map[int]*streamToolCallState), + thinkingBlocks: make(map[int]bool), + buffer: make([]byte, 0, 1024), } } @@ -798,13 +796,21 @@ func (sc *streamConverter) convertEvent(event *anthropicStreamEvent) string { switch event.Delta.Type { case "thinking_delta": if sc.thinkingBlocks[event.Index] && event.Delta.Thinking != "" { - return sc.formatChatChunk(map[string]any{ + delta := map[string]any{ "reasoning_content": event.Delta.Thinking, - }, nil, nil) + } + if sc.emitReasoningSignatures { + delta["reasoning_index"] = event.Index + } + return sc.formatChatChunk(delta, nil, nil) } case "signature_delta": - // Signature deltas are internal to Anthropic's thinking protocol; - // no OpenAI-compatible equivalent to emit. + if sc.emitReasoningSignatures && sc.thinkingBlocks[event.Index] && event.Delta.Signature != "" { + return sc.formatChatChunk(map[string]any{ + "reasoning_signature": event.Delta.Signature, + "reasoning_index": event.Index, + }, nil, nil) + } return "" case "text_delta": if event.Delta.Text != "" { @@ -1020,6 +1026,7 @@ func extractToolCalls(blocks []anthropicContent) []core.ToolCall { // convertAnthropicResponseToResponses converts an Anthropic response to ResponsesResponse func convertAnthropicResponseToResponses(resp *anthropicResponse, model string) *core.ResponsesResponse { content := extractTextContent(resp.Content) + reasoningDetails := extractAnthropicReasoningDetails(resp.Content) toolCalls := extractToolCalls(resp.Content) msg := core.Message{ @@ -1031,6 +1038,9 @@ func convertAnthropicResponseToResponses(resp *anthropicResponse, model string) Content: msg.Content, ToolCalls: msg.ToolCalls, }) + if reasoningItem := buildAnthropicResponsesReasoningItem(reasoningDetails); reasoningItem != nil { + output = append([]core.ResponsesOutputItem{*reasoningItem}, output...) + } return &core.ResponsesResponse{ ID: resp.ID, @@ -1468,32 +1478,49 @@ func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesReque // responsesStreamConverter wraps an Anthropic stream and converts it to Responses API format type responsesStreamConverter struct { - reader *bufio.Reader - body io.ReadCloser - model string - responseID string - output *providers.ResponsesOutputEventState - nextOutputIndex int - toolCalls map[int]*providers.ResponsesOutputToolCallState - thinkingBlocks map[int]bool // tracks which content block indices are thinking blocks - buffer []byte - closed bool - sentDone bool - usage anthropicUsage - hasUsage bool + reader *bufio.Reader + body io.ReadCloser + model string + responseID string + output *providers.ResponsesOutputEventState + nextOutputIndex int + assistantOutputIndex int + toolCalls map[int]*providers.ResponsesOutputToolCallState + thinkingContentIndices map[int]int + reasoning *responsesReasoningState + emitReasoningSignatures bool + buffer []byte + closed bool + sentDone bool + usage anthropicUsage + hasUsage bool +} + +type responsesReasoningState struct { + ItemID string + OutputIndex int + Started bool + Done bool + Blocks []responsesReasoningBlockState +} + +type responsesReasoningBlockState struct { + Text strings.Builder + Signature string } func newResponsesStreamConverter(body io.ReadCloser, model string) *responsesStreamConverter { responseID := "resp_" + uuid.New().String() return &responsesStreamConverter{ - reader: bufio.NewReader(body), - body: body, - model: model, - responseID: responseID, - output: providers.NewResponsesOutputEventState(responseID), - toolCalls: make(map[int]*providers.ResponsesOutputToolCallState), - thinkingBlocks: make(map[int]bool), - buffer: make([]byte, 0, 1024), + reader: bufio.NewReader(body), + body: body, + model: model, + responseID: responseID, + output: providers.NewResponsesOutputEventState(responseID), + toolCalls: make(map[int]*providers.ResponsesOutputToolCallState), + thinkingContentIndices: make(map[int]int), + emitReasoningSignatures: anthropicThinkingSignaturesCompatEnabled(), + buffer: make([]byte, 0, 1024), } } @@ -1517,7 +1544,7 @@ func (sc *responsesStreamConverter) Read(p []byte) (n int, err error) { // Send final done event and [DONE] message if !sc.sentDone { sc.sentDone = true - prefix := sc.output.CompleteAssistantOutput(0) + prefix := sc.completeReasoningOutputIfNeeded() + sc.output.CompleteAssistantOutput(sc.assistantOutputIndex) responseData := map[string]any{ "id": sc.responseID, "object": "response", @@ -1579,6 +1606,7 @@ func (sc *responsesStreamConverter) reserveAssistantMessageOutput() { return } sc.output.ReserveAssistant() + sc.assistantOutputIndex = sc.nextOutputIndex sc.nextOutputIndex++ } @@ -1600,6 +1628,124 @@ func (sc *responsesStreamConverter) newResponsesToolCallState(contentBlock *anth return state } +func (sc *responsesStreamConverter) ensureResponsesReasoningState() *responsesReasoningState { + if sc.reasoning != nil { + return sc.reasoning + } + sc.reasoning = &responsesReasoningState{ + ItemID: "rs_" + uuid.New().String(), + OutputIndex: sc.nextOutputIndex, + } + sc.nextOutputIndex++ + return sc.reasoning +} + +func (sc *responsesStreamConverter) startReasoningOutput() string { + state := sc.ensureResponsesReasoningState() + if state.Started { + return "" + } + state.Started = true + return sc.output.WriteEvent("response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "item": map[string]any{ + "id": state.ItemID, + "type": "reasoning", + "status": "in_progress", + }, + "output_index": state.OutputIndex, + }) +} + +func (sc *responsesStreamConverter) reasoningContentIndex(anthropicIndex int) int { + if contentIndex, ok := sc.thinkingContentIndices[anthropicIndex]; ok { + return contentIndex + } + state := sc.ensureResponsesReasoningState() + contentIndex := len(state.Blocks) + state.Blocks = append(state.Blocks, responsesReasoningBlockState{}) + sc.thinkingContentIndices[anthropicIndex] = contentIndex + return contentIndex +} + +func (sc *responsesStreamConverter) appendReasoningText(anthropicIndex int, text string) { + if text == "" { + return + } + state := sc.ensureResponsesReasoningState() + contentIndex := sc.reasoningContentIndex(anthropicIndex) + if contentIndex >= len(state.Blocks) { + return + } + _, _ = state.Blocks[contentIndex].Text.WriteString(text) +} + +func (sc *responsesStreamConverter) setReasoningSignature(anthropicIndex int, signature string) { + if signature == "" { + return + } + state := sc.ensureResponsesReasoningState() + contentIndex := sc.reasoningContentIndex(anthropicIndex) + if contentIndex >= len(state.Blocks) { + return + } + state.Blocks[contentIndex].Signature = signature +} + +func (sc *responsesStreamConverter) reasoningTextDoneEvent(anthropicIndex int) string { + state := sc.reasoning + if state == nil { + return "" + } + contentIndex, ok := sc.thinkingContentIndices[anthropicIndex] + if !ok || contentIndex >= len(state.Blocks) { + return "" + } + block := state.Blocks[contentIndex] + payload := map[string]any{ + "type": "response.reasoning_text.done", + "item_id": state.ItemID, + "output_index": state.OutputIndex, + "content_index": contentIndex, + "text": block.Text.String(), + } + if sc.emitReasoningSignatures && strings.TrimSpace(block.Signature) != "" { + payload["signature"] = block.Signature + } + return sc.output.WriteEvent("response.reasoning_text.done", payload) +} + +func (sc *responsesStreamConverter) completeReasoningOutputIfNeeded() string { + state := sc.reasoning + if state == nil || !state.Started || state.Done { + return "" + } + + content := make([]map[string]any, 0, len(state.Blocks)) + for _, block := range state.Blocks { + part := map[string]any{ + "type": "reasoning_text", + "text": block.Text.String(), + } + if sc.emitReasoningSignatures && strings.TrimSpace(block.Signature) != "" { + part["signature"] = block.Signature + } + content = append(content, part) + } + + state.Done = true + return sc.output.WriteEvent("response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "item": map[string]any{ + "id": state.ItemID, + "type": "reasoning", + "status": "completed", + "content": content, + }, + "output_index": state.OutputIndex, + }) +} + func (sc *responsesStreamConverter) convertEvent(event *anthropicStreamEvent) string { switch event.Type { case "message_start": @@ -1632,19 +1778,23 @@ func (sc *responsesStreamConverter) convertEvent(event *anthropicStreamEvent) st case "content_block_start": if event.ContentBlock != nil && event.ContentBlock.Type == "thinking" { - sc.thinkingBlocks[event.Index] = true - return "" + if !sc.emitReasoningSignatures { + return "" + } + sc.reasoningContentIndex(event.Index) + return sc.startReasoningOutput() } if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" { + prefix := sc.completeReasoningOutputIfNeeded() if sc.output.AssistantStarted() && !sc.output.AssistantDone() { - prefix := sc.output.CompleteAssistantOutput(0) + prefix += sc.output.CompleteAssistantOutput(sc.assistantOutputIndex) state := sc.newResponsesToolCallState(event.ContentBlock) sc.toolCalls[event.Index] = state return prefix + sc.output.StartToolCall(state, true) } state := sc.newResponsesToolCallState(event.ContentBlock) sc.toolCalls[event.Index] = state - return sc.output.StartToolCall(state, true) + return prefix + sc.output.StartToolCall(state, true) } return "" @@ -1654,14 +1804,30 @@ func (sc *responsesStreamConverter) convertEvent(event *anthropicStreamEvent) st } switch event.Delta.Type { - case "thinking_delta", "signature_delta": - // Thinking and signature deltas are part of Anthropic's extended thinking; - // the Responses API format does not have a direct equivalent, so skip them. + case "thinking_delta": + if !sc.emitReasoningSignatures || event.Delta.Thinking == "" { + return "" + } + prefix := sc.startReasoningOutput() + sc.appendReasoningText(event.Index, event.Delta.Thinking) + contentIndex := sc.reasoningContentIndex(event.Index) + return prefix + sc.output.WriteEvent("response.reasoning_text.delta", map[string]any{ + "type": "response.reasoning_text.delta", + "item_id": sc.reasoning.ItemID, + "output_index": sc.reasoning.OutputIndex, + "content_index": contentIndex, + "delta": event.Delta.Thinking, + }) + case "signature_delta": + if !sc.emitReasoningSignatures || event.Delta.Signature == "" { + return "" + } + sc.setReasoningSignature(event.Index, event.Delta.Signature) return "" case "text_delta": if event.Delta.Text != "" { sc.reserveAssistantMessageOutput() - prefix := sc.output.StartAssistantOutput(0) + prefix := sc.completeReasoningOutputIfNeeded() + sc.output.StartAssistantOutput(sc.assistantOutputIndex) sc.output.AppendAssistantText(event.Delta.Text) deltaEvent := map[string]any{ "type": "response.output_text.delta", @@ -1697,6 +1863,11 @@ func (sc *responsesStreamConverter) convertEvent(event *anthropicStreamEvent) st return "" case "content_block_stop": + if sc.emitReasoningSignatures { + if _, ok := sc.thinkingContentIndices[event.Index]; ok { + return sc.reasoningTextDoneEvent(event.Index) + } + } state := sc.toolCalls[event.Index] return sc.output.CompleteToolCall(state, true) diff --git a/internal/providers/anthropic/anthropic_test.go b/internal/providers/anthropic/anthropic_test.go index 4e616042..0c32d6f9 100644 --- a/internal/providers/anthropic/anthropic_test.go +++ b/internal/providers/anthropic/anthropic_test.go @@ -553,6 +553,69 @@ data: {"type":"message_stop"} } } +func TestStreamChatCompletion_EmitsReasoningSignatureBehindFeatureFlag(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`event: message_start +data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_123"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello"}} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} +`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + body, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{ + Model: "claude-sonnet-4-5-20250929", + Messages: []core.Message{ + {Role: "user", Content: "Hello"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { _ = body.Close() }() + + raw, err := io.ReadAll(body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + responseStr := string(raw) + if !strings.Contains(responseStr, `"reasoning_signature":"sig_123"`) { + t.Fatalf("expected reasoning_signature delta, got %q", responseStr) + } + if !strings.Contains(responseStr, `"reasoning_index":0`) { + t.Fatalf("expected reasoning_index delta, got %q", responseStr) + } +} + func TestStreamChatCompletion_WithToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -2005,6 +2068,46 @@ func TestConvertFromAnthropicResponse_WithThinkingBlocks(t *testing.T) { } } +func TestConvertFromAnthropicResponse_PreservesThinkingSignaturesBehindFeatureFlag(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") + + resp := &anthropicResponse{ + ID: "msg_reasoning", + Type: "message", + Role: "assistant", + Model: "claude-sonnet-4-5-20250929", + Content: []anthropicContent{ + {Type: "thinking", Thinking: "Let me think.", Signature: "sig_123"}, + {Type: "text", Text: "Hello"}, + }, + StopReason: "end_turn", + Usage: anthropicUsage{ + InputTokens: 10, + OutputTokens: 2, + }, + } + + result := convertFromAnthropicResponse(resp) + if len(result.Choices) != 1 { + t.Fatalf("len(Choices) = %d, want 1", len(result.Choices)) + } + + if got := result.Choices[0].Message.ExtraFields.Lookup("reasoning_signature"); string(got) != `"sig_123"` { + t.Fatalf("reasoning_signature = %s, want %q", got, `"sig_123"`) + } + + var details []openAIReasoningDetail + if err := json.Unmarshal(result.Choices[0].Message.ExtraFields.Lookup("reasoning_details"), &details); err != nil { + t.Fatalf("failed to unmarshal reasoning_details: %v", err) + } + if len(details) != 1 { + t.Fatalf("len(reasoning_details) = %d, want 1", len(details)) + } + if details[0].Type != "reasoning_text" || details[0].Text != "Let me think." || details[0].Signature != "sig_123" { + t.Fatalf("reasoning_details[0] = %+v, want reasoning_text/Let me think./sig_123", details[0]) + } +} + func TestExtractTextContent(t *testing.T) { tests := []struct { name string @@ -2538,6 +2641,105 @@ data: {"type":"message_stop"} } } +func TestStreamResponses_EmitsReasoningSignaturesBehindFeatureFlag(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`event: message_start +data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_123"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} +`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + body, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ + Model: "claude-sonnet-4-5-20250929", + Input: "Hello", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { _ = body.Close() }() + + raw, err := io.ReadAll(body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + events := parseTestSSEEvents(t, string(raw)) + foundReasoningDelta := false + foundReasoningDone := false + foundReasoningItemDone := false + + for _, event := range events { + if event.Done { + continue + } + switch event.Name { + case "response.reasoning_text.delta": + if event.Payload["delta"] == "Let me think." { + foundReasoningDelta = true + } + case "response.reasoning_text.done": + if event.Payload["signature"] == "sig_123" { + foundReasoningDone = true + } + case "response.output_item.done": + item, _ := event.Payload["item"].(map[string]any) + if item["type"] != "reasoning" { + continue + } + content, _ := item["content"].([]any) + if len(content) == 1 { + part, _ := content[0].(map[string]any) + if part["signature"] == "sig_123" { + foundReasoningItemDone = true + } + } + } + } + + if !foundReasoningDelta { + t.Fatal("expected response.reasoning_text.delta event") + } + if !foundReasoningDone { + t.Fatal("expected response.reasoning_text.done event with signature") + } + if !foundReasoningItemDone { + t.Fatal("expected response.output_item.done reasoning item with signature") + } +} + func TestStreamResponses_WithToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -3455,6 +3657,85 @@ func TestConvertAnthropicResponseToResponses_WithThinkingBlocks(t *testing.T) { } } +func TestConvertAnthropicResponseToResponses_PreservesThinkingSignaturesBehindFeatureFlag(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") + + resp := &anthropicResponse{ + ID: "msg_reasoning_resp", + Type: "message", + Role: "assistant", + Model: "claude-sonnet-4-5-20250929", + Content: []anthropicContent{ + {Type: "thinking", Thinking: "Let me think.", Signature: "sig_123"}, + {Type: "text", Text: "Hello"}, + }, + StopReason: "end_turn", + Usage: anthropicUsage{ + InputTokens: 10, + OutputTokens: 2, + }, + } + + result := convertAnthropicResponseToResponses(resp, "claude-sonnet-4-5-20250929") + if len(result.Output) != 2 { + t.Fatalf("len(Output) = %d, want 2", len(result.Output)) + } + if result.Output[0].Type != "reasoning" { + t.Fatalf("Output[0].Type = %q, want reasoning", result.Output[0].Type) + } + if len(result.Output[0].Content) != 1 { + t.Fatalf("len(Output[0].Content) = %d, want 1", len(result.Output[0].Content)) + } + if result.Output[0].Content[0].Type != "reasoning_text" { + t.Fatalf("Output[0].Content[0].Type = %q, want reasoning_text", result.Output[0].Content[0].Type) + } + if result.Output[0].Content[0].Signature != "sig_123" { + t.Fatalf("Output[0].Content[0].Signature = %q, want sig_123", result.Output[0].Content[0].Signature) + } + if result.Output[1].Type != "message" || result.Output[1].Content[0].Text != "Hello" { + t.Fatalf("unexpected message output: %+v", result.Output[1]) + } +} + +func TestConvertToAnthropicRequest_PreservesReasoningDetailsBehindFeatureFlag(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") + + req := &core.ChatRequest{ + Model: "claude-sonnet-4-5-20250929", + Messages: []core.Message{ + { + Role: "assistant", + Content: "Hello", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_details": json.RawMessage(`[{"type":"reasoning_text","text":"Let me think.","signature":"sig_123"}]`), + }), + }, + }, + } + + result, err := convertToAnthropicRequest(req) + if err != nil { + t.Fatalf("convertToAnthropicRequest() error = %v", err) + } + + if len(result.Messages) != 1 { + t.Fatalf("len(Messages) = %d, want 1", len(result.Messages)) + } + blocks, ok := result.Messages[0].Content.([]anthropicContentBlock) + if !ok { + t.Fatalf("content = %#v, want []anthropicContentBlock", result.Messages[0].Content) + } + if len(blocks) != 2 { + t.Fatalf("len(blocks) = %d, want 2", len(blocks)) + } + if blocks[0].Type != "thinking" || blocks[0].Thinking != "Let me think." || blocks[0].Signature != "sig_123" { + t.Fatalf("blocks[0] = %+v, want thinking block with signature", blocks[0]) + } + if blocks[1].Type != "text" || blocks[1].Text != "Hello" { + t.Fatalf("blocks[1] = %+v, want text block", blocks[1]) + } +} + func TestConvertToAnthropicRequest_ReasoningEffort(t *testing.T) { tests := []struct { name string diff --git a/internal/providers/anthropic/reasoning_compat.go b/internal/providers/anthropic/reasoning_compat.go new file mode 100644 index 00000000..03aee445 --- /dev/null +++ b/internal/providers/anthropic/reasoning_compat.go @@ -0,0 +1,210 @@ +package anthropic + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/google/uuid" + + "gomodel/internal/core" +) + +const openAICompatBreakingAnthropicThinkingSignaturesEnv = "OPENAI_COMPAT_BREAKING_ANTHROPIC_THINKING_SIGNATURES" + +type openAIReasoningDetail struct { + Type string `json:"type"` + Text string `json:"text"` + Signature string `json:"signature,omitempty"` +} + +func anthropicThinkingSignaturesCompatEnabled() bool { + value, ok := os.LookupEnv(openAICompatBreakingAnthropicThinkingSignaturesEnv) + if !ok { + return false + } + + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func extractAnthropicReasoningDetails(blocks []anthropicContent) []openAIReasoningDetail { + details := make([]openAIReasoningDetail, 0, len(blocks)) + for _, block := range blocks { + if block.Type != "thinking" || block.Thinking == "" { + continue + } + details = append(details, openAIReasoningDetail{ + Type: "reasoning_text", + Text: block.Thinking, + Signature: block.Signature, + }) + } + if len(details) == 0 { + return nil + } + return details +} + +func buildAnthropicReasoningExtraFields(details []openAIReasoningDetail) core.UnknownJSONFields { + if len(details) == 0 { + return core.UnknownJSONFields{} + } + + fields := map[string]json.RawMessage{} + + reasoningContent, err := json.Marshal(joinAnthropicReasoningText(details)) + if err == nil { + fields["reasoning_content"] = reasoningContent + } + + if anthropicThinkingSignaturesCompatEnabled() { + reasoningDetails, marshalErr := json.Marshal(details) + if marshalErr == nil { + fields["reasoning_details"] = reasoningDetails + } + if len(details) == 1 && strings.TrimSpace(details[0].Signature) != "" { + reasoningSignature, marshalErr := json.Marshal(details[0].Signature) + if marshalErr == nil { + fields["reasoning_signature"] = reasoningSignature + } + } + } + + if len(fields) == 0 { + return core.UnknownJSONFields{} + } + return core.UnknownJSONFieldsFromMap(fields) +} + +func joinAnthropicReasoningText(details []openAIReasoningDetail) string { + var builder strings.Builder + for _, detail := range details { + if detail.Text == "" { + continue + } + if builder.Len() > 0 { + builder.WriteString("\n\n") + } + builder.WriteString(detail.Text) + } + return builder.String() +} + +func buildAnthropicResponsesReasoningItem(details []openAIReasoningDetail) *core.ResponsesOutputItem { + if !anthropicThinkingSignaturesCompatEnabled() || len(details) == 0 { + return nil + } + + content := make([]core.ResponsesContentItem, 0, len(details)) + for _, detail := range details { + content = append(content, core.ResponsesContentItem{ + Type: "reasoning_text", + Text: detail.Text, + Signature: detail.Signature, + }) + } + + return &core.ResponsesOutputItem{ + ID: "rs_" + uuid.New().String(), + Type: "reasoning", + Status: "completed", + Content: content, + } +} + +func extractAnthropicReasoningBlocksFromExtraFields(fields core.UnknownJSONFields) ([]anthropicContentBlock, error) { + if !anthropicThinkingSignaturesCompatEnabled() || fields.IsEmpty() { + return nil, nil + } + + if raw := fields.Lookup("reasoning_details"); len(raw) > 0 { + details, err := parseOpenAIReasoningDetails(raw) + if err != nil { + return nil, core.NewInvalidRequestError("message.reasoning_details must be an array of reasoning_text objects", err) + } + return openAIReasoningDetailsToAnthropicBlocks(details), nil + } + + reasoningContentRaw := fields.Lookup("reasoning_content") + reasoningSignatureRaw := fields.Lookup("reasoning_signature") + if len(reasoningContentRaw) == 0 || len(reasoningSignatureRaw) == 0 { + return nil, nil + } + + var reasoningContent string + if err := json.Unmarshal(reasoningContentRaw, &reasoningContent); err != nil { + return nil, core.NewInvalidRequestError("message.reasoning_content must be a string", err) + } + + var reasoningSignature string + if err := json.Unmarshal(reasoningSignatureRaw, &reasoningSignature); err != nil { + return nil, core.NewInvalidRequestError("message.reasoning_signature must be a string", err) + } + + if strings.TrimSpace(reasoningContent) == "" || strings.TrimSpace(reasoningSignature) == "" { + return nil, nil + } + + return []anthropicContentBlock{{ + Type: "thinking", + Thinking: reasoningContent, + Signature: reasoningSignature, + }}, nil +} + +func parseOpenAIReasoningDetails(raw json.RawMessage) ([]openAIReasoningDetail, error) { + var details []openAIReasoningDetail + if err := json.Unmarshal(raw, &details); err != nil { + return nil, err + } + + normalized := make([]openAIReasoningDetail, 0, len(details)) + for _, detail := range details { + if strings.TrimSpace(detail.Text) == "" { + continue + } + + detailType := strings.TrimSpace(detail.Type) + if detailType == "" { + detailType = "reasoning_text" + } + if detailType != "reasoning_text" && detailType != "thinking" { + return nil, fmt.Errorf("unsupported reasoning_details type %q", detailType) + } + + normalized = append(normalized, openAIReasoningDetail{ + Type: "reasoning_text", + Text: detail.Text, + Signature: strings.TrimSpace(detail.Signature), + }) + } + + if len(normalized) == 0 { + return nil, nil + } + return normalized, nil +} + +func openAIReasoningDetailsToAnthropicBlocks(details []openAIReasoningDetail) []anthropicContentBlock { + blocks := make([]anthropicContentBlock, 0, len(details)) + for _, detail := range details { + if strings.TrimSpace(detail.Text) == "" { + continue + } + blocks = append(blocks, anthropicContentBlock{ + Type: "thinking", + Thinking: detail.Text, + Signature: detail.Signature, + }) + } + if len(blocks) == 0 { + return nil + } + return blocks +} diff --git a/internal/providers/anthropic/request_translation.go b/internal/providers/anthropic/request_translation.go index 2b2690a3..05a4c093 100644 --- a/internal/providers/anthropic/request_translation.go +++ b/internal/providers/anthropic/request_translation.go @@ -204,18 +204,43 @@ func buildAnthropicMessageContent(msg core.Message) (any, error) { }, nil } + reasoningBlocks, err := extractAnthropicReasoningBlocksFromExtraFields(msg.ExtraFields) + if err != nil { + return nil, err + } + content, err := convertMessageContentToAnthropic(msg.Content) if err != nil { return nil, err } if len(msg.ToolCalls) == 0 { - return content, nil + if len(reasoningBlocks) == 0 { + return content, nil + } + blocks := make([]anthropicContentBlock, 0, len(reasoningBlocks)+1) + blocks = append(blocks, reasoningBlocks...) + switch c := content.(type) { + case string: + if strings.TrimSpace(c) != "" { + blocks = append(blocks, anthropicContentBlock{ + Type: "text", + Text: c, + }) + } + case []anthropicContentBlock: + blocks = append(blocks, c...) + } + if len(blocks) == 0 { + return "", nil + } + return blocks, nil } if len(msg.ToolCalls) > maxToolCallsPerMessage { return nil, core.NewInvalidRequestError("too many tool calls in message", nil) } - blocks := make([]anthropicContentBlock, 0, len(msg.ToolCalls)+1) + blocks := make([]anthropicContentBlock, 0, len(reasoningBlocks)+len(msg.ToolCalls)+1) + blocks = append(blocks, reasoningBlocks...) switch c := content.(type) { case string: if strings.TrimSpace(c) != "" { diff --git a/internal/providers/responses_adapter.go b/internal/providers/responses_adapter.go index f4e3f1d5..8ab007df 100644 --- a/internal/providers/responses_adapter.go +++ b/internal/providers/responses_adapter.go @@ -146,6 +146,144 @@ func cloneStringAnyMap(src map[string]any) map[string]any { return dst } +type responsesReasoningDetail struct { + Type string `json:"type"` + Text string `json:"text"` + Signature string `json:"signature,omitempty"` +} + +func buildResponsesReasoningExtraFields(content any) (core.UnknownJSONFields, error) { + details, err := parseResponsesReasoningDetails(content) + if err != nil { + return core.UnknownJSONFields{}, err + } + if len(details) == 0 { + return core.UnknownJSONFields{}, nil + } + + raw, err := json.Marshal(details) + if err != nil { + return core.UnknownJSONFields{}, err + } + return core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_details": raw, + }), nil +} + +func mergeResponsesReasoningExtraFields(base core.UnknownJSONFields, content any) (core.UnknownJSONFields, error) { + reasoningExtras, err := buildResponsesReasoningExtraFields(content) + if err != nil { + return core.UnknownJSONFields{}, err + } + return core.MergeUnknownJSONFields(base, reasoningExtras), nil +} + +func parseResponsesReasoningDetails(content any) ([]responsesReasoningDetail, error) { + switch typed := content.(type) { + case []core.ResponsesContentItem: + details := make([]responsesReasoningDetail, 0, len(typed)) + for _, part := range typed { + if part.Type != "reasoning_text" { + return nil, fmt.Errorf("reasoning content items must have type reasoning_text") + } + if strings.TrimSpace(part.Text) == "" { + continue + } + details = append(details, responsesReasoningDetail{ + Type: "reasoning_text", + Text: part.Text, + Signature: strings.TrimSpace(part.Signature), + }) + } + return details, nil + case []map[string]any: + items := make([]any, 0, len(typed)) + for _, item := range typed { + items = append(items, item) + } + return parseResponsesReasoningDetails(items) + case []any: + details := make([]responsesReasoningDetail, 0, len(typed)) + for _, item := range typed { + part, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("reasoning content items must be objects") + } + partType, _ := part["type"].(string) + if partType != "reasoning_text" { + return nil, fmt.Errorf("reasoning content items must have type reasoning_text") + } + text, _ := part["text"].(string) + if strings.TrimSpace(text) == "" { + continue + } + signature, _ := part["signature"].(string) + details = append(details, responsesReasoningDetail{ + Type: "reasoning_text", + Text: text, + Signature: strings.TrimSpace(signature), + }) + } + return details, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("reasoning content must be an array") + } +} + +func reasoningDetailsFromUnknownFields(fields core.UnknownJSONFields) []responsesReasoningDetail { + raw := fields.Lookup("reasoning_details") + if len(raw) == 0 { + return nil + } + + var details []responsesReasoningDetail + if err := json.Unmarshal(raw, &details); err != nil { + return nil + } + return details +} + +func buildReasoningUnknownFields(details []responsesReasoningDetail) core.UnknownJSONFields { + if len(details) == 0 { + return core.UnknownJSONFields{} + } + + raw, err := json.Marshal(details) + if err != nil { + return core.UnknownJSONFields{} + } + return core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_details": raw, + }) +} + +func assistantMessageContainsOnlyReasoning(msg core.Message) bool { + if msg.Role != "assistant" || len(msg.ToolCalls) != 0 { + return false + } + if core.HasStructuredContent(msg.Content) { + return false + } + if core.ExtractTextContent(msg.Content) != "" { + return false + } + return len(reasoningDetailsFromUnknownFields(msg.ExtraFields)) > 0 +} + +func mergeReasoningOnlyAssistantMessage(dst *core.Message, src core.Message) { + details := append(reasoningDetailsFromUnknownFields(dst.ExtraFields), reasoningDetailsFromUnknownFields(src.ExtraFields)...) + dst.ExtraFields = core.MergeUnknownJSONFields(dst.ExtraFields, src.ExtraFields, buildReasoningUnknownFields(details)) +} + +func mergeReasoningIntoAssistantMessage(dst *core.Message, src core.Message) { + details := reasoningDetailsFromUnknownFields(dst.ExtraFields) + merged := cloneResponsesMessage(src) + merged.ExtraFields = core.MergeUnknownJSONFields(dst.ExtraFields, src.ExtraFields, buildReasoningUnknownFields(details)) + *dst = merged +} + // ConvertResponsesInputToMessages converts a Responses API input payload into Chat API messages. func ConvertResponsesInputToMessages(input any) ([]core.Message, error) { switch in := input.(type) { @@ -191,6 +329,23 @@ func convertResponsesInputItems(items []any) ([]core.Message, error) { } if msg.Role == "assistant" { + if itemType == "reasoning" { + if pendingAssistant == nil { + assistant := cloneResponsesMessage(msg) + pendingAssistant = &assistant + } else if assistantMessageContainsOnlyReasoning(*pendingAssistant) { + mergeReasoningOnlyAssistantMessage(pendingAssistant, msg) + } else { + flushPendingAssistant() + assistant := cloneResponsesMessage(msg) + pendingAssistant = &assistant + } + continue + } + if pendingAssistant != nil && assistantMessageContainsOnlyReasoning(*pendingAssistant) { + mergeReasoningIntoAssistantMessage(pendingAssistant, msg) + continue + } if itemType == "message" { flushPendingAssistant() } @@ -268,6 +423,17 @@ func convertResponsesInputElement(item core.ResponsesInputElement, index int) (c Content: content, ExtraFields: core.CloneUnknownJSONFields(item.ExtraFields), }, "function_call_output", nil + case "reasoning": + reasoningExtras, err := mergeResponsesReasoningExtraFields(core.CloneUnknownJSONFields(item.ExtraFields), item.Content) + if err != nil { + return core.Message{}, "", core.NewInvalidRequestError(fmt.Sprintf("invalid responses input item at index %d: reasoning.content must be an array of reasoning_text items", index), err) + } + return core.Message{ + Role: "assistant", + Content: "", + ContentNull: true, + ExtraFields: reasoningExtras, + }, "reasoning", nil default: // message (type="" or "message") role := strings.TrimSpace(item.Role) if role == "" { @@ -329,6 +495,20 @@ func convertResponsesInputMap(item map[string]any, index int) (core.Message, str Content: content, ExtraFields: core.UnknownJSONFieldsFromMap(rawJSONMapFromUnknownKeys(item, "type", "call_id", "status", "output")), }, "function_call_output", nil + case "reasoning": + reasoningExtras, err := mergeResponsesReasoningExtraFields( + core.UnknownJSONFieldsFromMap(rawJSONMapFromUnknownKeys(item, "type", "status", "content")), + item["content"], + ) + if err != nil { + return core.Message{}, "", core.NewInvalidRequestError(fmt.Sprintf("invalid responses input item at index %d: reasoning.content must be an array of reasoning_text items", index), err) + } + return core.Message{ + Role: "assistant", + Content: "", + ContentNull: true, + ExtraFields: reasoningExtras, + }, "reasoning", nil } role, _ := item["role"].(string) diff --git a/internal/providers/responses_adapter_test.go b/internal/providers/responses_adapter_test.go index a0b5cd7c..d1f94dd2 100644 --- a/internal/providers/responses_adapter_test.go +++ b/internal/providers/responses_adapter_test.go @@ -422,6 +422,217 @@ func TestConvertResponsesRequestToChat_DoesNotMergeAssistantMessagesWithExtraFie } } +func TestConvertResponsesRequestToChat_MergesReasoningItemIntoFollowingAssistantMessage(t *testing.T) { + req := &core.ResponsesRequest{ + Model: "test-model", + Input: []core.ResponsesInputElement{ + { + Type: "reasoning", + Content: []any{ + map[string]any{ + "type": "reasoning_text", + "text": "Let me think.", + "signature": "sig_123", + }, + }, + Status: "completed", + }, + { + Type: "message", + Role: "assistant", + Content: "Hello", + Status: "completed", + }, + }, + } + + chatReq, err := ConvertResponsesRequestToChat(req) + if err != nil { + t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) + } + if len(chatReq.Messages) != 1 { + t.Fatalf("len(Messages) = %d, want 1", len(chatReq.Messages)) + } + if got := core.ExtractTextContent(chatReq.Messages[0].Content); got != "Hello" { + t.Fatalf("Messages[0].Content = %q, want Hello", got) + } + + raw := chatReq.Messages[0].ExtraFields.Lookup("reasoning_details") + if len(raw) == 0 { + t.Fatal("reasoning_details missing from merged assistant message") + } + + var details []map[string]any + if err := json.Unmarshal(raw, &details); err != nil { + t.Fatalf("json.Unmarshal(reasoning_details) error = %v", err) + } + if len(details) != 1 { + t.Fatalf("len(reasoning_details) = %d, want 1", len(details)) + } + if details[0]["signature"] != "sig_123" { + t.Fatalf("reasoning_details[0].signature = %#v, want sig_123", details[0]["signature"]) + } +} + +func TestConvertResponsesRequestToChat_DoesNotReplaceMultimodalAssistantAfterReasoningMerge(t *testing.T) { + req := &core.ResponsesRequest{ + Model: "test-model", + Input: []any{ + map[string]any{ + "type": "reasoning", + "status": "completed", + "content": []map[string]any{ + { + "type": "reasoning_text", + "text": "Let me inspect the image.", + "signature": "sig_123", + }, + }, + "x_trace": map[string]any{"attempt": 1}, + }, + map[string]any{ + "type": "message", + "role": "assistant", + "status": "completed", + "content": []map[string]any{ + { + "type": "input_image", + "image_url": map[string]any{"url": "https://example.com/image.png"}, + }, + }, + }, + map[string]any{ + "type": "message", + "role": "assistant", + "status": "completed", + "content": []map[string]any{ + {"type": "output_text", "text": "Here is the follow-up."}, + }, + }, + }, + } + + chatReq, err := ConvertResponsesRequestToChat(req) + if err != nil { + t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) + } + if len(chatReq.Messages) != 2 { + t.Fatalf("len(Messages) = %d, want 2", len(chatReq.Messages)) + } + + firstParts, ok := chatReq.Messages[0].Content.([]core.ContentPart) + if !ok { + t.Fatalf("Messages[0].Content type = %T, want []core.ContentPart", chatReq.Messages[0].Content) + } + if len(firstParts) != 1 || firstParts[0].ImageURL == nil || firstParts[0].ImageURL.URL != "https://example.com/image.png" { + t.Fatalf("unexpected first assistant content: %+v", firstParts) + } + if chatReq.Messages[0].ExtraFields.Lookup("reasoning_details") == nil { + t.Fatal("first assistant lost reasoning_details") + } + if chatReq.Messages[0].ExtraFields.Lookup("x_trace") == nil { + t.Fatal("first assistant lost reasoning extra fields") + } + + if got := core.ExtractTextContent(chatReq.Messages[1].Content); got != "Here is the follow-up." { + t.Fatalf("Messages[1].Content = %q, want follow-up text", got) + } +} + +func TestConvertResponsesRequestToChat_RejectsNonReasoningTextParts(t *testing.T) { + tests := []struct { + name string + input any + }{ + { + name: "map payload", + input: []any{ + map[string]any{ + "type": "reasoning", + "content": []map[string]any{ + {"type": "output_text", "text": "bad"}, + }, + }, + }, + }, + { + name: "typed payload", + input: []core.ResponsesInputElement{ + { + Type: "reasoning", + Content: []core.ResponsesContentItem{ + {Type: "output_text", Text: "bad"}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ConvertResponsesRequestToChat(&core.ResponsesRequest{ + Model: "test-model", + Input: tt.input, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "reasoning.content must be an array of reasoning_text items") { + t.Fatalf("error = %v, want reasoning validation error", err) + } + }) + } +} + +func TestConvertResponsesRequestToChat_PreservesReasoningExtrasWhenMerged(t *testing.T) { + req := &core.ResponsesRequest{ + Model: "test-model", + Input: []core.ResponsesInputElement{ + { + Type: "reasoning", + Content: []core.ResponsesContentItem{ + { + Type: "reasoning_text", + Text: "Let me think.", + Signature: "sig_123", + }, + }, + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "x_trace": json.RawMessage(`{"attempt":2}`), + }), + }, + { + Type: "message", + Role: "assistant", + Content: "Hello", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "x_assistant": json.RawMessage(`true`), + }), + }, + }, + } + + chatReq, err := ConvertResponsesRequestToChat(req) + if err != nil { + t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) + } + if len(chatReq.Messages) != 1 { + t.Fatalf("len(Messages) = %d, want 1", len(chatReq.Messages)) + } + if got := core.ExtractTextContent(chatReq.Messages[0].Content); got != "Hello" { + t.Fatalf("Messages[0].Content = %q, want Hello", got) + } + if chatReq.Messages[0].ExtraFields.Lookup("reasoning_details") == nil { + t.Fatal("reasoning_details missing after merge") + } + if chatReq.Messages[0].ExtraFields.Lookup("x_trace") == nil { + t.Fatal("typed reasoning extra missing after merge") + } + if chatReq.Messages[0].ExtraFields.Lookup("x_assistant") == nil { + t.Fatal("assistant extra missing after reasoning merge") + } +} + func TestConvertResponsesRequestToChat_RejectsWhitespaceOnlyMediaFields(t *testing.T) { tests := []struct { name string From d7263a5c1209ef9bc50735435cb9100edc316b1c Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 26 Mar 2026 13:33:10 +0100 Subject: [PATCH 2/6] fix(anthropic): tighten reasoning compatibility invariants --- internal/providers/anthropic/anthropic.go | 44 +- .../providers/anthropic/anthropic_test.go | 375 ++++++++++++++++++ .../providers/anthropic/reasoning_compat.go | 18 +- internal/providers/responses_adapter.go | 5 +- internal/providers/responses_adapter_test.go | 106 +++++ 5 files changed, 540 insertions(+), 8 deletions(-) diff --git a/internal/providers/anthropic/anthropic.go b/internal/providers/anthropic/anthropic.go index 5154574a..8098a368 100644 --- a/internal/providers/anthropic/anthropic.go +++ b/internal/providers/anthropic/anthropic.go @@ -499,11 +499,13 @@ type streamConverter struct { nextToolCallIndex int toolCalls map[int]*streamToolCallState thinkingBlocks map[int]bool // tracks which content block indices are thinking blocks + reasoningIndices map[int]int usage anthropicUsage hasUsage bool buffer []byte closed bool emittedToolCalls bool + nextReasoningIndex int } type streamToolCallState struct { @@ -523,10 +525,29 @@ func newStreamConverter(body io.ReadCloser, model string) *streamConverter { emitReasoningSignatures: anthropicThinkingSignaturesCompatEnabled(), toolCalls: make(map[int]*streamToolCallState), thinkingBlocks: make(map[int]bool), + reasoningIndices: make(map[int]int), buffer: make([]byte, 0, 1024), } } +func (sc *streamConverter) ensureReasoningIndex(anthropicIndex int) (int, bool) { + if !sc.thinkingBlocks[anthropicIndex] { + return 0, false + } + if reasoningIndex, ok := sc.reasoningIndices[anthropicIndex]; ok { + return reasoningIndex, true + } + reasoningIndex := sc.nextReasoningIndex + sc.nextReasoningIndex++ + sc.reasoningIndices[anthropicIndex] = reasoningIndex + return reasoningIndex, true +} + +func (sc *streamConverter) reasoningIndex(anthropicIndex int) (int, bool) { + reasoningIndex, ok := sc.reasoningIndices[anthropicIndex] + return reasoningIndex, ok +} + func malformedAnthropicStreamError(err error) error { return core.NewProviderError("anthropic", http.StatusBadGateway, "failed to decode anthropic stream event: "+err.Error(), err) } @@ -795,20 +816,26 @@ func (sc *streamConverter) convertEvent(event *anthropicStreamEvent) string { switch event.Delta.Type { case "thinking_delta": - if sc.thinkingBlocks[event.Index] && event.Delta.Thinking != "" { + if sc.thinkingBlocks[event.Index] && strings.TrimSpace(event.Delta.Thinking) != "" { delta := map[string]any{ "reasoning_content": event.Delta.Thinking, } if sc.emitReasoningSignatures { - delta["reasoning_index"] = event.Index + if reasoningIndex, ok := sc.ensureReasoningIndex(event.Index); ok { + delta["reasoning_index"] = reasoningIndex + } } return sc.formatChatChunk(delta, nil, nil) } case "signature_delta": - if sc.emitReasoningSignatures && sc.thinkingBlocks[event.Index] && event.Delta.Signature != "" { + if sc.emitReasoningSignatures && event.Delta.Signature != "" { + reasoningIndex, ok := sc.reasoningIndex(event.Index) + if !ok { + return "" + } return sc.formatChatChunk(map[string]any{ "reasoning_signature": event.Delta.Signature, - "reasoning_index": event.Index, + "reasoning_index": reasoningIndex, }, nil, nil) } return "" @@ -1702,6 +1729,9 @@ func (sc *responsesStreamConverter) reasoningTextDoneEvent(anthropicIndex int) s return "" } block := state.Blocks[contentIndex] + if strings.TrimSpace(block.Text.String()) == "" { + return "" + } payload := map[string]any{ "type": "response.reasoning_text.done", "item_id": state.ItemID, @@ -1723,6 +1753,9 @@ func (sc *responsesStreamConverter) completeReasoningOutputIfNeeded() string { content := make([]map[string]any, 0, len(state.Blocks)) for _, block := range state.Blocks { + if strings.TrimSpace(block.Text.String()) == "" { + continue + } part := map[string]any{ "type": "reasoning_text", "text": block.Text.String(), @@ -1732,6 +1765,9 @@ func (sc *responsesStreamConverter) completeReasoningOutputIfNeeded() string { } content = append(content, part) } + if len(content) == 0 { + return "" + } state.Done = true return sc.output.WriteEvent("response.output_item.done", map[string]any{ diff --git a/internal/providers/anthropic/anthropic_test.go b/internal/providers/anthropic/anthropic_test.go index 0c32d6f9..ae9534b7 100644 --- a/internal/providers/anthropic/anthropic_test.go +++ b/internal/providers/anthropic/anthropic_test.go @@ -616,6 +616,138 @@ data: {"type":"message_stop"} } } +func TestStreamChatCompletion_DoesNotEmitReasoningSignatureWhenFeatureFlagDisabled(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`event: message_start +data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_123"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello"}} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} +`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + body, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{ + Model: "claude-sonnet-4-5-20250929", + Messages: []core.Message{ + {Role: "user", Content: "Hello"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { _ = body.Close() }() + + raw, err := io.ReadAll(body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + responseStr := string(raw) + if strings.Contains(responseStr, `"reasoning_signature":"sig_123"`) { + t.Fatalf("did not expect reasoning_signature delta, got %q", responseStr) + } + if strings.Contains(responseStr, `"reasoning_index":0`) { + t.Fatalf("did not expect reasoning_index delta, got %q", responseStr) + } +} + +func TestStreamChatCompletion_UsesDenseReasoningIndicesBehindFeatureFlag(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`event: message_start +data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":2,"content_block":{"type":"thinking","thinking":"","signature":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"thinking_delta","thinking":"Let me think."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"signature_delta","signature":"sig_123"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":2} + +event: content_block_start +data: {"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"Hello"}} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} +`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + body, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{ + Model: "claude-sonnet-4-5-20250929", + Messages: []core.Message{ + {Role: "user", Content: "Hello"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { _ = body.Close() }() + + raw, err := io.ReadAll(body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + responseStr := string(raw) + if !strings.Contains(responseStr, `"reasoning_index":0`) { + t.Fatalf("expected dense reasoning_index 0, got %q", responseStr) + } + if strings.Contains(responseStr, `"reasoning_index":2`) { + t.Fatalf("did not expect sparse reasoning_index 2, got %q", responseStr) + } +} + func TestStreamChatCompletion_WithToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -2740,6 +2872,163 @@ data: {"type":"message_stop"} } } +func TestStreamResponses_DoesNotEmitReasoningSignaturesWhenFeatureFlagDisabled(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`event: message_start +data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_123"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} +`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + body, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ + Model: "claude-sonnet-4-5-20250929", + Input: "Hello", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { _ = body.Close() }() + + raw, err := io.ReadAll(body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + responseStr := string(raw) + if strings.Contains(responseStr, "response.reasoning_text.delta") { + t.Fatalf("did not expect reasoning delta events, got %q", responseStr) + } + if strings.Contains(responseStr, `"type":"reasoning"`) { + t.Fatalf("did not expect reasoning output items, got %q", responseStr) + } +} + +func TestStreamResponses_SkipsEmptyReasoningBlocks(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`event: message_start +data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"thinking","thinking":"","signature":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"Let me think."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"signature_delta","signature":"sig_123"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: content_block_start +data: {"type":"content_block_start","index":2,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"Hello"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":2} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} +`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + body, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ + Model: "claude-sonnet-4-5-20250929", + Input: "Hello", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { _ = body.Close() }() + + raw, err := io.ReadAll(body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + events := parseTestSSEEvents(t, string(raw)) + reasoningDoneCount := 0 + reasoningItemParts := 0 + + for _, event := range events { + if event.Done { + continue + } + switch event.Name { + case "response.reasoning_text.done": + reasoningDoneCount++ + if event.Payload["text"] != "Let me think." { + t.Fatalf("unexpected reasoning_text.done payload: %+v", event.Payload) + } + case "response.output_item.done": + item, _ := event.Payload["item"].(map[string]any) + if item["type"] != "reasoning" { + continue + } + content, _ := item["content"].([]any) + reasoningItemParts = len(content) + } + } + + if reasoningDoneCount != 1 { + t.Fatalf("reasoningTextDone count = %d, want 1", reasoningDoneCount) + } + if reasoningItemParts != 1 { + t.Fatalf("reasoning output content len = %d, want 1", reasoningItemParts) + } +} + func TestStreamResponses_WithToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -3736,6 +4025,92 @@ func TestConvertToAnthropicRequest_PreservesReasoningDetailsBehindFeatureFlag(t } } +func TestConvertToAnthropicRequest_RejectsInvalidReasoningCompatFieldsBehindFeatureFlag(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") + + tests := []struct { + name string + extraFields core.UnknownJSONFields + wantMessage string + }{ + { + name: "empty reasoning_details", + extraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_details": json.RawMessage(`[]`), + }), + wantMessage: "message.reasoning_details", + }, + { + name: "whitespace reasoning_details", + extraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_details": json.RawMessage(`[{"type":"reasoning_text","text":" ","signature":"sig_123"}]`), + }), + wantMessage: "message.reasoning_details", + }, + { + name: "missing reasoning_signature", + extraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_content": json.RawMessage(`"Let me think."`), + }), + wantMessage: "message.reasoning_content requires message.reasoning_signature", + }, + { + name: "missing reasoning_content", + extraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_signature": json.RawMessage(`"sig_123"`), + }), + wantMessage: "message.reasoning_signature requires message.reasoning_content", + }, + { + name: "blank reasoning_content", + extraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_content": json.RawMessage(`" "`), + "reasoning_signature": json.RawMessage(`"sig_123"`), + }), + wantMessage: "message.reasoning_content must be a non-empty string", + }, + { + name: "blank reasoning_signature", + extraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_content": json.RawMessage(`"Let me think."`), + "reasoning_signature": json.RawMessage(`" "`), + }), + wantMessage: "message.reasoning_signature must be a non-empty string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &core.ChatRequest{ + Model: "claude-sonnet-4-5-20250929", + Messages: []core.Message{ + { + Role: "assistant", + Content: "Hello", + ExtraFields: tt.extraFields, + }, + }, + } + + _, err := convertToAnthropicRequest(req) + if err == nil { + t.Fatal("expected error, got nil") + } + + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) { + t.Fatalf("error = %T, want *core.GatewayError", err) + } + if gatewayErr.Type != core.ErrorTypeInvalidRequest { + t.Fatalf("GatewayError.Type = %q, want %q", gatewayErr.Type, core.ErrorTypeInvalidRequest) + } + if !strings.Contains(gatewayErr.Message, tt.wantMessage) { + t.Fatalf("GatewayError.Message = %q, want substring %q", gatewayErr.Message, tt.wantMessage) + } + }) + } +} + func TestConvertToAnthropicRequest_ReasoningEffort(t *testing.T) { tests := []struct { name string diff --git a/internal/providers/anthropic/reasoning_compat.go b/internal/providers/anthropic/reasoning_compat.go index 03aee445..d2072c1b 100644 --- a/internal/providers/anthropic/reasoning_compat.go +++ b/internal/providers/anthropic/reasoning_compat.go @@ -128,14 +128,23 @@ func extractAnthropicReasoningBlocksFromExtraFields(fields core.UnknownJSONField if err != nil { return nil, core.NewInvalidRequestError("message.reasoning_details must be an array of reasoning_text objects", err) } + if len(details) == 0 { + return nil, core.NewInvalidRequestError("message.reasoning_details must contain at least one non-empty reasoning_text object", nil) + } return openAIReasoningDetailsToAnthropicBlocks(details), nil } reasoningContentRaw := fields.Lookup("reasoning_content") reasoningSignatureRaw := fields.Lookup("reasoning_signature") - if len(reasoningContentRaw) == 0 || len(reasoningSignatureRaw) == 0 { + if len(reasoningContentRaw) == 0 && len(reasoningSignatureRaw) == 0 { return nil, nil } + if len(reasoningContentRaw) == 0 { + return nil, core.NewInvalidRequestError("message.reasoning_signature requires message.reasoning_content", nil) + } + if len(reasoningSignatureRaw) == 0 { + return nil, core.NewInvalidRequestError("message.reasoning_content requires message.reasoning_signature", nil) + } var reasoningContent string if err := json.Unmarshal(reasoningContentRaw, &reasoningContent); err != nil { @@ -147,8 +156,11 @@ func extractAnthropicReasoningBlocksFromExtraFields(fields core.UnknownJSONField return nil, core.NewInvalidRequestError("message.reasoning_signature must be a string", err) } - if strings.TrimSpace(reasoningContent) == "" || strings.TrimSpace(reasoningSignature) == "" { - return nil, nil + if strings.TrimSpace(reasoningContent) == "" { + return nil, core.NewInvalidRequestError("message.reasoning_content must be a non-empty string", nil) + } + if strings.TrimSpace(reasoningSignature) == "" { + return nil, core.NewInvalidRequestError("message.reasoning_signature must be a non-empty string", nil) } return []anthropicContentBlock{{ diff --git a/internal/providers/responses_adapter.go b/internal/providers/responses_adapter.go index 8ab007df..592645ea 100644 --- a/internal/providers/responses_adapter.go +++ b/internal/providers/responses_adapter.go @@ -175,6 +175,9 @@ func mergeResponsesReasoningExtraFields(base core.UnknownJSONFields, content any if err != nil { return core.UnknownJSONFields{}, err } + if reasoningExtras.IsEmpty() { + return core.UnknownJSONFields{}, fmt.Errorf("reasoning content must include at least one non-empty reasoning_text item") + } return core.MergeUnknownJSONFields(base, reasoningExtras), nil } @@ -278,7 +281,7 @@ func mergeReasoningOnlyAssistantMessage(dst *core.Message, src core.Message) { } func mergeReasoningIntoAssistantMessage(dst *core.Message, src core.Message) { - details := reasoningDetailsFromUnknownFields(dst.ExtraFields) + details := append(reasoningDetailsFromUnknownFields(dst.ExtraFields), reasoningDetailsFromUnknownFields(src.ExtraFields)...) merged := cloneResponsesMessage(src) merged.ExtraFields = core.MergeUnknownJSONFields(dst.ExtraFields, src.ExtraFields, buildReasoningUnknownFields(details)) *dst = merged diff --git a/internal/providers/responses_adapter_test.go b/internal/providers/responses_adapter_test.go index d1f94dd2..9d16ea7e 100644 --- a/internal/providers/responses_adapter_test.go +++ b/internal/providers/responses_adapter_test.go @@ -3,6 +3,7 @@ package providers import ( "context" "encoding/json" + "errors" "io" "math" "strings" @@ -577,6 +578,13 @@ func TestConvertResponsesRequestToChat_RejectsNonReasoningTextParts(t *testing.T if err == nil { t.Fatal("expected error, got nil") } + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) { + t.Fatalf("error = %T, want *core.GatewayError", err) + } + if gatewayErr.Type != core.ErrorTypeInvalidRequest { + t.Fatalf("GatewayError.Type = %q, want %q", gatewayErr.Type, core.ErrorTypeInvalidRequest) + } if !strings.Contains(err.Error(), "reasoning.content must be an array of reasoning_text items") { t.Fatalf("error = %v, want reasoning validation error", err) } @@ -584,6 +592,104 @@ func TestConvertResponsesRequestToChat_RejectsNonReasoningTextParts(t *testing.T } } +func TestConvertResponsesRequestToChat_RejectsEmptyReasoningPayload(t *testing.T) { + tests := []struct { + name string + input any + }{ + { + name: "empty array", + input: []any{ + map[string]any{ + "type": "reasoning", + "content": []map[string]any{}, + }, + }, + }, + { + name: "whitespace-only reasoning text", + input: []any{ + map[string]any{ + "type": "reasoning", + "content": []map[string]any{ + {"type": "reasoning_text", "text": " "}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ConvertResponsesRequestToChat(&core.ResponsesRequest{ + Model: "test-model", + Input: tt.input, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) { + t.Fatalf("error = %T, want *core.GatewayError", err) + } + if gatewayErr.Type != core.ErrorTypeInvalidRequest { + t.Fatalf("GatewayError.Type = %q, want %q", gatewayErr.Type, core.ErrorTypeInvalidRequest) + } + }) + } +} + +func TestConvertResponsesRequestToChat_MergesReasoningDetailsFromPendingAndAssistantExtras(t *testing.T) { + req := &core.ResponsesRequest{ + Model: "test-model", + Input: []core.ResponsesInputElement{ + { + Type: "reasoning", + Content: []core.ResponsesContentItem{ + { + Type: "reasoning_text", + Text: "First thought.", + Signature: "sig_first", + }, + }, + }, + { + Type: "message", + Role: "assistant", + Content: "Hello", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_details": json.RawMessage(`[{"type":"reasoning_text","text":"Second thought.","signature":"sig_second"}]`), + }), + }, + }, + } + + chatReq, err := ConvertResponsesRequestToChat(req) + if err != nil { + t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) + } + if len(chatReq.Messages) != 1 { + t.Fatalf("len(Messages) = %d, want 1", len(chatReq.Messages)) + } + + raw := chatReq.Messages[0].ExtraFields.Lookup("reasoning_details") + if len(raw) == 0 { + t.Fatal("reasoning_details missing after merge") + } + + var details []map[string]any + if err := json.Unmarshal(raw, &details); err != nil { + t.Fatalf("json.Unmarshal(reasoning_details) error = %v", err) + } + if len(details) != 2 { + t.Fatalf("len(reasoning_details) = %d, want 2", len(details)) + } + if details[0]["signature"] != "sig_first" || details[1]["signature"] != "sig_second" { + t.Fatalf("reasoning_details = %+v, want both signatures preserved", details) + } +} + func TestConvertResponsesRequestToChat_PreservesReasoningExtrasWhenMerged(t *testing.T) { req := &core.ResponsesRequest{ Model: "test-model", From 26c912964adbf8895193532a27c22a1893eb43e5 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 26 Mar 2026 14:02:46 +0100 Subject: [PATCH 3/6] fix(ci): address lint and CodeQL findings --- internal/core/json_fields.go | 2 +- internal/providers/anthropic/anthropic.go | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/internal/core/json_fields.go b/internal/core/json_fields.go index 421100f4..99c5ac6b 100644 --- a/internal/core/json_fields.go +++ b/internal/core/json_fields.go @@ -85,7 +85,7 @@ func UnknownJSONFieldsFromMap(fields map[string]json.RawMessage) UnknownJSONFiel } sort.Strings(keys) - buf := bytes.NewBuffer(make([]byte, 0, len(keys)*16)) + var buf bytes.Buffer buf.WriteByte('{') for i, key := range keys { if i > 0 { diff --git a/internal/providers/anthropic/anthropic.go b/internal/providers/anthropic/anthropic.go index 8098a368..a4210888 100644 --- a/internal/providers/anthropic/anthropic.go +++ b/internal/providers/anthropic/anthropic.go @@ -998,20 +998,6 @@ func extractTextContent(blocks []anthropicContent) string { return sb.String() } -// extractThinkingContent returns the concatenated thinking text from all "thinking" content blocks. -func extractThinkingContent(blocks []anthropicContent) string { - var sb strings.Builder - for _, b := range blocks { - if b.Type == "thinking" && b.Thinking != "" { - if sb.Len() > 0 { - sb.WriteString("\n\n") - } - sb.WriteString(b.Thinking) - } - } - return sb.String() -} - // extractToolCalls maps Anthropic "tool_use" content blocks to OpenAI-compatible tool calls. func extractToolCalls(blocks []anthropicContent) []core.ToolCall { out := make([]core.ToolCall, 0) From 9e1b2f2370a0b6600eeedaa7e735581580a2f40f Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 26 Mar 2026 14:15:22 +0100 Subject: [PATCH 4/6] docs(anthropic): document breaking compat flag --- .env.template | 11 +++++++++++ README.md | 1 + docs/advanced/configuration.mdx | 14 ++++++++++++++ internal/providers/anthropic/reasoning_compat.go | 4 ++++ 4 files changed, 30 insertions(+) diff --git a/.env.template b/.env.template index b4e5559b..63883446 100644 --- a/.env.template +++ b/.env.template @@ -142,6 +142,17 @@ # Auto-delete usage data older than N days, 0 = keep forever (default: 90) # USAGE_RETENTION_DAYS=90 +# ============================================================================= +# Experimental OpenAI Compatibility Flags +# ============================================================================= + +# Preserve Anthropic reasoning fingerprints/signatures when routing Claude +# through the OpenAI-compatible APIs. +# WARNING: This adds Anthropic-specific non-standard fields such as +# reasoning_details / reasoning_signature / reasoning_index and can break strict +# OpenAI-compatible clients, SDKs, or schema validators. Disabled by default. +# OPENAI_COMPAT_BREAKING_ANTHROPIC_THINKING_SIGNATURES=false + # ============================================================================= # Provider API Keys (uncomment and set the ones you need) # ============================================================================= diff --git a/README.md b/README.md index dffe3e98..7617bdf3 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ Key settings: | `METRICS_ENABLED` | `false` | Enable Prometheus metrics | | `LOGGING_ENABLED` | `false` | Enable audit logging | | `GUARDRAILS_ENABLED` | `false` | Enable the configured guardrails pipeline | +| `OPENAI_COMPAT_BREAKING_ANTHROPIC_THINKING_SIGNATURES` | `false` | Preserve Anthropic reasoning fingerprints/signatures in OpenAI-compatible payloads by emitting non-standard fields; breaks strict compatibility | **Quick Start - Authentication:** By default `GOMODEL_MASTER_KEY` is unset. Without this key, API endpoints are unprotected and anyone can call them. This is insecure for production. **Strongly recommend** setting a strong secret before exposing the service. Add `GOMODEL_MASTER_KEY` to your `.env` or environment for production deployments. diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index cf019a8a..8a71ba71 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -162,6 +162,20 @@ cp .env.template .env `.env` file is only loaded if it exists — missing it is not an error. +### Experimental Compatibility Flags + +Some compatibility features are intentionally opt-in because they preserve +provider-specific behavior by emitting non-standard fields. + +`OPENAI_COMPAT_BREAKING_ANTHROPIC_THINKING_SIGNATURES=true` + +- Preserves Anthropic reasoning fingerprints/signatures when Claude is accessed + through the OpenAI-compatible Chat Completions and Responses APIs. +- Adds Anthropic-specific fields such as `reasoning_details`, + `reasoning_signature`, and `reasoning_index`. +- Can break strict OpenAI-compatible clients, typed SDKs, and schema validators, + so it is disabled by default. + ### 3. Configuration File (YAML) For more complex setups, you can use an optional YAML configuration file. GOModel looks for it in two locations (in order): diff --git a/internal/providers/anthropic/reasoning_compat.go b/internal/providers/anthropic/reasoning_compat.go index d2072c1b..f690d818 100644 --- a/internal/providers/anthropic/reasoning_compat.go +++ b/internal/providers/anthropic/reasoning_compat.go @@ -11,6 +11,10 @@ import ( "gomodel/internal/core" ) +// openAICompatBreakingAnthropicThinkingSignaturesEnv enables preserving Anthropic +// reasoning fingerprints/signatures in OpenAI-compatible chat/responses payloads. +// This is intentionally behind a flag because it adds Anthropic-specific fields +// that can break strict OpenAI-compatible clients. const openAICompatBreakingAnthropicThinkingSignaturesEnv = "OPENAI_COMPAT_BREAKING_ANTHROPIC_THINKING_SIGNATURES" type openAIReasoningDetail struct { From 497b18ca4b456dde76816a1defae89d0ec578619 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 26 Mar 2026 16:22:44 +0100 Subject: [PATCH 5/6] fix(anthropic): isolate reasoning compatibility paths --- internal/providers/anthropic/anthropic.go | 70 ++++-- .../providers/anthropic/anthropic_test.go | 231 +++++++++++++++++- .../anthropic/request_translation.go | 4 +- internal/providers/responses_adapter.go | 77 +++++- internal/providers/responses_adapter_test.go | 96 ++++++-- 5 files changed, 430 insertions(+), 48 deletions(-) diff --git a/internal/providers/anthropic/anthropic.go b/internal/providers/anthropic/anthropic.go index a4210888..9cbb7d75 100644 --- a/internal/providers/anthropic/anthropic.go +++ b/internal/providers/anthropic/anthropic.go @@ -816,7 +816,7 @@ func (sc *streamConverter) convertEvent(event *anthropicStreamEvent) string { switch event.Delta.Type { case "thinking_delta": - if sc.thinkingBlocks[event.Index] && strings.TrimSpace(event.Delta.Thinking) != "" { + if sc.thinkingBlocks[event.Index] && event.Delta.Thinking != "" { delta := map[string]any{ "reasoning_content": event.Delta.Thinking, } @@ -1500,6 +1500,8 @@ type responsesStreamConverter struct { assistantOutputIndex int toolCalls map[int]*providers.ResponsesOutputToolCallState thinkingContentIndices map[int]int + pendingReasoningText map[int]string + pendingReasoningSig map[int]string reasoning *responsesReasoningState emitReasoningSignatures bool buffer []byte @@ -1532,6 +1534,8 @@ func newResponsesStreamConverter(body io.ReadCloser, model string) *responsesStr output: providers.NewResponsesOutputEventState(responseID), toolCalls: make(map[int]*providers.ResponsesOutputToolCallState), thinkingContentIndices: make(map[int]int), + pendingReasoningText: make(map[int]string), + pendingReasoningSig: make(map[int]string), emitReasoningSignatures: anthropicThinkingSignaturesCompatEnabled(), buffer: make([]byte, 0, 1024), } @@ -1676,33 +1680,64 @@ func (sc *responsesStreamConverter) reasoningContentIndex(anthropicIndex int) in } state := sc.ensureResponsesReasoningState() contentIndex := len(state.Blocks) - state.Blocks = append(state.Blocks, responsesReasoningBlockState{}) + block := responsesReasoningBlockState{} + if signature, ok := sc.pendingReasoningSig[anthropicIndex]; ok { + block.Signature = signature + delete(sc.pendingReasoningSig, anthropicIndex) + } + state.Blocks = append(state.Blocks, block) sc.thinkingContentIndices[anthropicIndex] = contentIndex return contentIndex } -func (sc *responsesStreamConverter) appendReasoningText(anthropicIndex int, text string) { +func (sc *responsesStreamConverter) materializeReasoningTextDelta(anthropicIndex int, text string) (int, string) { if text == "" { - return + return 0, "" + } + + if contentIndex, ok := sc.thinkingContentIndices[anthropicIndex]; ok { + state := sc.reasoning + if state == nil || contentIndex >= len(state.Blocks) { + return 0, "" + } + _, _ = state.Blocks[contentIndex].Text.WriteString(text) + return contentIndex, text } + + buffered := sc.pendingReasoningText[anthropicIndex] + text + sc.pendingReasoningText[anthropicIndex] = buffered + if strings.TrimSpace(buffered) == "" { + return 0, "" + } + state := sc.ensureResponsesReasoningState() contentIndex := sc.reasoningContentIndex(anthropicIndex) if contentIndex >= len(state.Blocks) { - return + return 0, "" } - _, _ = state.Blocks[contentIndex].Text.WriteString(text) + delete(sc.pendingReasoningText, anthropicIndex) + _, _ = state.Blocks[contentIndex].Text.WriteString(buffered) + return contentIndex, buffered } func (sc *responsesStreamConverter) setReasoningSignature(anthropicIndex int, signature string) { if signature == "" { return } - state := sc.ensureResponsesReasoningState() - contentIndex := sc.reasoningContentIndex(anthropicIndex) - if contentIndex >= len(state.Blocks) { + if contentIndex, ok := sc.thinkingContentIndices[anthropicIndex]; ok { + state := sc.reasoning + if state == nil || contentIndex >= len(state.Blocks) { + return + } + state.Blocks[contentIndex].Signature = signature return } - state.Blocks[contentIndex].Signature = signature + sc.pendingReasoningSig[anthropicIndex] = signature +} + +func (sc *responsesStreamConverter) clearPendingReasoning(anthropicIndex int) { + delete(sc.pendingReasoningText, anthropicIndex) + delete(sc.pendingReasoningSig, anthropicIndex) } func (sc *responsesStreamConverter) reasoningTextDoneEvent(anthropicIndex int) string { @@ -1800,11 +1835,7 @@ func (sc *responsesStreamConverter) convertEvent(event *anthropicStreamEvent) st case "content_block_start": if event.ContentBlock != nil && event.ContentBlock.Type == "thinking" { - if !sc.emitReasoningSignatures { - return "" - } - sc.reasoningContentIndex(event.Index) - return sc.startReasoningOutput() + return "" } if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" { prefix := sc.completeReasoningOutputIfNeeded() @@ -1830,15 +1861,17 @@ func (sc *responsesStreamConverter) convertEvent(event *anthropicStreamEvent) st if !sc.emitReasoningSignatures || event.Delta.Thinking == "" { return "" } + contentIndex, delta := sc.materializeReasoningTextDelta(event.Index, event.Delta.Thinking) + if delta == "" { + return "" + } prefix := sc.startReasoningOutput() - sc.appendReasoningText(event.Index, event.Delta.Thinking) - contentIndex := sc.reasoningContentIndex(event.Index) return prefix + sc.output.WriteEvent("response.reasoning_text.delta", map[string]any{ "type": "response.reasoning_text.delta", "item_id": sc.reasoning.ItemID, "output_index": sc.reasoning.OutputIndex, "content_index": contentIndex, - "delta": event.Delta.Thinking, + "delta": delta, }) case "signature_delta": if !sc.emitReasoningSignatures || event.Delta.Signature == "" { @@ -1885,6 +1918,7 @@ func (sc *responsesStreamConverter) convertEvent(event *anthropicStreamEvent) st return "" case "content_block_stop": + defer sc.clearPendingReasoning(event.Index) if sc.emitReasoningSignatures { if _, ok := sc.thinkingContentIndices[event.Index]; ok { return sc.reasoningTextDoneEvent(event.Index) diff --git a/internal/providers/anthropic/anthropic_test.go b/internal/providers/anthropic/anthropic_test.go index ae9534b7..780ed179 100644 --- a/internal/providers/anthropic/anthropic_test.go +++ b/internal/providers/anthropic/anthropic_test.go @@ -748,6 +748,72 @@ data: {"type":"message_stop"} } } +func TestStreamChatCompletion_PreservesWhitespaceOnlyReasoningDeltasBehindFeatureFlag(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`event: message_start +data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"foo"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" "}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"bar"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_123"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} +`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + body, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{ + Model: "claude-sonnet-4-5-20250929", + Messages: []core.Message{ + {Role: "user", Content: "Hello"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { _ = body.Close() }() + + raw, err := io.ReadAll(body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + responseStr := string(raw) + if !strings.Contains(responseStr, `"reasoning_content":"foo"`) { + t.Fatalf("expected first reasoning delta, got %q", responseStr) + } + if !strings.Contains(responseStr, `"reasoning_content":" "`) { + t.Fatalf("expected whitespace-only reasoning delta, got %q", responseStr) + } + if !strings.Contains(responseStr, `"reasoning_content":"bar"`) { + t.Fatalf("expected trailing reasoning delta, got %q", responseStr) + } +} + func TestStreamChatCompletion_WithToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -2936,7 +3002,7 @@ data: {"type":"message_stop"} } } -func TestStreamResponses_SkipsEmptyReasoningBlocks(t *testing.T) { +func TestStreamResponses_SkipsEmptyReasoningBlocksAndUsesDenseReasoningContentIndices(t *testing.T) { t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -2998,6 +3064,8 @@ data: {"type":"message_stop"} } events := parseTestSSEEvents(t, string(raw)) + reasoningAddedCount := 0 + reasoningDeltaCount := 0 reasoningDoneCount := 0 reasoningItemParts := 0 @@ -3006,8 +3074,22 @@ data: {"type":"message_stop"} continue } switch event.Name { + case "response.output_item.added": + item, _ := event.Payload["item"].(map[string]any) + if item["type"] != "reasoning" { + continue + } + reasoningAddedCount++ + case "response.reasoning_text.delta": + reasoningDeltaCount++ + if got := event.Payload["content_index"]; got != float64(0) { + t.Fatalf("reasoning_text.delta content_index = %#v, want 0", got) + } case "response.reasoning_text.done": reasoningDoneCount++ + if got := event.Payload["content_index"]; got != float64(0) { + t.Fatalf("reasoning_text.done content_index = %#v, want 0", got) + } if event.Payload["text"] != "Let me think." { t.Fatalf("unexpected reasoning_text.done payload: %+v", event.Payload) } @@ -3021,6 +3103,12 @@ data: {"type":"message_stop"} } } + if reasoningAddedCount != 1 { + t.Fatalf("reasoning output_item.added count = %d, want 1", reasoningAddedCount) + } + if reasoningDeltaCount != 1 { + t.Fatalf("reasoningTextDelta count = %d, want 1", reasoningDeltaCount) + } if reasoningDoneCount != 1 { t.Fatalf("reasoningTextDone count = %d, want 1", reasoningDoneCount) } @@ -3029,6 +3117,67 @@ data: {"type":"message_stop"} } } +func TestStreamResponses_DoesNotStartReasoningOutputForAllEmptyThinkingBlocks(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`event: message_start +data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_123"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} +`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + body, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ + Model: "claude-sonnet-4-5-20250929", + Input: "Hello", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { _ = body.Close() }() + + raw, err := io.ReadAll(body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + responseStr := string(raw) + if strings.Contains(responseStr, `"type":"reasoning"`) { + t.Fatalf("did not expect reasoning output items, got %q", responseStr) + } + if strings.Contains(responseStr, "response.reasoning_text.") { + t.Fatalf("did not expect reasoning text events, got %q", responseStr) + } +} + func TestStreamResponses_WithToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -4025,6 +4174,86 @@ func TestConvertToAnthropicRequest_PreservesReasoningDetailsBehindFeatureFlag(t } } +func TestConvertResponsesRequestToAnthropic_PreservesReasoningItemsBehindFeatureFlag(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") + + req := &core.ResponsesRequest{ + Model: "claude-sonnet-4-5-20250929", + Input: []core.ResponsesInputElement{ + { + Type: "reasoning", + Content: []core.ResponsesContentItem{ + { + Type: "reasoning_text", + Text: "Let me think.", + Signature: "sig_123", + }, + }, + }, + { + Type: "message", + Role: "assistant", + Content: "Hello", + }, + }, + } + + result, err := convertResponsesRequestToAnthropic(req) + if err != nil { + t.Fatalf("convertResponsesRequestToAnthropic() error = %v", err) + } + if len(result.Messages) != 1 { + t.Fatalf("len(Messages) = %d, want 1", len(result.Messages)) + } + blocks, ok := result.Messages[0].Content.([]anthropicContentBlock) + if !ok { + t.Fatalf("content = %#v, want []anthropicContentBlock", result.Messages[0].Content) + } + if len(blocks) != 2 { + t.Fatalf("len(blocks) = %d, want 2", len(blocks)) + } + if blocks[0].Type != "thinking" || blocks[0].Thinking != "Let me think." || blocks[0].Signature != "sig_123" { + t.Fatalf("blocks[0] = %+v, want thinking block with signature", blocks[0]) + } + if blocks[1].Type != "text" || blocks[1].Text != "Hello" { + t.Fatalf("blocks[1] = %+v, want text block", blocks[1]) + } +} + +func TestConvertResponsesRequestToAnthropic_RejectsReasoningItemsWhenFeatureFlagDisabled(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "") + + _, err := convertResponsesRequestToAnthropic(&core.ResponsesRequest{ + Model: "claude-sonnet-4-5-20250929", + Input: []core.ResponsesInputElement{ + { + Type: "reasoning", + Content: []core.ResponsesContentItem{ + { + Type: "reasoning_text", + Text: "Let me think.", + Signature: "sig_123", + }, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) { + t.Fatalf("error = %T, want *core.GatewayError", err) + } + if gatewayErr.Type != core.ErrorTypeInvalidRequest { + t.Fatalf("GatewayError.Type = %q, want %q", gatewayErr.Type, core.ErrorTypeInvalidRequest) + } + if !strings.Contains(err.Error(), "reasoning items require provider-specific reasoning compatibility") { + t.Fatalf("error = %v, want reasoning compatibility error", err) + } +} + func TestConvertToAnthropicRequest_RejectsInvalidReasoningCompatFieldsBehindFeatureFlag(t *testing.T) { t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") diff --git a/internal/providers/anthropic/request_translation.go b/internal/providers/anthropic/request_translation.go index 05a4c093..ebf5d6ac 100644 --- a/internal/providers/anthropic/request_translation.go +++ b/internal/providers/anthropic/request_translation.go @@ -348,7 +348,9 @@ func convertResponsesRequestToAnthropic(req *core.ResponsesRequest) (*anthropicR return nil, core.NewInvalidRequestError("anthropic responses request is required", nil) } - chatReq, err := providers.ConvertResponsesRequestToChat(req) + chatReq, err := providers.ConvertResponsesRequestToChatWithOptions(req, providers.ResponsesToChatOptions{ + PreserveAnthropicReasoningCompat: anthropicThinkingSignaturesCompatEnabled(), + }) if err != nil { return nil, err } diff --git a/internal/providers/responses_adapter.go b/internal/providers/responses_adapter.go index 592645ea..99917fa2 100644 --- a/internal/providers/responses_adapter.go +++ b/internal/providers/responses_adapter.go @@ -21,10 +21,25 @@ type ChatProvider interface { StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) } +// ResponsesToChatOptions controls non-standard provider-specific translations +// when adapting Responses input into Chat semantics. +type ResponsesToChatOptions struct { + // PreserveAnthropicReasoningCompat allows Anthropic-only reasoning history + // to be carried through ChatRequest.ExtraFields. Generic chat adapters must + // keep this off so provider-specific fields do not leak upstream. + PreserveAnthropicReasoningCompat bool +} + // ConvertResponsesRequestToChat converts a ResponsesRequest to a ChatRequest. // It also validates the supported Responses input shapes and returns an error // when the request cannot be converted safely. func ConvertResponsesRequestToChat(req *core.ResponsesRequest) (*core.ChatRequest, error) { + return ConvertResponsesRequestToChatWithOptions(req, ResponsesToChatOptions{}) +} + +// ConvertResponsesRequestToChatWithOptions converts a ResponsesRequest to a +// ChatRequest while allowing explicit provider-specific compatibility modes. +func ConvertResponsesRequestToChatWithOptions(req *core.ResponsesRequest, opts ResponsesToChatOptions) (*core.ChatRequest, error) { if req == nil { return nil, core.NewInvalidRequestError("responses request is required", nil) } @@ -54,7 +69,7 @@ func ConvertResponsesRequestToChat(req *core.ResponsesRequest) (*core.ChatReques }) } - messages, err := ConvertResponsesInputToMessages(req.Input) + messages, err := convertResponsesInputToMessagesWithOptions(req.Input, opts) if err != nil { return nil, err } @@ -289,6 +304,10 @@ func mergeReasoningIntoAssistantMessage(dst *core.Message, src core.Message) { // ConvertResponsesInputToMessages converts a Responses API input payload into Chat API messages. func ConvertResponsesInputToMessages(input any) ([]core.Message, error) { + return convertResponsesInputToMessagesWithOptions(input, ResponsesToChatOptions{}) +} + +func convertResponsesInputToMessagesWithOptions(input any, opts ResponsesToChatOptions) ([]core.Message, error) { switch in := input.(type) { case string: return []core.Message{{Role: "user", Content: in}}, nil @@ -297,15 +316,15 @@ func ConvertResponsesInputToMessages(input any) ([]core.Message, error) { for _, item := range in { items = append(items, item) } - return convertResponsesInputItems(items) + return convertResponsesInputItems(items, opts) case []any: - return convertResponsesInputItems(in) + return convertResponsesInputItems(in, opts) case []core.ResponsesInputElement: items := make([]any, 0, len(in)) for _, item := range in { items = append(items, item) } - return convertResponsesInputItems(items) + return convertResponsesInputItems(items, opts) case nil: return nil, core.NewInvalidRequestError("invalid responses input: unsupported type", nil) default: @@ -313,7 +332,7 @@ func ConvertResponsesInputToMessages(input any) ([]core.Message, error) { } } -func convertResponsesInputItems(items []any) ([]core.Message, error) { +func convertResponsesInputItems(items []any, opts ResponsesToChatOptions) ([]core.Message, error) { messages := make([]core.Message, 0, len(items)) var pendingAssistant *core.Message @@ -326,7 +345,7 @@ func convertResponsesInputItems(items []any) ([]core.Message, error) { } for i, item := range items { - msg, itemType, err := convertResponsesInputItem(item, i) + msg, itemType, err := convertResponsesInputItem(item, i, opts) if err != nil { return nil, err } @@ -373,18 +392,18 @@ func convertResponsesInputItems(items []any) ([]core.Message, error) { return messages, nil } -func convertResponsesInputItem(item any, index int) (core.Message, string, error) { +func convertResponsesInputItem(item any, index int, opts ResponsesToChatOptions) (core.Message, string, error) { switch typed := item.(type) { case core.ResponsesInputElement: - return convertResponsesInputElement(typed, index) + return convertResponsesInputElement(typed, index, opts) case map[string]any: - return convertResponsesInputMap(typed, index) + return convertResponsesInputMap(typed, index, opts) default: return core.Message{}, "", core.NewInvalidRequestError(fmt.Sprintf("invalid responses input item at index %d: expected object", index), nil) } } -func convertResponsesInputElement(item core.ResponsesInputElement, index int) (core.Message, string, error) { +func convertResponsesInputElement(item core.ResponsesInputElement, index int, opts ResponsesToChatOptions) (core.Message, string, error) { switch item.Type { case "function_call": name := strings.TrimSpace(item.Name) @@ -427,6 +446,12 @@ func convertResponsesInputElement(item core.ResponsesInputElement, index int) (c ExtraFields: core.CloneUnknownJSONFields(item.ExtraFields), }, "function_call_output", nil case "reasoning": + if !opts.PreserveAnthropicReasoningCompat { + return core.Message{}, "", core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: reasoning items require provider-specific reasoning compatibility", index), + nil, + ) + } reasoningExtras, err := mergeResponsesReasoningExtraFields(core.CloneUnknownJSONFields(item.ExtraFields), item.Content) if err != nil { return core.Message{}, "", core.NewInvalidRequestError(fmt.Sprintf("invalid responses input item at index %d: reasoning.content must be an array of reasoning_text items", index), err) @@ -442,6 +467,12 @@ func convertResponsesInputElement(item core.ResponsesInputElement, index int) (c if role == "" { return core.Message{}, "", core.NewInvalidRequestError(fmt.Sprintf("invalid responses input item at index %d: role is required", index), nil) } + if !opts.PreserveAnthropicReasoningCompat && containsAnthropicReasoningCompatFields(item.ExtraFields) { + return core.Message{}, "", core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: anthropic reasoning compatibility fields require provider-specific reasoning compatibility", index), + nil, + ) + } content, ok := ConvertResponsesContentToChatContent(item.Content) if !ok { return core.Message{}, "", core.NewInvalidRequestError(fmt.Sprintf("invalid responses input item at index %d: unsupported content", index), nil) @@ -454,7 +485,7 @@ func convertResponsesInputElement(item core.ResponsesInputElement, index int) (c } } -func convertResponsesInputMap(item map[string]any, index int) (core.Message, string, error) { +func convertResponsesInputMap(item map[string]any, index int, opts ResponsesToChatOptions) (core.Message, string, error) { itemType, _ := item["type"].(string) switch itemType { case "function_call": @@ -499,6 +530,12 @@ func convertResponsesInputMap(item map[string]any, index int) (core.Message, str ExtraFields: core.UnknownJSONFieldsFromMap(rawJSONMapFromUnknownKeys(item, "type", "call_id", "status", "output")), }, "function_call_output", nil case "reasoning": + if !opts.PreserveAnthropicReasoningCompat { + return core.Message{}, "", core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: reasoning items require provider-specific reasoning compatibility", index), + nil, + ) + } reasoningExtras, err := mergeResponsesReasoningExtraFields( core.UnknownJSONFieldsFromMap(rawJSONMapFromUnknownKeys(item, "type", "status", "content")), item["content"], @@ -519,6 +556,13 @@ func convertResponsesInputMap(item map[string]any, index int) (core.Message, str if role == "" { return core.Message{}, "", core.NewInvalidRequestError(fmt.Sprintf("invalid responses input item at index %d: role is required", index), nil) } + extraFields := core.UnknownJSONFieldsFromMap(rawJSONMapFromUnknownKeys(item, "type", "role", "status", "content")) + if !opts.PreserveAnthropicReasoningCompat && containsAnthropicReasoningCompatFields(extraFields) { + return core.Message{}, "", core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: anthropic reasoning compatibility fields require provider-specific reasoning compatibility", index), + nil, + ) + } content, ok := ConvertResponsesContentToChatContent(item["content"]) if !ok { @@ -527,10 +571,19 @@ func convertResponsesInputMap(item map[string]any, index int) (core.Message, str return core.Message{ Role: role, Content: content, - ExtraFields: core.UnknownJSONFieldsFromMap(rawJSONMapFromUnknownKeys(item, "type", "role", "status", "content")), + ExtraFields: extraFields, }, "message", nil } +func containsAnthropicReasoningCompatFields(fields core.UnknownJSONFields) bool { + for _, key := range []string{"reasoning_details", "reasoning_content", "reasoning_signature"} { + if len(fields.Lookup(key)) > 0 { + return true + } + } + return false +} + func cloneResponsesMessage(msg core.Message) core.Message { cloned := msg if len(msg.ToolCalls) > 0 { diff --git a/internal/providers/responses_adapter_test.go b/internal/providers/responses_adapter_test.go index 9d16ea7e..94a146d6 100644 --- a/internal/providers/responses_adapter_test.go +++ b/internal/providers/responses_adapter_test.go @@ -18,6 +18,12 @@ type capturingChatProvider struct { streamErr error } +func convertResponsesRequestToChatWithAnthropicCompat(req *core.ResponsesRequest) (*core.ChatRequest, error) { + return ConvertResponsesRequestToChatWithOptions(req, ResponsesToChatOptions{ + PreserveAnthropicReasoningCompat: true, + }) +} + func (p *capturingChatProvider) ChatCompletion(_ context.Context, _ *core.ChatRequest) (*core.ChatResponse, error) { return nil, nil } @@ -423,7 +429,65 @@ func TestConvertResponsesRequestToChat_DoesNotMergeAssistantMessagesWithExtraFie } } -func TestConvertResponsesRequestToChat_MergesReasoningItemIntoFollowingAssistantMessage(t *testing.T) { +func TestConvertResponsesRequestToChat_RejectsReasoningItemsByDefault(t *testing.T) { + _, err := ConvertResponsesRequestToChat(&core.ResponsesRequest{ + Model: "test-model", + Input: []any{ + map[string]any{ + "type": "reasoning", + "content": []map[string]any{ + {"type": "reasoning_text", "text": "Let me think."}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) { + t.Fatalf("error = %T, want *core.GatewayError", err) + } + if gatewayErr.Type != core.ErrorTypeInvalidRequest { + t.Fatalf("GatewayError.Type = %q, want %q", gatewayErr.Type, core.ErrorTypeInvalidRequest) + } + if !strings.Contains(err.Error(), "reasoning items require provider-specific reasoning compatibility") { + t.Fatalf("error = %v, want provider-specific reasoning compatibility error", err) + } +} + +func TestConvertResponsesRequestToChat_RejectsAnthropicReasoningCompatFieldsByDefault(t *testing.T) { + _, err := ConvertResponsesRequestToChat(&core.ResponsesRequest{ + Model: "test-model", + Input: []core.ResponsesInputElement{ + { + Type: "message", + Role: "assistant", + Content: "Hello", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_details": json.RawMessage(`[{"type":"reasoning_text","text":"Let me think.","signature":"sig_123"}]`), + }), + }, + }, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) { + t.Fatalf("error = %T, want *core.GatewayError", err) + } + if gatewayErr.Type != core.ErrorTypeInvalidRequest { + t.Fatalf("GatewayError.Type = %q, want %q", gatewayErr.Type, core.ErrorTypeInvalidRequest) + } + if !strings.Contains(err.Error(), "anthropic reasoning compatibility fields require provider-specific reasoning compatibility") { + t.Fatalf("error = %v, want anthropic reasoning compatibility error", err) + } +} + +func TestConvertResponsesRequestToChatWithAnthropicCompat_MergesReasoningItemIntoFollowingAssistantMessage(t *testing.T) { req := &core.ResponsesRequest{ Model: "test-model", Input: []core.ResponsesInputElement{ @@ -447,9 +511,9 @@ func TestConvertResponsesRequestToChat_MergesReasoningItemIntoFollowingAssistant }, } - chatReq, err := ConvertResponsesRequestToChat(req) + chatReq, err := convertResponsesRequestToChatWithAnthropicCompat(req) if err != nil { - t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) + t.Fatalf("convertResponsesRequestToChatWithAnthropicCompat() error = %v", err) } if len(chatReq.Messages) != 1 { t.Fatalf("len(Messages) = %d, want 1", len(chatReq.Messages)) @@ -475,7 +539,7 @@ func TestConvertResponsesRequestToChat_MergesReasoningItemIntoFollowingAssistant } } -func TestConvertResponsesRequestToChat_DoesNotReplaceMultimodalAssistantAfterReasoningMerge(t *testing.T) { +func TestConvertResponsesRequestToChatWithAnthropicCompat_DoesNotReplaceMultimodalAssistantAfterReasoningMerge(t *testing.T) { req := &core.ResponsesRequest{ Model: "test-model", Input: []any{ @@ -513,9 +577,9 @@ func TestConvertResponsesRequestToChat_DoesNotReplaceMultimodalAssistantAfterRea }, } - chatReq, err := ConvertResponsesRequestToChat(req) + chatReq, err := convertResponsesRequestToChatWithAnthropicCompat(req) if err != nil { - t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) + t.Fatalf("convertResponsesRequestToChatWithAnthropicCompat() error = %v", err) } if len(chatReq.Messages) != 2 { t.Fatalf("len(Messages) = %d, want 2", len(chatReq.Messages)) @@ -540,7 +604,7 @@ func TestConvertResponsesRequestToChat_DoesNotReplaceMultimodalAssistantAfterRea } } -func TestConvertResponsesRequestToChat_RejectsNonReasoningTextParts(t *testing.T) { +func TestConvertResponsesRequestToChatWithAnthropicCompat_RejectsNonReasoningTextParts(t *testing.T) { tests := []struct { name string input any @@ -571,7 +635,7 @@ func TestConvertResponsesRequestToChat_RejectsNonReasoningTextParts(t *testing.T for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := ConvertResponsesRequestToChat(&core.ResponsesRequest{ + _, err := convertResponsesRequestToChatWithAnthropicCompat(&core.ResponsesRequest{ Model: "test-model", Input: tt.input, }) @@ -592,7 +656,7 @@ func TestConvertResponsesRequestToChat_RejectsNonReasoningTextParts(t *testing.T } } -func TestConvertResponsesRequestToChat_RejectsEmptyReasoningPayload(t *testing.T) { +func TestConvertResponsesRequestToChatWithAnthropicCompat_RejectsEmptyReasoningPayload(t *testing.T) { tests := []struct { name string input any @@ -621,7 +685,7 @@ func TestConvertResponsesRequestToChat_RejectsEmptyReasoningPayload(t *testing.T for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := ConvertResponsesRequestToChat(&core.ResponsesRequest{ + _, err := convertResponsesRequestToChatWithAnthropicCompat(&core.ResponsesRequest{ Model: "test-model", Input: tt.input, }) @@ -640,7 +704,7 @@ func TestConvertResponsesRequestToChat_RejectsEmptyReasoningPayload(t *testing.T } } -func TestConvertResponsesRequestToChat_MergesReasoningDetailsFromPendingAndAssistantExtras(t *testing.T) { +func TestConvertResponsesRequestToChatWithAnthropicCompat_MergesReasoningDetailsFromPendingAndAssistantExtras(t *testing.T) { req := &core.ResponsesRequest{ Model: "test-model", Input: []core.ResponsesInputElement{ @@ -665,9 +729,9 @@ func TestConvertResponsesRequestToChat_MergesReasoningDetailsFromPendingAndAssis }, } - chatReq, err := ConvertResponsesRequestToChat(req) + chatReq, err := convertResponsesRequestToChatWithAnthropicCompat(req) if err != nil { - t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) + t.Fatalf("convertResponsesRequestToChatWithAnthropicCompat() error = %v", err) } if len(chatReq.Messages) != 1 { t.Fatalf("len(Messages) = %d, want 1", len(chatReq.Messages)) @@ -690,7 +754,7 @@ func TestConvertResponsesRequestToChat_MergesReasoningDetailsFromPendingAndAssis } } -func TestConvertResponsesRequestToChat_PreservesReasoningExtrasWhenMerged(t *testing.T) { +func TestConvertResponsesRequestToChatWithAnthropicCompat_PreservesReasoningExtrasWhenMerged(t *testing.T) { req := &core.ResponsesRequest{ Model: "test-model", Input: []core.ResponsesInputElement{ @@ -718,9 +782,9 @@ func TestConvertResponsesRequestToChat_PreservesReasoningExtrasWhenMerged(t *tes }, } - chatReq, err := ConvertResponsesRequestToChat(req) + chatReq, err := convertResponsesRequestToChatWithAnthropicCompat(req) if err != nil { - t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) + t.Fatalf("convertResponsesRequestToChatWithAnthropicCompat() error = %v", err) } if len(chatReq.Messages) != 1 { t.Fatalf("len(Messages) = %d, want 1", len(chatReq.Messages)) From 7647aef8ed42347b6cb0459141badb5c95f1975e Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 26 Mar 2026 19:56:24 +0100 Subject: [PATCH 6/6] fix(anthropic): preserve reasoning replay semantics --- .../providers/anthropic/anthropic_test.go | 81 ++++++++++ .../providers/anthropic/reasoning_compat.go | 18 +++ .../anthropic/request_translation.go | 12 ++ internal/providers/responses_adapter.go | 139 ++++++++++++++++-- internal/providers/responses_adapter_test.go | 121 +++++++++++++++ 5 files changed, 360 insertions(+), 11 deletions(-) diff --git a/internal/providers/anthropic/anthropic_test.go b/internal/providers/anthropic/anthropic_test.go index 780ed179..dd1e90a4 100644 --- a/internal/providers/anthropic/anthropic_test.go +++ b/internal/providers/anthropic/anthropic_test.go @@ -4174,6 +4174,87 @@ func TestConvertToAnthropicRequest_PreservesReasoningDetailsBehindFeatureFlag(t } } +func TestConvertToAnthropicRequest_RejectsReasoningCompatFieldsOutsideAssistantTurnOrWithoutFlag(t *testing.T) { + tests := []struct { + name string + flagValue string + role string + wantMessage string + }{ + { + name: "assistant turn requires flag", + flagValue: "", + role: "assistant", + wantMessage: "anthropic reasoning compatibility fields require " + openAICompatBreakingAnthropicThinkingSignaturesEnv + " to be enabled", + }, + { + name: "user turn unsupported", + flagValue: "true", + role: "user", + wantMessage: "anthropic reasoning compatibility fields are only supported on assistant messages", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, tt.flagValue) + + _, err := convertToAnthropicRequest(&core.ChatRequest{ + Model: "claude-sonnet-4-5-20250929", + Messages: []core.Message{ + { + Role: tt.role, + Content: "Hello", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_details": json.RawMessage(`[{"type":"reasoning_text","text":"Let me think.","signature":"sig_123"}]`), + }), + }, + }, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) { + t.Fatalf("error = %T, want *core.GatewayError", err) + } + if gatewayErr.Type != core.ErrorTypeInvalidRequest { + t.Fatalf("GatewayError.Type = %q, want %q", gatewayErr.Type, core.ErrorTypeInvalidRequest) + } + if !strings.Contains(err.Error(), tt.wantMessage) { + t.Fatalf("error = %v, want substring %q", err, tt.wantMessage) + } + }) + } +} + +func TestConvertToAnthropicRequest_AllowsReplayedReasoningContentWhenFeatureFlagDisabled(t *testing.T) { + t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "") + + result, err := convertToAnthropicRequest(&core.ChatRequest{ + Model: "claude-sonnet-4-5-20250929", + Messages: []core.Message{ + { + Role: "assistant", + Content: "Hello", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_content": json.RawMessage(`"Let me think."`), + }), + }, + }, + }) + if err != nil { + t.Fatalf("convertToAnthropicRequest() error = %v", err) + } + if len(result.Messages) != 1 { + t.Fatalf("len(Messages) = %d, want 1", len(result.Messages)) + } + if got, ok := result.Messages[0].Content.(string); !ok || got != "Hello" { + t.Fatalf("content = %#v, want plain text Hello", result.Messages[0].Content) + } +} + func TestConvertResponsesRequestToAnthropic_PreservesReasoningItemsBehindFeatureFlag(t *testing.T) { t.Setenv(openAICompatBreakingAnthropicThinkingSignaturesEnv, "true") diff --git a/internal/providers/anthropic/reasoning_compat.go b/internal/providers/anthropic/reasoning_compat.go index f690d818..b4b9d467 100644 --- a/internal/providers/anthropic/reasoning_compat.go +++ b/internal/providers/anthropic/reasoning_compat.go @@ -37,6 +37,24 @@ func anthropicThinkingSignaturesCompatEnabled() bool { } } +func hasAnthropicReasoningCompatFields(fields core.UnknownJSONFields) bool { + for _, key := range []string{"reasoning_details", "reasoning_content", "reasoning_signature"} { + if len(fields.Lookup(key)) > 0 { + return true + } + } + return false +} + +func hasAnthropicBreakingReasoningCompatFields(fields core.UnknownJSONFields) bool { + for _, key := range []string{"reasoning_details", "reasoning_signature"} { + if len(fields.Lookup(key)) > 0 { + return true + } + } + return false +} + func extractAnthropicReasoningDetails(blocks []anthropicContent) []openAIReasoningDetail { details := make([]openAIReasoningDetail, 0, len(blocks)) for _, block := range blocks { diff --git a/internal/providers/anthropic/request_translation.go b/internal/providers/anthropic/request_translation.go index ebf5d6ac..c7ca8f77 100644 --- a/internal/providers/anthropic/request_translation.go +++ b/internal/providers/anthropic/request_translation.go @@ -190,6 +190,18 @@ func parseToolCallArguments(arguments string) (any, error) { func buildAnthropicMessageContent(msg core.Message) (any, error) { const maxToolCallsPerMessage = 1024 + if hasAnthropicReasoningCompatFields(msg.ExtraFields) { + if msg.Role != "assistant" { + return nil, core.NewInvalidRequestError("anthropic reasoning compatibility fields are only supported on assistant messages", nil) + } + } + if hasAnthropicBreakingReasoningCompatFields(msg.ExtraFields) && !anthropicThinkingSignaturesCompatEnabled() { + return nil, core.NewInvalidRequestError( + "anthropic reasoning compatibility fields require "+openAICompatBreakingAnthropicThinkingSignaturesEnv+" to be enabled", + nil, + ) + } + if msg.Role == "tool" { toolUseID := strings.TrimSpace(msg.ToolCallID) if toolUseID == "" { diff --git a/internal/providers/responses_adapter.go b/internal/providers/responses_adapter.go index 99917fa2..b1844b7f 100644 --- a/internal/providers/responses_adapter.go +++ b/internal/providers/responses_adapter.go @@ -467,11 +467,8 @@ func convertResponsesInputElement(item core.ResponsesInputElement, index int, op if role == "" { return core.Message{}, "", core.NewInvalidRequestError(fmt.Sprintf("invalid responses input item at index %d: role is required", index), nil) } - if !opts.PreserveAnthropicReasoningCompat && containsAnthropicReasoningCompatFields(item.ExtraFields) { - return core.Message{}, "", core.NewInvalidRequestError( - fmt.Sprintf("invalid responses input item at index %d: anthropic reasoning compatibility fields require provider-specific reasoning compatibility", index), - nil, - ) + if err := validateResponsesMessageAnthropicReasoningCompat(index, role, item.ExtraFields, opts); err != nil { + return core.Message{}, "", err } content, ok := ConvertResponsesContentToChatContent(item.Content) if !ok { @@ -557,11 +554,8 @@ func convertResponsesInputMap(item map[string]any, index int, opts ResponsesToCh return core.Message{}, "", core.NewInvalidRequestError(fmt.Sprintf("invalid responses input item at index %d: role is required", index), nil) } extraFields := core.UnknownJSONFieldsFromMap(rawJSONMapFromUnknownKeys(item, "type", "role", "status", "content")) - if !opts.PreserveAnthropicReasoningCompat && containsAnthropicReasoningCompatFields(extraFields) { - return core.Message{}, "", core.NewInvalidRequestError( - fmt.Sprintf("invalid responses input item at index %d: anthropic reasoning compatibility fields require provider-specific reasoning compatibility", index), - nil, - ) + if err := validateResponsesMessageAnthropicReasoningCompat(index, role, extraFields, opts); err != nil { + return core.Message{}, "", err } content, ok := ConvertResponsesContentToChatContent(item["content"]) @@ -584,6 +578,126 @@ func containsAnthropicReasoningCompatFields(fields core.UnknownJSONFields) bool return false } +func validateResponsesMessageAnthropicReasoningCompat(index int, role string, fields core.UnknownJSONFields, opts ResponsesToChatOptions) error { + if !containsAnthropicReasoningCompatFields(fields) { + return nil + } + if !opts.PreserveAnthropicReasoningCompat { + return core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: anthropic reasoning compatibility fields require provider-specific reasoning compatibility", index), + nil, + ) + } + if role != "assistant" { + return core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: anthropic reasoning compatibility fields are only supported on assistant messages", index), + nil, + ) + } + return validateAnthropicReasoningCompatPayload(index, fields) +} + +func validateAnthropicReasoningCompatPayload(index int, fields core.UnknownJSONFields) error { + if raw := fields.Lookup("reasoning_details"); len(raw) > 0 { + details, err := parseAnthropicReasoningCompatDetails(raw) + if err != nil { + return core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: message.reasoning_details must be an array of reasoning_text objects", index), + err, + ) + } + if len(details) == 0 { + return core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: message.reasoning_details must contain at least one non-empty reasoning_text object", index), + nil, + ) + } + return nil + } + + reasoningContentRaw := fields.Lookup("reasoning_content") + reasoningSignatureRaw := fields.Lookup("reasoning_signature") + if len(reasoningContentRaw) == 0 && len(reasoningSignatureRaw) == 0 { + return nil + } + if len(reasoningContentRaw) == 0 { + return core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: message.reasoning_signature requires message.reasoning_content", index), + nil, + ) + } + if len(reasoningSignatureRaw) == 0 { + return core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: message.reasoning_content requires message.reasoning_signature", index), + nil, + ) + } + + var reasoningContent string + if err := json.Unmarshal(reasoningContentRaw, &reasoningContent); err != nil { + return core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: message.reasoning_content must be a string", index), + err, + ) + } + + var reasoningSignature string + if err := json.Unmarshal(reasoningSignatureRaw, &reasoningSignature); err != nil { + return core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: message.reasoning_signature must be a string", index), + err, + ) + } + + if strings.TrimSpace(reasoningContent) == "" { + return core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: message.reasoning_content must be a non-empty string", index), + nil, + ) + } + if strings.TrimSpace(reasoningSignature) == "" { + return core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: message.reasoning_signature must be a non-empty string", index), + nil, + ) + } + + return nil +} + +func parseAnthropicReasoningCompatDetails(raw json.RawMessage) ([]responsesReasoningDetail, error) { + var details []responsesReasoningDetail + if err := json.Unmarshal(raw, &details); err != nil { + return nil, err + } + + normalized := make([]responsesReasoningDetail, 0, len(details)) + for _, detail := range details { + if strings.TrimSpace(detail.Text) == "" { + continue + } + + detailType := strings.TrimSpace(detail.Type) + if detailType == "" { + detailType = "reasoning_text" + } + if detailType != "reasoning_text" && detailType != "thinking" { + return nil, fmt.Errorf("unsupported reasoning_details type %q", detailType) + } + + normalized = append(normalized, responsesReasoningDetail{ + Type: "reasoning_text", + Text: detail.Text, + Signature: strings.TrimSpace(detail.Signature), + }) + } + + if len(normalized) == 0 { + return nil, nil + } + return normalized, nil +} + func cloneResponsesMessage(msg core.Message) core.Message { cloned := msg if len(msg.ToolCalls) > 0 { @@ -604,13 +718,16 @@ func cloneResponsesMessage(msg core.Message) core.Message { } func canMergeAssistantMessages(current, next core.Message) bool { + if isAssistantToolCallOnlyMessage(next) { + return next.ExtraFields.IsEmpty() + } if !current.ExtraFields.IsEmpty() || !next.ExtraFields.IsEmpty() { return false } if !core.HasStructuredContent(current.Content) && !core.HasStructuredContent(next.Content) { return true } - return isAssistantToolCallOnlyMessage(next) + return false } func mergeAssistantMessage(dst *core.Message, src core.Message) { diff --git a/internal/providers/responses_adapter_test.go b/internal/providers/responses_adapter_test.go index 94a146d6..a37fe9b4 100644 --- a/internal/providers/responses_adapter_test.go +++ b/internal/providers/responses_adapter_test.go @@ -487,6 +487,78 @@ func TestConvertResponsesRequestToChat_RejectsAnthropicReasoningCompatFieldsByDe } } +func TestConvertResponsesRequestToChatWithAnthropicCompat_RejectsInvalidMessageReasoningCompatFields(t *testing.T) { + tests := []struct { + name string + input any + wantMessage string + }{ + { + name: "typed assistant invalid reasoning_details", + input: []core.ResponsesInputElement{ + { + Type: "message", + Role: "assistant", + Content: "Hello", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_details": json.RawMessage(`[{"type":"output_text","text":"bad"}]`), + }), + }, + }, + wantMessage: "message.reasoning_details must be an array of reasoning_text objects", + }, + { + name: "map assistant missing reasoning_signature", + input: []any{ + map[string]any{ + "type": "message", + "role": "assistant", + "content": "Hello", + "reasoning_content": "Let me think.", + }, + }, + wantMessage: "message.reasoning_content requires message.reasoning_signature", + }, + { + name: "typed non-assistant reasoning fields", + input: []core.ResponsesInputElement{ + { + Type: "message", + Role: "user", + Content: "Hello", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_details": json.RawMessage(`[{"type":"reasoning_text","text":"Let me think.","signature":"sig_123"}]`), + }), + }, + }, + wantMessage: "anthropic reasoning compatibility fields are only supported on assistant messages", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := convertResponsesRequestToChatWithAnthropicCompat(&core.ResponsesRequest{ + Model: "test-model", + Input: tt.input, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) { + t.Fatalf("error = %T, want *core.GatewayError", err) + } + if gatewayErr.Type != core.ErrorTypeInvalidRequest { + t.Fatalf("GatewayError.Type = %q, want %q", gatewayErr.Type, core.ErrorTypeInvalidRequest) + } + if !strings.Contains(err.Error(), tt.wantMessage) { + t.Fatalf("error = %v, want substring %q", err, tt.wantMessage) + } + }) + } +} + func TestConvertResponsesRequestToChatWithAnthropicCompat_MergesReasoningItemIntoFollowingAssistantMessage(t *testing.T) { req := &core.ResponsesRequest{ Model: "test-model", @@ -803,6 +875,55 @@ func TestConvertResponsesRequestToChatWithAnthropicCompat_PreservesReasoningExtr } } +func TestConvertResponsesRequestToChatWithAnthropicCompat_MergesToolCallsAfterReasoningIntoSameAssistantTurn(t *testing.T) { + req := &core.ResponsesRequest{ + Model: "test-model", + Input: []core.ResponsesInputElement{ + { + Type: "reasoning", + Content: []core.ResponsesContentItem{ + { + Type: "reasoning_text", + Text: "Let me think.", + Signature: "sig_123", + }, + }, + }, + { + Type: "message", + Role: "assistant", + Content: "Hello", + }, + { + Type: "function_call", + CallID: "call_123", + Name: "lookup_weather", + Arguments: `{"city":"Warsaw"}`, + }, + }, + } + + chatReq, err := convertResponsesRequestToChatWithAnthropicCompat(req) + if err != nil { + t.Fatalf("convertResponsesRequestToChatWithAnthropicCompat() error = %v", err) + } + if len(chatReq.Messages) != 1 { + t.Fatalf("len(Messages) = %d, want 1", len(chatReq.Messages)) + } + if got := core.ExtractTextContent(chatReq.Messages[0].Content); got != "Hello" { + t.Fatalf("Messages[0].Content = %q, want Hello", got) + } + if len(chatReq.Messages[0].ToolCalls) != 1 { + t.Fatalf("len(Messages[0].ToolCalls) = %d, want 1", len(chatReq.Messages[0].ToolCalls)) + } + if chatReq.Messages[0].ToolCalls[0].Function.Name != "lookup_weather" { + t.Fatalf("ToolCalls[0].Function.Name = %q, want lookup_weather", chatReq.Messages[0].ToolCalls[0].Function.Name) + } + if chatReq.Messages[0].ExtraFields.Lookup("reasoning_details") == nil { + t.Fatal("reasoning_details missing after tool-call merge") + } +} + func TestConvertResponsesRequestToChat_RejectsWhitespaceOnlyMediaFields(t *testing.T) { tests := []struct { name string