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 671f98d0..1a575992 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -667,26 +667,44 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string // Aggregation state var ( - responseID string - createdAt int64 - currentMsgID string - currentFCID string - textBuf strings.Builder - reasoningBuf strings.Builder - reasoningActive bool - reasoningItemID string - reasoningSig string - annotations []any - usageTokens claudeResponsesUsageTokens + responseID string + createdAt int64 + usageTokens claudeResponsesUsageTokens ) - // Per-index tool call aggregation - type toolState struct { - id string - name string - args strings.Builder + type nonStreamOutputItem struct { + outputIndex int + itemType string + id string + callID string + name string + text strings.Builder + signature string + annotations []any + args strings.Builder + } + + blockToItem := make(map[int]*nonStreamOutputItem) + outputItems := make([]*nonStreamOutputItem, 0) + nextOutputIndex := 0 + messageCount := 0 + var activeMessageItem *nonStreamOutputItem + var pendingAnnotations []any + + allocateOutputIndex := func() int { + outputIndex := nextOutputIndex + nextOutputIndex++ + return outputIndex + } + newOutputItem := func(itemType string, blockIndex int) *nonStreamOutputItem { + item := &nonStreamOutputItem{ + outputIndex: allocateOutputIndex(), + itemType: itemType, + } + outputItems = append(outputItems, item) + blockToItem[blockIndex] = item + return item } - toolCalls := make(map[int]*toolState) // Walk through SSE chunks to fill state for _, ch := range chunks { @@ -710,22 +728,26 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string typ := cb.Get("type").String() switch typ { case "text": - currentMsgID = "msg_" + responseID + "_0" - case "tool_use": - currentFCID = cb.Get("id").String() - name := cb.Get("name").String() - if toolCalls[idx] == nil { - toolCalls[idx] = &toolState{id: currentFCID, name: name} - } else { - toolCalls[idx].id = currentFCID - toolCalls[idx].name = name + item := newOutputItem("message", idx) + item.id = fmt.Sprintf("msg_%s_%d", responseID, messageCount) + messageCount++ + if len(pendingAnnotations) > 0 { + item.annotations = append(item.annotations, pendingAnnotations...) + pendingAnnotations = nil } + activeMessageItem = item + case "tool_use": + activeMessageItem = nil + item := newOutputItem("function_call", idx) + item.callID = cb.Get("id").String() + item.id = fmt.Sprintf("fc_%s", item.callID) + item.name = cb.Get("name").String() case "thinking": - reasoningActive = true - reasoningItemID = fmt.Sprintf("rs_%s_%d", responseID, idx) - reasoningSig = "" + activeMessageItem = nil + item := newOutputItem("reasoning", idx) + item.id = fmt.Sprintf("rs_%s_%d", responseID, idx) if signature := cb.Get("signature"); signature.Exists() && signature.String() != "" { - reasoningSig = signature.String() + item.signature = signature.String() } } @@ -734,41 +756,48 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string if !d.Exists() { continue } + idx := int(root.Get("index").Int()) + item := blockToItem[idx] dt := d.Get("type").String() switch dt { case "text_delta": - if t := d.Get("text"); t.Exists() { - textBuf.WriteString(t.String()) + if item != nil && item.itemType == "message" { + if t := d.Get("text"); t.Exists() { + item.text.WriteString(t.String()) + } } case "input_json_delta": - if pj := d.Get("partial_json"); pj.Exists() { - idx := int(root.Get("index").Int()) - if toolCalls[idx] == nil { - toolCalls[idx] = &toolState{} + if item != nil && item.itemType == "function_call" { + if pj := d.Get("partial_json"); pj.Exists() { + item.args.WriteString(pj.String()) } - toolCalls[idx].args.WriteString(pj.String()) } case "thinking_delta": - if reasoningActive { + if item != nil && item.itemType == "reasoning" { if t := d.Get("thinking"); t.Exists() { - reasoningBuf.WriteString(t.String()) + item.text.WriteString(t.String()) } } case "signature_delta": - if reasoningActive { + if item != nil && item.itemType == "reasoning" { if signature := d.Get("signature"); signature.Exists() && signature.String() != "" { - reasoningSig = signature.String() + item.signature = signature.String() } } case "citations_delta": if citation := d.Get("citation"); citation.Exists() { - annotations = append(annotations, citation.Value()) + if item != nil && item.itemType == "message" { + item.annotations = append(item.annotations, citation.Value()) + } else if activeMessageItem != nil { + activeMessageItem.annotations = append(activeMessageItem.annotations, citation.Value()) + } else { + pendingAnnotations = append(pendingAnnotations, citation.Value()) + } } } case "content_block_stop": - // Nothing special to finalize for non-stream aggregation - _ = root + // Output items are finalized after all deltas have been aggregated. case "message_delta": usageTokens.Merge(root.Get("usage")) @@ -845,53 +874,38 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string } } - // Build output array + // Build output array in the order of the original content blocks. outputsWrapper := []byte(`{"arr":[]}`) - if reasoningBuf.Len() > 0 || reasoningSig != "" { - item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`) - item, _ = sjson.SetBytes(item, "id", reasoningItemID) - item, _ = sjson.SetBytes(item, "encrypted_content", reasoningSig) - if reasoningBuf.Len() > 0 { + for _, outputItem := range outputItems { + var item []byte + switch outputItem.itemType { + case "reasoning": + item = []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`) + item, _ = sjson.SetBytes(item, "id", outputItem.id) + item, _ = sjson.SetBytes(item, "encrypted_content", outputItem.signature) summary := []byte(`{"type":"summary_text","text":""}`) - summary, _ = sjson.SetBytes(summary, "text", reasoningBuf.String()) + summary, _ = sjson.SetBytes(summary, "text", outputItem.text.String()) item, _ = sjson.SetRawBytes(item, "summary.-1", summary) - } - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) - } - if currentMsgID != "" || textBuf.Len() > 0 { - item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`) - item, _ = sjson.SetBytes(item, "id", currentMsgID) - item, _ = sjson.SetBytes(item, "content.0.text", textBuf.String()) - if len(annotations) > 0 { - item, _ = sjson.SetBytes(item, "content.0.annotations", annotations) - } - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) - } - if len(toolCalls) > 0 { - // Preserve index order - idxs := make([]int, 0, len(toolCalls)) - for i := range toolCalls { - idxs = append(idxs, i) - } - for i := 0; i < len(idxs); i++ { - for j := i + 1; j < len(idxs); j++ { - if idxs[j] < idxs[i] { - idxs[i], idxs[j] = idxs[j], idxs[i] - } - } - } - for _, i := range idxs { - st := toolCalls[i] - args := st.args.String() + 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, "content.0.text", outputItem.text.String()) + if len(outputItem.annotations) > 0 { + item, _ = sjson.SetBytes(item, "content.0.annotations", outputItem.annotations) + } + case "function_call": + args := outputItem.args.String() if args == "" { args = "{}" } - item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) - item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", st.id)) + item = []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) + item, _ = sjson.SetBytes(item, "id", outputItem.id) item, _ = sjson.SetBytes(item, "arguments", args) - item, _ = sjson.SetBytes(item, "call_id", st.id) - item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, st.name, "") - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) + item, _ = sjson.SetBytes(item, "call_id", outputItem.callID) + item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, outputItem.name, "") + } + if len(item) > 0 { + outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", outputItem.outputIndex), item) } } if gjson.GetBytes(outputsWrapper, "arr.#").Int() > 0 { @@ -912,9 +926,15 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string if totalTokens != 0 { out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens) } - if reasoningBuf.Len() > 0 { + reasoningLength := 0 + for _, outputItem := range outputItems { + if outputItem.itemType == "reasoning" { + reasoningLength += outputItem.text.Len() + } + } + if reasoningLength > 0 { // Rough estimate similar to chat completions - reasoningTokens := int64(len(reasoningBuf.String()) / 4) + reasoningTokens := int64(reasoningLength / 4) if reasoningTokens > 0 { out, _ = sjson.SetBytes(out, "usage.output_tokens_details.reasoning_tokens", reasoningTokens) } 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 16e5a330..a58c23fb 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 @@ -704,6 +704,59 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_ThinkingIncludesSignatu } } +func TestConvertClaudeResponseToOpenAIResponsesNonStream_PreservesContentBlockOrder(t *testing.T) { + raw := []byte(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_nonstream_order","usage":{"input_tokens":1,"output_tokens":0}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `data: {"type":"content_block_stop","index":0}`, + `data: {"type":"content_block_start","index":1,"content_block":{"type":"thinking","thinking":""}}`, + `data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"call_order","name":"exec_command","input":{}}}`, + `data: {"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}}`, + `data: {"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"plan"}}`, + `data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"pwd\"}"}}`, + `data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"done"}}`, + `data: {"type":"content_block_stop","index":1}`, + `data: {"type":"content_block_stop","index":2}`, + `data: {"type":"content_block_stop","index":3}`, + `data: {"type":"content_block_start","index":4,"content_block":{"type":"thinking","thinking":""}}`, + `data: {"type":"content_block_delta","index":4,"delta":{"type":"thinking_delta","thinking":"more"}}`, + `data: {"type":"content_block_stop","index":4}`, + `data: {"type":"message_stop"}`, + }, "\n")) + + root := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", nil, nil, raw, nil)) + wantTypes := []string{"message", "reasoning", "function_call", "message", "reasoning"} + if got := root.Get("output.#").Int(); got != int64(len(wantTypes)) { + t.Fatalf("non-stream output count = %d, want %d", got, len(wantTypes)) + } + for index, wantType := range wantTypes { + if got := root.Get(fmt.Sprintf("output.%d.type", index)).String(); got != wantType { + t.Fatalf("non-stream output.%d.type = %q, want %q", index, got, wantType) + } + } + if got := root.Get("output.0.content.0.text").String(); got != "" { + t.Fatalf("empty text block content = %q, want empty string", got) + } + if got := root.Get("output.1.summary.0.text").String(); got != "plan" { + t.Fatalf("first reasoning text = %q, want %q", got, "plan") + } + if got := root.Get("output.2.call_id").String(); got != "call_order" { + t.Fatalf("function call id = %q, want %q", got, "call_order") + } + if got := root.Get("output.2.arguments").String(); got != `{"cmd":"pwd"}` { + t.Fatalf("function call arguments = %q, want %q", got, `{"cmd":"pwd"}`) + } + if got := root.Get("output.3.content.0.text").String(); got != "done" { + t.Fatalf("second message text = %q, want %q", got, "done") + } + if got := root.Get("output.4.summary.0.text").String(); got != "more" { + t.Fatalf("second reasoning text = %q, want %q", got, "more") + } + if got := root.Get("usage.output_tokens_details.reasoning_tokens").Int(); got != 2 { + t.Fatalf("reasoning tokens = %d, want 2", got) + } +} + func TestConvertClaudeResponseToOpenAIResponsesNonStream_ReportsCacheTokens(t *testing.T) { raw := []byte(strings.Join([]string{ `data: {"type":"message_start","message":{"id":"msg_nonstream","usage":{"input_tokens":13,"output_tokens":1,"cache_read_input_tokens":22000,"cache_creation_input_tokens":31}}}`,