diff --git a/config.example.yaml b/config.example.yaml index 50f14509..1e3f1301 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -849,6 +849,11 @@ nonstream-keepalive-interval: 0 # protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity # params: # JSON path (gjson/sjson syntax) -> value # "reasoning.effort": "high" +# - models: +# - name: "claude-fable-5-1" +# protocol: "claude" +# params: +# max_tokens: 128000 # override the final Claude Messages output budget after protocol translation # override-raw: # Override raw rules always set parameters using raw JSON (must be valid JSON). # - models: # - name: "gpt-*" # Supports wildcards (e.g., "gpt-*") diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 769b869b..2cb19ebf 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -6117,9 +6117,46 @@ func TestClaudeExecutorPayloadOverrideReenablesThinking(t *testing.T) { } } -func executeClaudeContextManagementRequest(t *testing.T, cfg *config.Config, payload []byte, stream bool) []byte { +func TestClaudeExecutorPayloadOverrideMaxTokens(t *testing.T) { + const model = "claude-fable-5-1" + for _, test := range []struct { + name string + stream bool + maxOutputTokens int + }{ + {name: "execute"}, + {name: "execute stream", stream: true}, + {name: "execute overrides client limit", maxOutputTokens: 32000}, + {name: "execute stream overrides client limit", stream: true, maxOutputTokens: 32000}, + } { + t.Run(test.name, func(t *testing.T) { + cfg := &config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: model, Protocol: "claude"}}, + Params: map[string]any{"max_tokens": 128000}, + }}}} + payload := []byte(`{"model":"claude-fable-5-1","input":"hi"}`) + if test.maxOutputTokens > 0 { + payload, _ = sjson.SetBytes(payload, "max_output_tokens", test.maxOutputTokens) + } + upstreamBody := executeClaudeContextManagementRequest(t, cfg, payload, test.stream, sdktranslator.FormatOpenAIResponse) + if got := gjson.GetBytes(upstreamBody, "max_tokens").Int(); got != 128000 { + t.Fatalf("final upstream max_tokens = %d, want 128000; body=%s", got, upstreamBody) + } + }) + } +} + +func executeClaudeContextManagementRequest(t *testing.T, cfg *config.Config, payload []byte, stream bool, sourceFormats ...sdktranslator.Format) []byte { t.Helper() + sourceFormat := sdktranslator.FormatClaude + if len(sourceFormats) > 0 { + sourceFormat = sourceFormats[0] + } + model := gjson.GetBytes(payload, "model").String() + if model == "" { + model = "claude-opus-5" + } var upstreamBody []byte transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { var errRead error @@ -6143,8 +6180,8 @@ func executeClaudeContextManagementRequest(t *testing.T, cfg *config.Config, pay ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) executor := NewClaudeExecutor(cfg) auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-payload-rule", "cloak_mode": "always"}} - request := cliproxyexecutor.Request{Model: "claude-opus-5", Payload: payload} - options := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude} + request := cliproxyexecutor.Request{Model: model, Payload: payload} + options := cliproxyexecutor.Options{SourceFormat: sourceFormat, ResponseFormat: sdktranslator.FormatClaude} if stream { result, errStream := executor.ExecuteStream(ctx, auth, request, options) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 0fefe15f..6a2f6370 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -1,7 +1,9 @@ package responses import ( + "strconv" "strings" + "unicode/utf16" log "github.com/sirupsen/logrus" @@ -14,6 +16,11 @@ import ( "github.com/tidwall/sjson" ) +const ( + defaultClaudeResponsesMaxTokens = 32000 + defaultFableResponsesMaxTokens = 64000 +) + // ConvertOpenAIResponsesRequestToClaude transforms an OpenAI Responses API request // into a Claude Messages API request using only gjson/sjson for JSON handling. // It supports: @@ -43,6 +50,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte // Base Claude message payload out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`) out, _ = sjson.SetBytes(out, "metadata.user_id", userID) + out, _ = sjson.SetBytes(out, "max_tokens", defaultClaudeResponsesMaxTokensForModel(modelName)) root := gjson.ParseBytes(rawJSON) @@ -99,8 +107,12 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte out, _ = sjson.SetBytes(out, "model", modelName) // Max tokens - if mot := root.Get("max_output_tokens"); mot.Exists() { - out, _ = sjson.SetBytes(out, "max_tokens", mot.Int()) + if mot := root.Get("max_output_tokens"); mot.Exists() && mot.Type != gjson.Null { + val := mot.Int() + if info := registry.LookupModelInfo(modelName, "claude"); info != nil && info.MaxCompletionTokens > 0 && val > int64(info.MaxCompletionTokens) { + val = int64(info.MaxCompletionTokens) + } + out, _ = sjson.SetBytes(out, "max_tokens", val) } // Stream @@ -568,6 +580,17 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte return out } +func defaultClaudeResponsesMaxTokensForModel(modelName string) int { + maxTokens := defaultClaudeResponsesMaxTokens + if strings.Contains(strings.ToLower(strings.TrimSpace(modelName)), "fable") { + maxTokens = defaultFableResponsesMaxTokens + } + if info := registry.LookupModelInfo(modelName, "claude"); info != nil && info.MaxCompletionTokens > 0 && info.MaxCompletionTokens < maxTokens { + return info.MaxCompletionTokens + } + return maxTokens +} + // isResponsesSystemLevelRole reports whether an input item carries system-level // authority. The Responses API ranks developer and system instructions above // user content, so both map to Claude's system slot rather than a user turn. @@ -1060,12 +1083,77 @@ func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} { } func unwrapCustomToolInput(arguments string) string { - if v := gjson.Get(arguments, "input"); v.Exists() { + trimmed := strings.TrimSpace(arguments) + if v := gjson.Get(trimmed, "input"); v.Exists() { if v.Type == gjson.String { return v.String() } return v.Raw } + idx := strings.Index(trimmed, `"input"`) + if idx >= 0 { + rest := strings.TrimSpace(trimmed[idx+7:]) + if strings.HasPrefix(rest, ":") { + rest = strings.TrimSpace(rest[1:]) + if strings.HasPrefix(rest, `"`) { + content := rest[1:] + var unescaped strings.Builder + inEscape := false + for i := 0; i < len(content); i++ { + c := content[i] + if inEscape { + switch c { + case '"', '\\', '/': + unescaped.WriteByte(c) + case 'b': + unescaped.WriteByte('\b') + case 'f': + unescaped.WriteByte('\f') + case 'n': + unescaped.WriteByte('\n') + case 'r': + unescaped.WriteByte('\r') + case 't': + unescaped.WriteByte('\t') + case 'u': + if i+4 < len(content) { + if r, err := strconv.ParseUint(content[i+1:i+5], 16, 16); err == nil { + if utf16.IsSurrogate(rune(r)) && i+10 < len(content) && content[i+5:i+7] == `\u` { + if r2, err2 := strconv.ParseUint(content[i+7:i+11], 16, 16); err2 == nil { + unescaped.WriteRune(utf16.DecodeRune(rune(r), rune(r2))) + i += 10 + inEscape = false + continue + } + } + unescaped.WriteRune(rune(r)) + i += 4 + inEscape = false + continue + } + } + unescaped.WriteByte('\\') + unescaped.WriteByte('u') + default: + unescaped.WriteByte('\\') + unescaped.WriteByte(c) + } + inEscape = false + } else if c == '\\' { + inEscape = true + } else if c == '"' { + break + } else { + unescaped.WriteByte(c) + } + } + if inEscape { + unescaped.WriteByte('\\') + } + return unescaped.String() + } + } + } return arguments } diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go index 3cb58404..e61e0b1d 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -42,6 +42,63 @@ func TestConvertOpenAIResponsesRequestToClaude_SanitizesToolCallIDsForClaude(t * } } +func TestConvertOpenAIResponsesRequestToClaude_FableMaxTokens(t *testing.T) { + t.Run("defaults to 64k", func(t *testing.T) { + out := ConvertOpenAIResponsesRequestToClaude( + "claude-fable-5-1", + []byte(`{"model":"claude-fable-5-1","input":"hello"}`), + true, + ) + if got := gjson.GetBytes(out, "max_tokens").Int(); got != 64000 { + t.Fatalf("max_tokens = %d, want %d; output=%s", got, 64000, out) + } + }) + + t.Run("preserves explicit 128k limit", func(t *testing.T) { + out := ConvertOpenAIResponsesRequestToClaude( + "claude-fable-5-1", + []byte(`{"model":"claude-fable-5-1","max_output_tokens":128000,"input":"hello"}`), + true, + ) + if got := gjson.GetBytes(out, "max_tokens").Int(); got != 128000 { + t.Fatalf("max_tokens = %d, want 128000; output=%s", got, out) + } + }) + + t.Run("does not exceed registered model maximum", func(t *testing.T) { + out := ConvertOpenAIResponsesRequestToClaude( + "claude-3-5-haiku-20241022", + []byte(`{"model":"claude-3-5-haiku-20241022","input":"hello"}`), + true, + ) + if got := gjson.GetBytes(out, "max_tokens").Int(); got != 8192 { + t.Fatalf("max_tokens = %d, want 8192; output=%s", got, out) + } + }) + + t.Run("clamps explicit limit exceeding registered model maximum", func(t *testing.T) { + out := ConvertOpenAIResponsesRequestToClaude( + "claude-3-5-haiku-20241022", + []byte(`{"model":"claude-3-5-haiku-20241022","max_output_tokens":128000,"input":"hello"}`), + true, + ) + if got := gjson.GetBytes(out, "max_tokens").Int(); got != 8192 { + t.Fatalf("max_tokens = %d, want 8192; output=%s", got, out) + } + }) + + t.Run("null max_output_tokens retains default 64k", func(t *testing.T) { + out := ConvertOpenAIResponsesRequestToClaude( + "claude-fable-5-1", + []byte(`{"model":"claude-fable-5-1","max_output_tokens":null,"input":"hello"}`), + true, + ) + if got := gjson.GetBytes(out, "max_tokens").Int(); got != 64000 { + t.Fatalf("max_tokens = %d, want 64000; output=%s", got, out) + } + }) +} + func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *testing.T) { rawSignature, expectedSignature := testClaudeResponsesThinkingSignature(t) raw := []byte(`{ diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index b9de9cd3..faf6658f 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -27,6 +27,9 @@ type claudeToResponsesState struct { ContentPartOpen bool MessageOutputIndex int FuncArgsBuf map[int]*strings.Builder // index -> args + FuncArgsDone map[int]bool + FuncItemDone map[int]bool + FuncItemStatus map[int]string // function call bookkeeping for output aggregation FuncNames map[int]string // Claude block index -> function name FuncCallIDs map[int]string // Claude block index -> call id @@ -38,17 +41,19 @@ type claudeToResponsesState struct { MessageAnnotations []any MessageItems []claudeResponsesMessageItem // reasoning state - ReasoningActive bool - ReasoningItemID string - ReasoningBuf strings.Builder - ReasoningSignature string - ReasoningIndex int - ReasoningItems []claudeResponsesReasoningItem + ReasoningActive bool + ReasoningDeltasDone bool + ReasoningItemID string + ReasoningBuf strings.Builder + ReasoningSignature string + ReasoningIndex int + ReasoningItems []claudeResponsesReasoningItem // server-side web search state: a Claude server_tool_use block and its // web_search_tool_result block fold into one Responses web_search_call item. WebSearchByBlock map[int]*claudeResponsesWebSearchItem WebSearchByToolID map[string]*claudeResponsesWebSearchItem WebSearchItems []*claudeResponsesWebSearchItem + StopReason string // usage aggregation Usage claudeResponsesUsageTokens } @@ -59,6 +64,7 @@ type claudeResponsesWebSearchItem struct { InputBuf strings.Builder Results []byte Emitted bool + Status string } func (item *claudeResponsesWebSearchItem) render() []byte { @@ -70,6 +76,7 @@ type claudeResponsesMessageItem struct { OutputIndex int Text string Annotations []any + Status string } type claudeResponsesReasoningItem struct { @@ -77,6 +84,7 @@ type claudeResponsesReasoningItem struct { OutputIndex int Text string Signature string + Status string } type claudeResponsesUsageTokens struct { @@ -143,6 +151,28 @@ func (u claudeResponsesUsageTokens) OpenAIResponsesUsage() (inputTokens, outputT return inputTokens, outputTokens, totalTokens, cachedTokens } +func claudeResponsesIncompleteDetails(stopReason string) ([]byte, bool) { + if strings.EqualFold(strings.TrimSpace(stopReason), "max_tokens") { + return []byte(`{"reason":"max_output_tokens"}`), true + } + return nil, false +} + +func claudeResponsesOutputStatus(stopReason string) string { + if _, incomplete := claudeResponsesIncompleteDetails(stopReason); incomplete { + return "incomplete" + } + return "completed" +} + +func claudeResponsesTerminalState(stopReason string) (eventType, status string, incompleteDetails []byte) { + incompleteDetails, incomplete := claudeResponsesIncompleteDetails(stopReason) + if incomplete { + return "response.incomplete", "incomplete", incompleteDetails + } + return "response.completed", "completed", nil +} + func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte { if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) { return originalRequestRawJSON @@ -208,26 +238,167 @@ func (st *claudeToResponsesState) startWebSearch(blockIndex int, toolUseID strin return item } -// finalizeWebSearch emits output_item.done once the result block has been seen, +// finalizeWebSearchWithStatus emits output_item.done once the result block has been seen, // or at message_stop when the turn ended without one. -func (st *claudeToResponsesState) finalizeWebSearch(item *claudeResponsesWebSearchItem, nextSeq func() int) [][]byte { +func (st *claudeToResponsesState) finalizeWebSearchWithStatus(item *claudeResponsesWebSearchItem, status string, nextSeq func() int) [][]byte { if item == nil || item.Emitted { return nil } item.Emitted = true + item.Status = status done := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`) done, _ = sjson.SetBytes(done, "sequence_number", nextSeq()) done, _ = sjson.SetBytes(done, "output_index", item.OutputIndex) - done, _ = sjson.SetRawBytes(done, "item", item.render()) + rendered := item.render() + rendered, _ = sjson.SetBytes(rendered, "status", status) + done, _ = sjson.SetRawBytes(done, "item", rendered) return [][]byte{emitEvent("response.output_item.done", done)} } +func (st *claudeToResponsesState) finalizeFuncItem(idx int, requestForToolMetadata []byte, status string, nextSeq func() int) [][]byte { + if st.FuncItemDone[idx] { + return nil + } + if st.FuncItemDone == nil { + st.FuncItemDone = make(map[int]bool) + } + st.FuncItemDone[idx] = true + if st.FuncItemStatus == nil { + st.FuncItemStatus = make(map[int]string) + } + st.FuncItemStatus[idx] = status + + outputIndex := st.functionOutputIndex(idx) + args := "" + if buf := st.FuncArgsBuf[idx]; buf != nil { + if buf.Len() > 0 { + args = buf.String() + } + } + if !st.FuncCustom[idx] && args == "" && status == "completed" { + args = "{}" + } + callID := st.FuncCallIDs[idx] + if callID == "" { + callID = st.CurrentFCID + } + name := st.FuncNames[idx] + + var out [][]byte + if st.FuncCustom[idx] { + input := unwrapCustomToolInput(args) + if !st.FuncArgsDone[idx] { + if st.FuncArgsDone == nil { + st.FuncArgsDone = make(map[int]bool) + } + st.FuncArgsDone[idx] = true + inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) + inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq()) + inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", callID)) + inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex) + inputDone, _ = sjson.SetBytes(inputDone, "input", input) + out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone)) + } + + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`) + itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex) + itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", callID)) + itemDone, _ = sjson.SetBytes(itemDone, "item.status", status) + itemDone, _ = sjson.SetBytes(itemDone, "item.input", input) + itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID) + itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForToolMetadata, name, "item") + out = append(out, emitEvent("response.output_item.done", itemDone)) + } else { + if !st.FuncArgsDone[idx] { + if st.FuncArgsDone == nil { + st.FuncArgsDone = make(map[int]bool) + } + st.FuncArgsDone[idx] = true + fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`) + fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq()) + fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", callID)) + fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex) + fcDone, _ = sjson.SetBytes(fcDone, "arguments", args) + out = append(out, emitEvent("response.function_call_arguments.done", fcDone)) + } + + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`) + itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex) + itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID)) + itemDone, _ = sjson.SetBytes(itemDone, "item.status", status) + itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args) + itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID) + itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForToolMetadata, name, "item") + out = append(out, emitEvent("response.output_item.done", itemDone)) + } + st.InFuncBlock = false + return out +} + +func (st *claudeToResponsesState) finalizeReasoningDeltas(nextSeq func() int) [][]byte { + if !st.ReasoningActive || st.ReasoningDeltasDone { + return nil + } + st.ReasoningDeltasDone = true + full := st.ReasoningBuf.String() + var out [][]byte + textDone := []byte(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`) + textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextSeq()) + textDone, _ = sjson.SetBytes(textDone, "item_id", st.ReasoningItemID) + textDone, _ = sjson.SetBytes(textDone, "output_index", st.ReasoningIndex) + textDone, _ = sjson.SetBytes(textDone, "text", full) + out = append(out, emitEvent("response.reasoning_summary_text.done", textDone)) + partDone := []byte(`{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`) + partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq()) + partDone, _ = sjson.SetBytes(partDone, "item_id", st.ReasoningItemID) + partDone, _ = sjson.SetBytes(partDone, "output_index", st.ReasoningIndex) + partDone, _ = sjson.SetBytes(partDone, "part.text", full) + out = append(out, emitEvent("response.reasoning_summary_part.done", partDone)) + return out +} + +func (st *claudeToResponsesState) finalizeReasoningItem(status string, nextSeq func() int) [][]byte { + if !st.ReasoningActive && st.ReasoningItemID == "" { + return nil + } + var out [][]byte + out = append(out, st.finalizeReasoningDeltas(nextSeq)...) + + full := st.ReasoningBuf.String() + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"completed","encrypted_content":"","summary":[]}}`) + itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", st.ReasoningIndex) + itemDone, _ = sjson.SetBytes(itemDone, "item.id", st.ReasoningItemID) + itemDone, _ = sjson.SetBytes(itemDone, "item.status", status) + itemDone, _ = sjson.SetBytes(itemDone, "item.encrypted_content", st.ReasoningSignature) + summary := []byte(`{"type":"summary_text","text":""}`) + summary, _ = sjson.SetBytes(summary, "text", full) + itemDone = translatorcommon.SetRawArrayItems(itemDone, "item.summary", [][]byte{summary}) + out = append(out, emitEvent("response.output_item.done", itemDone)) + st.ReasoningItems = append(st.ReasoningItems, claudeResponsesReasoningItem{ + ID: st.ReasoningItemID, + OutputIndex: st.ReasoningIndex, + Text: full, + Signature: st.ReasoningSignature, + Status: status, + }) + st.ReasoningActive = false + st.ReasoningItemID = "" + st.ReasoningBuf.Reset() + st.ReasoningSignature = "" + st.ReasoningIndex = -1 + return out +} + func (st *claudeToResponsesState) finalizeAssistantMessage(nextSeq func() int) [][]byte { if !st.MessageOpen { return nil } fullText := st.TextBuf.String() outputIndex := st.messageOutputIndex() + status := claudeResponsesOutputStatus(st.StopReason) var out [][]byte done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`) done, _ = sjson.SetBytes(done, "sequence_number", nextSeq()) @@ -250,6 +421,7 @@ func (st *claudeToResponsesState) finalizeAssistantMessage(nextSeq func() int) [ final, _ = sjson.SetBytes(final, "sequence_number", nextSeq()) final, _ = sjson.SetBytes(final, "output_index", outputIndex) final, _ = sjson.SetBytes(final, "item.id", st.CurrentMsgID) + final, _ = sjson.SetBytes(final, "item.status", status) final, _ = sjson.SetBytes(final, "item.content.0.text", fullText) if len(st.MessageAnnotations) > 0 { final, _ = sjson.SetBytes(final, "item.content.0.annotations", st.MessageAnnotations) @@ -261,6 +433,7 @@ func (st *claudeToResponsesState) finalizeAssistantMessage(nextSeq func() int) [ OutputIndex: outputIndex, Text: fullText, Annotations: append([]any(nil), st.MessageAnnotations...), + Status: status, }) st.InTextBlock = false st.MessageOpen = false @@ -315,6 +488,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.MessageItems = nil st.ReasoningBuf.Reset() st.ReasoningActive = false + st.ReasoningDeltasDone = false st.NextOutputIndex = 0 st.InTextBlock = false st.InFuncBlock = false @@ -327,7 +501,11 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.ReasoningSignature = "" st.ReasoningIndex = -1 st.ReasoningItems = nil + st.StopReason = "" st.FuncArgsBuf = make(map[int]*strings.Builder) + st.FuncArgsDone = make(map[int]bool) + st.FuncItemDone = make(map[int]bool) + st.FuncItemStatus = make(map[int]string) st.FuncNames = make(map[int]string) st.FuncCallIDs = make(map[int]string) st.FuncCustom = make(map[int]bool) @@ -364,6 +542,26 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } idx := int(root.Get("index").Int()) typ := cb.Get("type").String() + + // Finalize any previous assistant message + out = append(out, st.finalizeAssistantMessage(nextSeq)...) + // Finalize previous reasoning item + if st.ReasoningActive || st.ReasoningItemID != "" { + out = append(out, st.finalizeReasoningItem("completed", nextSeq)...) + } + // Finalize any previous completed function calls + for prevIdx := range st.FuncCallIDs { + if !st.FuncItemDone[prevIdx] && prevIdx != idx { + out = append(out, st.finalizeFuncItem(prevIdx, requestForToolMetadata, "completed", nextSeq)...) + } + } + // Finalize any previous web search items + for _, item := range st.WebSearchItems { + if !item.Emitted && item.Results != nil { + out = append(out, st.finalizeWebSearchWithStatus(item, "completed", nextSeq)...) + } + } + if typ == "text" { st.InTextBlock = true outputIndex := st.messageOutputIndex() @@ -387,7 +585,6 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.ContentPartOpen = true } } else if typ == "tool_use" { - out = append(out, st.finalizeAssistantMessage(nextSeq)...) st.InFuncBlock = true st.CurrentFCID = cb.Get("id").String() name := cb.Get("name").String() @@ -419,7 +616,6 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.FuncCallIDs[idx] = st.CurrentFCID st.FuncNames[idx] = name } else if typ == "server_tool_use" { - out = append(out, st.finalizeAssistantMessage(nextSeq)...) if name := cb.Get("name").String(); name != claudeWebSearchToolName { // Reachability guard: only web_search can be enabled on Claude by // this translator, so anything else means a new upstream tool. @@ -433,19 +629,18 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin out = append(out, emitEvent("response.output_item.added", added)) } } else if typ == "web_search_tool_result" { - // Unlike text or tool_use blocks, a result block carries its full - // content up front and has no deltas, so the item is closed here - // rather than at content_block_stop. + // A result block carries its full content up front and has no deltas. + // Results are stored here and the item is closed when the next block + // starts or at message_stop so the final stop_reason is respected. if item := st.WebSearchByToolID[cb.Get("tool_use_id").String()]; item != nil { item.Results = claudeWebSearchResultsToResponses(cb.Get("content")) - out = append(out, st.finalizeWebSearch(item, nextSeq)...) } else { log.Debugf("claude->responses: web_search_tool_result without matching server_tool_use at block %d", idx) } } else if typ == "thinking" || typ == "redacted_thinking" { - out = append(out, st.finalizeAssistantMessage(nextSeq)...) // start reasoning item st.ReasoningActive = true + st.ReasoningDeltasDone = false st.ReasoningIndex = st.allocateOutputIndex() st.ReasoningBuf.Reset() st.ReasoningSignature = claudeReasoningCarrier(cb) @@ -534,103 +729,47 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin return [][]byte{} } case "content_block_stop": - idx := int(root.Get("index").Int()) if st.InTextBlock { st.InTextBlock = false } else if st.InFuncBlock { - outputIndex := st.functionOutputIndex(idx) - args := "{}" - if st.FuncCustom[idx] { - args = "" - } - if buf := st.FuncArgsBuf[idx]; buf != nil { - if buf.Len() > 0 { - args = buf.String() - } - } - if st.FuncCustom[idx] { - input := unwrapCustomToolInput(args) - inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) - inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq()) - inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", st.CurrentFCID)) - inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex) - inputDone, _ = sjson.SetBytes(inputDone, "input", input) - out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone)) - - itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`) - itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) - itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex) - itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", st.CurrentFCID)) - itemDone, _ = sjson.SetBytes(itemDone, "item.input", input) - itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.CurrentFCID) - itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForToolMetadata, st.FuncNames[idx], "item") - out = append(out, emitEvent("response.output_item.done", itemDone)) - } else { - fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`) - fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq()) - fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.CurrentFCID)) - fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex) - fcDone, _ = sjson.SetBytes(fcDone, "arguments", args) - out = append(out, emitEvent("response.function_call_arguments.done", fcDone)) - itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`) - itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) - itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex) - itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID)) - itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args) - itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.CurrentFCID) - itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForToolMetadata, st.FuncNames[idx], "item") - out = append(out, emitEvent("response.output_item.done", itemDone)) - } st.InFuncBlock = false } else if st.ReasoningActive { - full := st.ReasoningBuf.String() - textDone := []byte(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`) - textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextSeq()) - textDone, _ = sjson.SetBytes(textDone, "item_id", st.ReasoningItemID) - textDone, _ = sjson.SetBytes(textDone, "output_index", st.ReasoningIndex) - textDone, _ = sjson.SetBytes(textDone, "text", full) - out = append(out, emitEvent("response.reasoning_summary_text.done", textDone)) - partDone := []byte(`{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`) - partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq()) - partDone, _ = sjson.SetBytes(partDone, "item_id", st.ReasoningItemID) - partDone, _ = sjson.SetBytes(partDone, "output_index", st.ReasoningIndex) - partDone, _ = sjson.SetBytes(partDone, "part.text", full) - out = append(out, emitEvent("response.reasoning_summary_part.done", partDone)) - itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","encrypted_content":"","summary":[]}}`) - itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) - itemDone, _ = sjson.SetBytes(itemDone, "item.id", st.ReasoningItemID) - itemDone, _ = sjson.SetBytes(itemDone, "output_index", st.ReasoningIndex) - itemDone, _ = sjson.SetBytes(itemDone, "item.encrypted_content", st.ReasoningSignature) - summary := []byte(`{"type":"summary_text","text":""}`) - summary, _ = sjson.SetBytes(summary, "text", full) - itemDone = translatorcommon.SetRawArrayItems(itemDone, "item.summary", [][]byte{summary}) - out = append(out, emitEvent("response.output_item.done", itemDone)) - st.ReasoningItems = append(st.ReasoningItems, claudeResponsesReasoningItem{ - ID: st.ReasoningItemID, - OutputIndex: st.ReasoningIndex, - Text: full, - Signature: st.ReasoningSignature, - }) - st.ReasoningActive = false - st.ReasoningItemID = "" - st.ReasoningBuf.Reset() - st.ReasoningSignature = "" - st.ReasoningIndex = -1 + out = append(out, st.finalizeReasoningDeltas(nextSeq)...) } return noSSEOutput(out) case "message_delta": st.Usage.Merge(root.Get("usage")) + if stopReason := root.Get("delta.stop_reason"); stopReason.Exists() { + st.StopReason = stopReason.String() + } return [][]byte{} case "message_stop": + toolStatus := claudeResponsesOutputStatus(st.StopReason) + if st.ReasoningActive || st.ReasoningItemID != "" { + out = append(out, st.finalizeReasoningItem(toolStatus, nextSeq)...) + } out = append(out, st.finalizeAssistantMessage(nextSeq)...) + for idx := range st.FuncCallIDs { + if !st.FuncItemDone[idx] { + out = append(out, st.finalizeFuncItem(idx, requestForToolMetadata, toolStatus, nextSeq)...) + } + } for _, item := range st.WebSearchItems { - out = append(out, st.finalizeWebSearch(item, nextSeq)...) + if !item.Emitted { + out = append(out, st.finalizeWebSearchWithStatus(item, toolStatus, nextSeq)...) + } } - completed := []byte(`{"type":"response.completed","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null}}`) + eventType, responseStatus, incompleteDetails := claudeResponsesTerminalState(st.StopReason) + completed := []byte(`{"type":"","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"","background":false,"error":null}}`) + completed, _ = sjson.SetBytes(completed, "type", eventType) completed, _ = sjson.SetBytes(completed, "sequence_number", nextSeq()) completed, _ = sjson.SetBytes(completed, "response.id", st.ResponseID) completed, _ = sjson.SetBytes(completed, "response.created_at", st.CreatedAt) + completed, _ = sjson.SetBytes(completed, "response.status", responseStatus) + if len(incompleteDetails) > 0 { + completed, _ = sjson.SetRawBytes(completed, "response.incomplete_details", incompleteDetails) + } // Inject original request fields into response as per docs/response.completed.json reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON) @@ -702,8 +841,13 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin outputsWrapper := []byte(`{"arr":[]}`) // reasoning items for _, reasoning := range st.ReasoningItems { - item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`) + status := reasoning.Status + if status == "" { + status = "completed" + } + item := []byte(`{"id":"","type":"reasoning","status":"completed","encrypted_content":"","summary":[]}`) item, _ = sjson.SetBytes(item, "id", reasoning.ID) + item, _ = sjson.SetBytes(item, "status", status) item, _ = sjson.SetBytes(item, "encrypted_content", reasoning.Signature) summary := []byte(`{"type":"summary_text","text":""}`) summary, _ = sjson.SetBytes(summary, "text", reasoning.Text) @@ -714,6 +858,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin for _, message := range st.MessageItems { item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`) item, _ = sjson.SetBytes(item, "id", message.ID) + item, _ = sjson.SetBytes(item, "status", message.Status) item, _ = sjson.SetBytes(item, "content.0.text", message.Text) if len(message.Annotations) > 0 { item, _ = sjson.SetBytes(item, "content.0.annotations", message.Annotations) @@ -722,7 +867,13 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } // web_search_call items for _, item := range st.WebSearchItems { - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", item.OutputIndex), item.render()) + status := item.Status + if status == "" { + status = "completed" + } + rendered := item.render() + rendered, _ = sjson.SetBytes(rendered, "status", status) + outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", item.OutputIndex), rendered) } // function_call items (in ascending index order for determinism) if len(st.FuncArgsBuf) > 0 { @@ -740,9 +891,13 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } } for _, idx := range idxs { - args := "{}" - if st.FuncCustom[idx] { - args = "" + status := st.FuncItemStatus[idx] + if status == "" { + status = "completed" + } + args := "" + if !st.FuncCustom[idx] && status == "completed" { + args = "{}" } if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 { args = b.String() @@ -755,6 +910,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin if st.FuncCustom[idx] { item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID)) + item, _ = sjson.SetBytes(item, "status", status) item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args)) item, _ = sjson.SetBytes(item, "call_id", callID) item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "") @@ -762,6 +918,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } else { item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID)) + item, _ = sjson.SetBytes(item, "status", status) item, _ = sjson.SetBytes(item, "arguments", args) item, _ = sjson.SetBytes(item, "call_id", callID) item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "") @@ -789,7 +946,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin completed, _ = sjson.SetBytes(completed, "response.usage.total_tokens", totalTokens) } } - out = append(out, emitEvent("response.completed", completed)) + out = append(out, emitEvent(eventType, completed)) } return noSSEOutput(out) @@ -831,6 +988,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string var ( responseID string createdAt int64 + stopReason string usageTokens claudeResponsesUsageTokens ) @@ -994,12 +1152,20 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string case "message_delta": usageTokens.Merge(root.Get("usage")) + if value := root.Get("delta.stop_reason"); value.Exists() { + stopReason = value.String() + } } } // Populate base fields + _, responseStatus, incompleteDetails := claudeResponsesTerminalState(stopReason) out, _ = sjson.SetBytes(out, "id", responseID) out, _ = sjson.SetBytes(out, "created_at", createdAt) + out, _ = sjson.SetBytes(out, "status", responseStatus) + if len(incompleteDetails) > 0 { + out, _ = sjson.SetRawBytes(out, "incomplete_details", incompleteDetails) + } // Inject request echo fields as top-level (similar to streaming variant) if len(reqBytes) > 0 { @@ -1068,21 +1234,28 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string // Build output array in the order of the original content blocks. outputs := make([][]byte, 0, len(outputItems)) - for _, outputItem := range outputItems { + for i, outputItem := range outputItems { + itemStatus := "completed" + if responseStatus == "incomplete" && i == len(outputItems)-1 { + itemStatus = "incomplete" + } var item []byte switch outputItem.itemType { case "reasoning": - item = []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`) + item = []byte(`{"id":"","type":"reasoning","status":"completed","encrypted_content":"","summary":[]}`) item, _ = sjson.SetBytes(item, "id", outputItem.id) + item, _ = sjson.SetBytes(item, "status", itemStatus) item, _ = sjson.SetBytes(item, "encrypted_content", outputItem.signature) summary := []byte(`{"type":"summary_text","text":""}`) summary, _ = sjson.SetBytes(summary, "text", outputItem.text.String()) item, _ = sjson.SetRawBytes(item, "summary", translatorcommon.JoinRawArray([][]byte{summary})) case "web_search_call": item = buildResponsesWebSearchCallItem(outputItem.callID, claudeWebSearchQuery(outputItem.args.String()), outputItem.results) + item, _ = sjson.SetBytes(item, "status", itemStatus) case "message": item = []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`) item, _ = sjson.SetBytes(item, "id", outputItem.id) + item, _ = sjson.SetBytes(item, "status", itemStatus) item, _ = sjson.SetBytes(item, "content.0.text", outputItem.text.String()) if len(outputItem.annotations) > 0 { item, _ = sjson.SetBytes(item, "content.0.annotations", outputItem.annotations) @@ -1091,17 +1264,19 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string if outputItem.itemType == "custom_tool_call" { item = []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", outputItem.id) + item, _ = sjson.SetBytes(item, "status", itemStatus) item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(outputItem.args.String())) item, _ = sjson.SetBytes(item, "call_id", outputItem.callID) item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, outputItem.name, "") break } args := outputItem.args.String() - if args == "" { + if args == "" && itemStatus == "completed" { args = "{}" } item = []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", outputItem.id) + item, _ = sjson.SetBytes(item, "status", itemStatus) item, _ = sjson.SetBytes(item, "arguments", args) item, _ = sjson.SetBytes(item, "call_id", outputItem.callID) item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, outputItem.name, "") diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go index 9c0b0a3e..5e0b36d7 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go @@ -1167,3 +1167,445 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_RestoresNamespaceFuncti t.Fatalf("non-stream output namespace = %q, want mcp__node_repl", got) } } + +func TestConvertClaudeResponseToOpenAIResponses_MaxTokensEmitsIncomplete(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_max_tokens","usage":{"input_tokens":10,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"unfinished reasoning"}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_max_tokens"}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":64000}}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var incomplete gjson.Result + for _, chunk := range chunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + if event == "response.completed" { + t.Fatalf("max_tokens response emitted response.completed: %s", output) + } + if event == "response.incomplete" { + incomplete = data + } + } + } + + if !incomplete.Exists() { + t.Fatal("expected response.incomplete event") + } + if got := incomplete.Get("response.status").String(); got != "incomplete" { + t.Fatalf("response.status = %q, want incomplete", got) + } + if got := incomplete.Get("response.incomplete_details.reason").String(); got != "max_output_tokens" { + t.Fatalf("incomplete reason = %q, want max_output_tokens", got) + } + if got := incomplete.Get("response.output.0.type").String(); got != "reasoning" { + t.Fatalf("response.output.0.type = %q, want reasoning; response=%s", got, incomplete.Raw) + } + if got := incomplete.Get("response.output.0.status").String(); got != "incomplete" { + t.Fatalf("response.output.0.status = %q, want incomplete", got) + } + if got := incomplete.Get("response.output.0.summary.0.text").String(); got != "unfinished reasoning" { + t.Fatalf("reasoning summary = %q, want unfinished reasoning", got) + } + if got := incomplete.Get("response.usage.output_tokens").Int(); got != 64000 { + t.Fatalf("output_tokens = %d, want 64000", got) + } +} + +func TestConvertClaudeResponseToOpenAIResponsesNonStream_MaxTokensPreservesPartialText(t *testing.T) { + raw := []byte(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_partial","usage":{"input_tokens":10,"output_tokens":0}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial answer"}}`, + `data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":64000}}`, + `data: {"type":"message_stop"}`, + }, "\n")) + + out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-fable-5-1", nil, nil, raw, nil) + root := gjson.ParseBytes(out) + if got := root.Get("status").String(); got != "incomplete" { + t.Fatalf("status = %q, want incomplete; response=%s", got, out) + } + if got := root.Get("incomplete_details.reason").String(); got != "max_output_tokens" { + t.Fatalf("incomplete reason = %q, want max_output_tokens", got) + } + if got := root.Get("output.0.status").String(); got != "incomplete" { + t.Fatalf("output.0.status = %q, want incomplete", got) + } + if got := root.Get("output.0.content.0.text").String(); got != "partial answer" { + t.Fatalf("partial text = %q, want partial answer", got) + } +} + +func TestConvertClaudeResponseToOpenAIResponses_MaxTokensWithToolCallAndTextEmitsIncomplete(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_tool_incomplete","usage":{"input_tokens":10,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"calling tool"}}`), + []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_inc_1","name":"get_weather","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"San"}}`), + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":64000}}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var incomplete gjson.Result + var funcDone gjson.Result + for _, chunk := range chunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + if event == "response.output_item.done" && data.Get("item.type").String() == "function_call" { + funcDone = data + } + if event == "response.incomplete" { + incomplete = data + } + } + } + + if !funcDone.Exists() { + t.Fatal("expected function_call response.output_item.done event") + } + if got := funcDone.Get("item.status").String(); got != "incomplete" { + t.Fatalf("funcDone item.status = %q, want incomplete", got) + } + if got := funcDone.Get("item.arguments").String(); got != "{\"city\":\"San" { + t.Fatalf("funcDone item.arguments = %q, want '{\"city\":\"San'", got) + } + + if !incomplete.Exists() { + t.Fatal("expected response.incomplete event") + } + if got := incomplete.Get("response.status").String(); got != "incomplete" { + t.Fatalf("response.status = %q, want incomplete", got) + } + if got := incomplete.Get("response.incomplete_details.reason").String(); got != "max_output_tokens" { + t.Fatalf("incomplete reason = %q, want max_output_tokens", got) + } + if got := incomplete.Get("response.output.0.status").String(); got != "completed" { + t.Fatalf("output.0.status = %q, want completed", got) + } + if got := incomplete.Get("response.output.0.content.0.text").String(); got != "calling tool" { + t.Fatalf("output.0 text = %q, want 'calling tool'", got) + } + if got := incomplete.Get("response.output.1.status").String(); got != "incomplete" { + t.Fatalf("output.1.status = %q, want incomplete", got) + } + if got := incomplete.Get("response.output.1.type").String(); got != "function_call" { + t.Fatalf("output.1.type = %q, want function_call", got) + } + if got := incomplete.Get("response.output.1.arguments").String(); got != "{\"city\":\"San" { + t.Fatalf("output.1.arguments = %q, want '{\"city\":\"San'", got) + } +} + +func TestConvertClaudeResponseToOpenAIResponses_MaxTokensWithWebSearchEmitsIncomplete(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_ws_incomplete","usage":{"input_tokens":10,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srv_1","name":"web_search"}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"golang\"}"}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":64000}}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var incomplete gjson.Result + var wsDone gjson.Result + for _, chunk := range chunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + if event == "response.output_item.done" && data.Get("item.type").String() == "web_search_call" { + wsDone = data + } + if event == "response.incomplete" { + incomplete = data + } + } + } + + if !wsDone.Exists() { + t.Fatal("expected web_search_call response.output_item.done event") + } + if got := wsDone.Get("item.status").String(); got != "incomplete" { + t.Fatalf("wsDone item.status = %q, want incomplete", got) + } + if !incomplete.Exists() { + t.Fatal("expected response.incomplete event") + } + if got := incomplete.Get("response.output.0.status").String(); got != "incomplete" { + t.Fatalf("response.output.0.status = %q, want incomplete", got) + } + + // Non-stream + raw := []byte(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_ws_incomplete","usage":{"input_tokens":10,"output_tokens":0}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srv_1","name":"web_search"}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"golang\"}"}}`, + `data: {"type":"content_block_stop","index":0}`, + `data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":64000}}`, + `data: {"type":"message_stop"}`, + }, "\n")) + nonStreamOut := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-fable-5-1", nil, nil, raw, nil) + nsRoot := gjson.ParseBytes(nonStreamOut) + if got := nsRoot.Get("status").String(); got != "incomplete" { + t.Fatalf("ns status = %q, want incomplete", got) + } + if got := nsRoot.Get("output.0.status").String(); got != "incomplete" { + t.Fatalf("ns output.0.status = %q, want incomplete", got) + } +} + +func TestConvertClaudeResponseToOpenAIResponses_MaxTokensReasoningWithoutBlockStop(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_reasoning_nostop","usage":{"input_tokens":10,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"partial thought before cut"}}`), + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":64000}}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var incomplete gjson.Result + var rsDone gjson.Result + for _, chunk := range chunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + if event == "response.output_item.done" && data.Get("item.type").String() == "reasoning" { + rsDone = data + } + if event == "response.incomplete" { + incomplete = data + } + } + } + + if !rsDone.Exists() { + t.Fatal("expected reasoning response.output_item.done event") + } + if got := rsDone.Get("item.status").String(); got != "incomplete" { + t.Fatalf("rsDone item.status = %q, want incomplete", got) + } + if !incomplete.Exists() { + t.Fatal("expected response.incomplete event") + } + if got := incomplete.Get("response.output.0.status").String(); got != "incomplete" { + t.Fatalf("response.output.0.status = %q, want incomplete", got) + } + if got := incomplete.Get("response.output.0.type").String(); got != "reasoning" { + t.Fatalf("response.output.0.type = %q, want reasoning", got) + } + if got := incomplete.Get("response.output.0.summary.0.text").String(); got != "partial thought before cut" { + t.Fatalf("summary = %q, want 'partial thought before cut'", got) + } +} + +func TestConvertClaudeResponseToOpenAIResponses_MaxTokensWithToolBlockStopEmitsIncomplete(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_tool_blockstop","usage":{"input_tokens":10,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_stop_1","name":"do_work","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"step\":1}"}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":64000}}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var incomplete gjson.Result + var funcDone gjson.Result + for _, chunk := range chunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + if event == "response.output_item.done" && data.Get("item.type").String() == "function_call" { + funcDone = data + } + if event == "response.incomplete" { + incomplete = data + } + } + } + + if !funcDone.Exists() { + t.Fatal("expected function_call response.output_item.done event") + } + if got := funcDone.Get("item.status").String(); got != "incomplete" { + t.Fatalf("funcDone item.status = %q, want incomplete", got) + } + if !incomplete.Exists() { + t.Fatal("expected response.incomplete event") + } + if got := incomplete.Get("response.output.0.status").String(); got != "incomplete" { + t.Fatalf("output.0.status = %q, want incomplete", got) + } +} + +func TestConvertClaudeResponseToOpenAIResponses_MaxTokensWithWebSearchResultsEmitsIncomplete(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_ws_res_incomplete","usage":{"input_tokens":10,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srv_2","name":"web_search"}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"golang\"}"}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srv_2","content":[{"type":"web_search_result","title":"Go","url":"https://golang.org"}]}}`), + []byte(`data: {"type":"content_block_stop","index":1}`), + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":64000}}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var incomplete gjson.Result + var wsDone gjson.Result + for _, chunk := range chunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + if event == "response.output_item.done" && data.Get("item.type").String() == "web_search_call" { + wsDone = data + } + if event == "response.incomplete" { + incomplete = data + } + } + } + + if !wsDone.Exists() { + t.Fatal("expected web_search_call response.output_item.done event") + } + if got := wsDone.Get("item.status").String(); got != "incomplete" { + t.Fatalf("wsDone item.status = %q, want incomplete", got) + } + if !incomplete.Exists() { + t.Fatal("expected response.incomplete event") + } + if got := incomplete.Get("response.output.0.status").String(); got != "incomplete" { + t.Fatalf("response.output.0.status = %q, want incomplete", got) + } +} + +func TestConvertClaudeResponseToOpenAIResponses_ReasoningEventsNotDuplicated(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_rs_once","usage":{"input_tokens":10,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"full thought"}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":10}}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + counts := make(map[string]int) + for _, chunk := range chunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) { + event, _ := parseClaudeResponsesSSEEvent(t, output) + counts[event]++ + } + } + + if counts["response.reasoning_summary_text.done"] != 1 { + t.Fatalf("reasoning_summary_text.done count = %d, want 1", counts["response.reasoning_summary_text.done"]) + } + if counts["response.reasoning_summary_part.done"] != 1 { + t.Fatalf("reasoning_summary_part.done count = %d, want 1", counts["response.reasoning_summary_part.done"]) + } + if counts["response.output_item.done"] != 1 { + t.Fatalf("output_item.done count = %d, want 1", counts["response.output_item.done"]) + } +} + +func TestConvertClaudeResponseToOpenAIResponses_CustomToolTruncatedInputUnwrapped(t *testing.T) { + originalRequest := []byte(`{"tools":[{"type":"custom","name":"bash"}]}`) + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_custom_trunc","usage":{"input_tokens":10,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_c1","name":"bash","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"input\":\"echo \\u4F60"}}`), + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":64000}}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var customDone gjson.Result + var incomplete gjson.Result + for _, chunk := range chunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", originalRequest, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + if event == "response.output_item.done" && data.Get("item.type").String() == "custom_tool_call" { + customDone = data + } + if event == "response.incomplete" { + incomplete = data + } + } + } + + if !customDone.Exists() { + t.Fatal("expected custom_tool_call response.output_item.done event") + } + if got := customDone.Get("item.input").String(); got != "echo 你" { + t.Fatalf("customDone item.input = %q, want 'echo 你'", got) + } + if got := customDone.Get("item.status").String(); got != "incomplete" { + t.Fatalf("customDone item.status = %q, want incomplete", got) + } + if !incomplete.Exists() { + t.Fatal("expected response.incomplete event") + } + if got := incomplete.Get("response.output.0.input").String(); got != "echo 你" { + t.Fatalf("response.output.0.input = %q, want 'echo 你'", got) + } + if got := incomplete.Get("response.output.0.status").String(); got != "incomplete" { + t.Fatalf("response.output.0.status = %q, want incomplete", got) + } +} + +func TestConvertClaudeResponseToOpenAIResponses_EmptyFunctionArgsConsistentOnTruncation(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_empty_args_trunc","usage":{"input_tokens":10,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_empty","name":"get_info","input":{}}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":64000}}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var funcDone gjson.Result + var argsDone gjson.Result + var incomplete gjson.Result + for _, chunk := range chunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + if event == "response.function_call_arguments.done" { + argsDone = data + } + if event == "response.output_item.done" && data.Get("item.type").String() == "function_call" { + funcDone = data + } + if event == "response.incomplete" { + incomplete = data + } + } + } + + if !argsDone.Exists() { + t.Fatal("expected response.function_call_arguments.done event") + } + if !funcDone.Exists() { + t.Fatal("expected function_call response.output_item.done event") + } + if got := argsDone.Get("arguments").String(); got != "" { + t.Fatalf("argsDone arguments = %q, want empty string", got) + } + if got := funcDone.Get("item.arguments").String(); got != "" { + t.Fatalf("funcDone arguments = %q, want empty string", got) + } + if got := funcDone.Get("item.status").String(); got != "incomplete" { + t.Fatalf("funcDone item.status = %q, want incomplete", got) + } + if !incomplete.Exists() { + t.Fatal("expected response.incomplete event") + } + if got := incomplete.Get("response.output.0.arguments").String(); got != "" { + t.Fatalf("response.output.0.arguments = %q, want empty string", got) + } +}