From fe9e4078c3a890c06e324df7d43f6b11f7cd8416 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 17:15:55 -0400 Subject: [PATCH 1/4] fix(translator): close message before function call --- .../claude_openai-responses_response.go | 1 + .../claude_openai-responses_response_test.go | 97 +++++++++++++++++++ 2 files changed, 98 insertions(+) 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 c27cb4b38..694362909 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -245,6 +245,7 @@ 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() 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 9db2e0586..c04e897b2 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 @@ -166,6 +166,103 @@ func TestConvertClaudeResponseToOpenAIResponses_AggregatesTextBlocksUntilMessage } } +func TestConvertClaudeResponseToOpenAIResponses_FinalizesMessageBeforeFunctionCall(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":1,"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":"Checking the workspace."}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_123","name":"exec_command","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"pwd\"}"}}`), + []byte(`data: {"type":"content_block_stop","index":1}`), + []byte(`data: {"type":"message_stop"}`), + } + + outputs := translateClaudeResponsesStreamThroughRegistry(chunks) + + messageAddedPosition := -1 + messageDonePosition := -1 + functionAddedPosition := -1 + functionDonePosition := -1 + messageDoneCount := 0 + functionDoneCount := 0 + var completed gjson.Result + for position, output := range outputs { + event, data := parseClaudeResponsesSSEEvent(t, output) + itemType := data.Get("item.type").String() + switch { + case event == "response.output_item.added" && itemType == "message": + messageAddedPosition = position + if got := data.Get("output_index").Int(); got != 0 { + t.Fatalf("message added output_index = %d, want 0", got) + } + case event == "response.output_item.done" && itemType == "message": + messageDonePosition = position + messageDoneCount++ + if got := data.Get("output_index").Int(); got != 0 { + t.Fatalf("message done output_index = %d, want 0", got) + } + case event == "response.output_item.added" && itemType == "function_call": + functionAddedPosition = position + if got := data.Get("output_index").Int(); got != 1 { + t.Fatalf("function added output_index = %d, want 1", got) + } + case event == "response.output_item.done" && itemType == "function_call": + functionDonePosition = position + functionDoneCount++ + if got := data.Get("output_index").Int(); got != 1 { + t.Fatalf("function done output_index = %d, want 1", got) + } + case event == "response.completed": + completed = data + } + } + + if messageAddedPosition < 0 || messageDonePosition < 0 || functionAddedPosition < 0 || functionDonePosition < 0 { + t.Fatalf( + "missing lifecycle event: message added=%d done=%d, function added=%d done=%d", + messageAddedPosition, + messageDonePosition, + functionAddedPosition, + functionDonePosition, + ) + } + if messageDonePosition >= functionAddedPosition { + t.Fatalf( + "message done position = %d, want before function added position %d", + messageDonePosition, + functionAddedPosition, + ) + } + if functionAddedPosition >= functionDonePosition { + t.Fatalf("function added position = %d, want before done position %d", functionAddedPosition, functionDonePosition) + } + if messageDoneCount != 1 { + t.Fatalf("message output_item.done count = %d, want 1", messageDoneCount) + } + if functionDoneCount != 1 { + t.Fatalf("function output_item.done count = %d, want 1", functionDoneCount) + } + if !completed.Exists() { + t.Fatal("expected response.completed event") + } + if got := completed.Get("response.output.#").Int(); got != 2 { + t.Fatalf("completed output count = %d, want 2", got) + } + if got := completed.Get("response.output.0.type").String(); got != "message" { + t.Fatalf("completed output[0] type = %q, want message", got) + } + if got := completed.Get("response.output.0.content.0.text").String(); got != "Checking the workspace." { + t.Fatalf("completed message text = %q", got) + } + if got := completed.Get("response.output.1.type").String(); got != "function_call" { + t.Fatalf("completed output[1] type = %q, want function_call", got) + } + if got := completed.Get("response.output.1.call_id").String(); got != "call_123" { + t.Fatalf("completed function call_id = %q, want call_123", got) + } +} + func TestConvertClaudeResponseToOpenAIResponses_ReportsCacheTokens(t *testing.T) { chunks := [][]byte{ []byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":13,"output_tokens":1,"cache_read_input_tokens":100,"cache_creation_input_tokens":7}}}`), From cb4fab85f33bc366b1527bc5562f3d22176abcd0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 17:20:20 -0400 Subject: [PATCH 2/4] fix(kiro): register OpenAI Responses translator --- internal/translator/init.go | 2 + internal/translator/kiro/responses/init.go | 20 +++ .../kiro/responses/kiro_responses.go | 19 +++ .../kiro/responses/kiro_responses_test.go | 152 ++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 internal/translator/kiro/responses/init.go create mode 100644 internal/translator/kiro/responses/kiro_responses.go create mode 100644 internal/translator/kiro/responses/kiro_responses_test.go diff --git a/internal/translator/init.go b/internal/translator/init.go index 65428dd0b..571cbc3bb 100644 --- a/internal/translator/init.go +++ b/internal/translator/init.go @@ -20,6 +20,8 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/interactions/claude" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/kiro/responses" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/claude" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/interactions/chat-completions" diff --git a/internal/translator/kiro/responses/init.go b/internal/translator/kiro/responses/init.go new file mode 100644 index 000000000..848a8ff35 --- /dev/null +++ b/internal/translator/kiro/responses/init.go @@ -0,0 +1,20 @@ +// Package responses provides translation between OpenAI Responses and Kiro formats. +package responses + +import ( + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" +) + +func init() { + translator.Register( + OpenaiResponse, + Kiro, + ConvertOpenAIResponsesRequestToKiro, + interfaces.TranslateResponse{ + Stream: ConvertKiroStreamToOpenAIResponses, + NonStream: ConvertKiroNonStreamToOpenAIResponses, + }, + ) +} diff --git a/internal/translator/kiro/responses/kiro_responses.go b/internal/translator/kiro/responses/kiro_responses.go new file mode 100644 index 000000000..3bb974366 --- /dev/null +++ b/internal/translator/kiro/responses/kiro_responses.go @@ -0,0 +1,19 @@ +package responses + +import ( + "context" + + clauderesponses "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/responses" +) + +func ConvertOpenAIResponsesRequestToKiro(modelName string, inputRawJSON []byte, stream bool) []byte { + return clauderesponses.ConvertOpenAIResponsesRequestToClaude(modelName, inputRawJSON, stream) +} + +func ConvertKiroStreamToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + return clauderesponses.ConvertClaudeResponseToOpenAIResponses(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param) +} + +func ConvertKiroNonStreamToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte { + return clauderesponses.ConvertClaudeResponseToOpenAIResponsesNonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param) +} diff --git a/internal/translator/kiro/responses/kiro_responses_test.go b/internal/translator/kiro/responses/kiro_responses_test.go new file mode 100644 index 000000000..04dc89321 --- /dev/null +++ b/internal/translator/kiro/responses/kiro_responses_test.go @@ -0,0 +1,152 @@ +package responses + +import ( + "context" + "strings" + "testing" + + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func parseKiroResponsesSSEEvent(t *testing.T, chunk []byte) (string, gjson.Result) { + t.Helper() + + var event string + var data string + for _, line := range strings.Split(string(chunk), "\n") { + switch { + case strings.HasPrefix(line, "event: "): + event = strings.TrimPrefix(line, "event: ") + case strings.HasPrefix(line, "data: "): + data = strings.TrimPrefix(line, "data: ") + } + } + if data == "" { + t.Fatalf("SSE chunk has no data line: %s", string(chunk)) + } + return event, gjson.Parse(data) +} + +func TestKiroResponsesTranslatorRegistrationAndRequestDelegation(t *testing.T) { + kiroFormat := sdktranslator.FromString("kiro") + if !sdktranslator.HasRequestTransformer(sdktranslator.FormatOpenAIResponse, kiroFormat) { + t.Fatal("OpenAI Responses to Kiro request transformer is not registered") + } + if !sdktranslator.HasStreamResponseTransformer(sdktranslator.FormatOpenAIResponse, kiroFormat) { + t.Fatal("Kiro to OpenAI Responses stream transformer is not registered") + } + if !sdktranslator.HasNonStreamResponseTransformer(sdktranslator.FormatOpenAIResponse, kiroFormat) { + t.Fatal("Kiro to OpenAI Responses non-stream transformer is not registered") + } + + raw := []byte(`{ + "model":"client-model", + "max_output_tokens":128, + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"Run pwd"}] + } + ] + }`) + out := sdktranslator.TranslateRequest( + sdktranslator.FormatOpenAIResponse, + kiroFormat, + "kiro-claude-sonnet-4-6", + raw, + true, + ) + root := gjson.ParseBytes(out) + + if got := root.Get("model").String(); got != "kiro-claude-sonnet-4-6" { + t.Fatalf("model = %q, want kiro-claude-sonnet-4-6. Output: %s", got, string(out)) + } + if got := root.Get("max_tokens").Int(); got != 128 { + t.Fatalf("max_tokens = %d, want 128. Output: %s", got, string(out)) + } + if !root.Get("stream").Bool() { + t.Fatalf("stream = false, want true. Output: %s", string(out)) + } + if got := root.Get("messages.0.role").String(); got != "user" { + t.Fatalf("message role = %q, want user. Output: %s", got, string(out)) + } + if got := root.Get("messages.0.content").String(); got != "Run pwd" { + t.Fatalf("message content = %q, want Run pwd. Output: %s", got, string(out)) + } + if root.Get("input").Exists() { + t.Fatalf("OpenAI Responses input leaked into Claude request. Output: %s", string(out)) + } +} + +func TestKiroResponsesStreamFinalizesMessageBeforeFunctionCall(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":1,"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":"Checking the workspace."}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_123","name":"exec_command","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"pwd\"}"}}`), + []byte(`data: {"type":"content_block_stop","index":1}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, sdktranslator.TranslateStream( + context.Background(), + sdktranslator.FromString("kiro"), + sdktranslator.FormatOpenAIResponse, + "kiro-claude-sonnet-4-6", + nil, + nil, + chunk, + ¶m, + )...) + } + + messageDonePosition := -1 + functionAddedPosition := -1 + messageDoneCount := 0 + functionDoneCount := 0 + var completed gjson.Result + for position, output := range outputs { + event, data := parseKiroResponsesSSEEvent(t, output) + itemType := data.Get("item.type").String() + switch { + case event == "response.output_item.done" && itemType == "message": + messageDonePosition = position + messageDoneCount++ + if got := data.Get("output_index").Int(); got != 0 { + t.Fatalf("message done output_index = %d, want 0", got) + } + case event == "response.output_item.added" && itemType == "function_call": + functionAddedPosition = position + if got := data.Get("output_index").Int(); got != 1 { + t.Fatalf("function added output_index = %d, want 1", got) + } + case event == "response.output_item.done" && itemType == "function_call": + functionDoneCount++ + case event == "response.completed": + completed = data + } + } + + if messageDonePosition < 0 || functionAddedPosition < 0 { + t.Fatalf("missing lifecycle event: message done=%d, function added=%d", messageDonePosition, functionAddedPosition) + } + if messageDonePosition >= functionAddedPosition { + t.Fatalf("message done position = %d, want before function added position %d", messageDonePosition, functionAddedPosition) + } + if messageDoneCount != 1 || functionDoneCount != 1 { + t.Fatalf("output_item.done counts: message=%d function=%d, want 1 each", messageDoneCount, functionDoneCount) + } + if got := completed.Get("response.output.0.type").String(); got != "message" { + t.Fatalf("completed output[0] type = %q, want message", got) + } + if got := completed.Get("response.output.1.type").String(); got != "function_call" { + t.Fatalf("completed output[1] type = %q, want function_call", got) + } +} From c51f395d70b32f7417b3a40f6cecc2e45a26ae5d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 17:45:57 -0400 Subject: [PATCH 3/4] fix(translator): make Responses output indices contiguous --- .../claude_openai-responses_response.go | 94 ++++++++--- .../claude_openai-responses_response_test.go | 156 ++++++++++++++++++ 2 files changed, 226 insertions(+), 24 deletions(-) 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 694362909..55905824a 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -14,19 +14,22 @@ import ( ) type claudeToResponsesState struct { - Seq int - ResponseID string - CreatedAt int64 - CurrentMsgID string - CurrentFCID string - InTextBlock bool - InFuncBlock bool - MessageOpen bool - ContentPartOpen bool - FuncArgsBuf map[int]*strings.Builder // index -> args + Seq int + ResponseID string + CreatedAt int64 + NextOutputIndex int + CurrentMsgID string + CurrentFCID string + InTextBlock bool + InFuncBlock bool + MessageOpen bool + ContentPartOpen bool + MessageOutputIndex int + FuncArgsBuf map[int]*strings.Builder // index -> args // function call bookkeeping for output aggregation - FuncNames map[int]string // index -> function name - FuncCallIDs map[int]string // index -> call id + FuncNames map[int]string // Claude block index -> function name + FuncCallIDs map[int]string // Claude block index -> call id + FuncOutputIndices map[int]int // Claude block index -> Responses output index // message text aggregation TextBuf strings.Builder CurrentTextBuf strings.Builder @@ -124,21 +127,46 @@ func (st *claudeToResponsesState) appendMessageAnnotation(annotation any) { st.MessageAnnotations = append(st.MessageAnnotations, annotation) } +func (st *claudeToResponsesState) allocateOutputIndex() int { + index := st.NextOutputIndex + st.NextOutputIndex++ + return index +} + +func (st *claudeToResponsesState) messageOutputIndex() int { + if st.MessageOutputIndex < 0 { + st.MessageOutputIndex = st.allocateOutputIndex() + } + return st.MessageOutputIndex +} + +func (st *claudeToResponsesState) functionOutputIndex(blockIndex int) int { + if index, ok := st.FuncOutputIndices[blockIndex]; ok { + return index + } + index := st.allocateOutputIndex() + st.FuncOutputIndices[blockIndex] = index + return index +} + func (st *claudeToResponsesState) finalizeAssistantMessage(nextSeq func() int) [][]byte { if !st.MessageOpen { return nil } fullText := st.TextBuf.String() + outputIndex := st.messageOutputIndex() 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()) done, _ = sjson.SetBytes(done, "item_id", st.CurrentMsgID) + done, _ = sjson.SetBytes(done, "output_index", outputIndex) done, _ = sjson.SetBytes(done, "text", fullText) out = append(out, emitEvent("response.output_text.done", done)) partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`) partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq()) partDone, _ = sjson.SetBytes(partDone, "item_id", st.CurrentMsgID) + partDone, _ = sjson.SetBytes(partDone, "output_index", outputIndex) partDone, _ = sjson.SetBytes(partDone, "part.text", fullText) if len(st.MessageAnnotations) > 0 { partDone, _ = sjson.SetBytes(partDone, "part.annotations", st.MessageAnnotations) @@ -147,6 +175,7 @@ func (st *claudeToResponsesState) finalizeAssistantMessage(nextSeq func() int) [ final := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`) 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.content.0.text", fullText) if len(st.MessageAnnotations) > 0 { @@ -164,7 +193,14 @@ func (st *claudeToResponsesState) finalizeAssistantMessage(nextSeq func() int) [ // ConvertClaudeResponseToOpenAIResponses converts Claude SSE to OpenAI Responses SSE events. func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { if *param == nil { - *param = &claudeToResponsesState{FuncArgsBuf: make(map[int]*strings.Builder), FuncNames: make(map[int]string), FuncCallIDs: make(map[int]string)} + *param = &claudeToResponsesState{ + MessageOutputIndex: -1, + ReasoningIndex: -1, + FuncArgsBuf: make(map[int]*strings.Builder), + FuncNames: make(map[int]string), + FuncCallIDs: make(map[int]string), + FuncOutputIndices: make(map[int]int), + } } st := (*param).(*claudeToResponsesState) @@ -190,19 +226,22 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.MessageAnnotations = nil st.ReasoningBuf.Reset() st.ReasoningActive = false + st.NextOutputIndex = 0 st.InTextBlock = false st.InFuncBlock = false st.MessageOpen = false st.ContentPartOpen = false st.CurrentMsgID = "" st.CurrentFCID = "" + st.MessageOutputIndex = -1 st.ReasoningItemID = "" st.ReasoningSignature = "" - st.ReasoningIndex = 0 + st.ReasoningIndex = -1 st.ReasoningPartAdded = false st.FuncArgsBuf = make(map[int]*strings.Builder) st.FuncNames = make(map[int]string) st.FuncCallIDs = make(map[int]string) + st.FuncOutputIndices = make(map[int]int) st.Usage = claudeResponsesUsageTokens{} st.Usage.Merge(msg.Get("usage")) // response.created @@ -227,12 +266,14 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin typ := cb.Get("type").String() if typ == "text" { st.InTextBlock = true + outputIndex := st.messageOutputIndex() if st.CurrentMsgID == "" { st.CurrentMsgID = fmt.Sprintf("msg_%s_0", st.ResponseID) } if !st.MessageOpen { item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`) item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) + item, _ = sjson.SetBytes(item, "output_index", outputIndex) item, _ = sjson.SetBytes(item, "item.id", st.CurrentMsgID) out = append(out, emitEvent("response.output_item.added", item)) st.MessageOpen = true @@ -241,6 +282,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin part := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`) part, _ = sjson.SetBytes(part, "sequence_number", nextSeq()) part, _ = sjson.SetBytes(part, "item_id", st.CurrentMsgID) + part, _ = sjson.SetBytes(part, "output_index", outputIndex) out = append(out, emitEvent("response.content_part.added", part)) st.ContentPartOpen = true } @@ -249,9 +291,10 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.InFuncBlock = true st.CurrentFCID = cb.Get("id").String() name := cb.Get("name").String() + outputIndex := st.functionOutputIndex(idx) item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`) item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) - item, _ = sjson.SetBytes(item, "output_index", idx) + item, _ = sjson.SetBytes(item, "output_index", outputIndex) item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID)) item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID) item = applyResponsesFunctionCallNamespaceFields(item, pickRequestJSON(originalRequestRawJSON, requestRawJSON), name, "item") @@ -265,7 +308,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } else if typ == "thinking" { // start reasoning item st.ReasoningActive = true - st.ReasoningIndex = idx + st.ReasoningIndex = st.allocateOutputIndex() st.ReasoningBuf.Reset() st.ReasoningSignature = "" if signature := cb.Get("signature"); signature.Exists() && signature.String() != "" { @@ -274,7 +317,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.ReasoningItemID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx) item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`) item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) - item, _ = sjson.SetBytes(item, "output_index", idx) + item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex) item, _ = sjson.SetBytes(item, "item.id", st.ReasoningItemID) item, _ = sjson.SetBytes(item, "item.encrypted_content", st.ReasoningSignature) out = append(out, emitEvent("response.output_item.added", item)) @@ -282,7 +325,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`) part, _ = sjson.SetBytes(part, "sequence_number", nextSeq()) part, _ = sjson.SetBytes(part, "item_id", st.ReasoningItemID) - part, _ = sjson.SetBytes(part, "output_index", idx) + part, _ = sjson.SetBytes(part, "output_index", st.ReasoningIndex) out = append(out, emitEvent("response.reasoning_summary_part.added", part)) st.ReasoningPartAdded = true } @@ -297,6 +340,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`) msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) msg, _ = sjson.SetBytes(msg, "item_id", st.CurrentMsgID) + msg, _ = sjson.SetBytes(msg, "output_index", st.messageOutputIndex()) msg, _ = sjson.SetBytes(msg, "delta", t.String()) out = append(out, emitEvent("response.output_text.delta", msg)) // aggregate text for response.output @@ -313,10 +357,11 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.FuncArgsBuf[idx] = &strings.Builder{} } st.FuncArgsBuf[idx].WriteString(pj.String()) + outputIndex := st.functionOutputIndex(idx) msg := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`) msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("fc_%s", st.CurrentFCID)) - msg, _ = sjson.SetBytes(msg, "output_index", idx) + msg, _ = sjson.SetBytes(msg, "output_index", outputIndex) msg, _ = sjson.SetBytes(msg, "delta", pj.String()) out = append(out, emitEvent("response.function_call_arguments.delta", msg)) } @@ -350,6 +395,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin if st.InTextBlock { st.InTextBlock = false } else if st.InFuncBlock { + outputIndex := st.functionOutputIndex(idx) args := "{}" if buf := st.FuncArgsBuf[idx]; buf != nil { if buf.Len() > 0 { @@ -359,12 +405,12 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin 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", idx) + 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", idx) + 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) @@ -489,7 +535,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin summary, _ = sjson.SetBytes(summary, "text", st.ReasoningBuf.String()) item, _ = sjson.SetRawBytes(item, "summary.-1", summary) } - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) + outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.ReasoningIndex), item) } // assistant message item (if any text) if st.TextBuf.Len() > 0 || st.InTextBlock || st.CurrentMsgID != "" { @@ -499,7 +545,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin if len(st.MessageAnnotations) > 0 { item, _ = sjson.SetBytes(item, "content.0.annotations", st.MessageAnnotations) } - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) + outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.MessageOutputIndex), item) } // function_call items (in ascending index order for determinism) if len(st.FuncArgsBuf) > 0 { @@ -531,7 +577,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin item, _ = sjson.SetBytes(item, "arguments", args) item, _ = sjson.SetBytes(item, "call_id", callID) item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "") - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) + outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item) } } if gjson.GetBytes(outputsWrapper, "arr.#").Int() > 0 { 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 c04e897b2..d7af9b247 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 @@ -2,6 +2,7 @@ package responses import ( "context" + "fmt" "strings" "testing" @@ -263,6 +264,161 @@ func TestConvertClaudeResponseToOpenAIResponses_FinalizesMessageBeforeFunctionCa } } +func TestConvertClaudeResponseToOpenAIResponses_UsesContiguousIndicesForReasoningTextAndTool(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":1,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srv_123","name":"web_search","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"Qwen3\"}"}}`), + []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_123","content":[]}}`), + []byte(`data: {"type":"content_block_stop","index":1}`), + []byte(`data: {"type":"content_block_start","index":2,"content_block":{"type":"thinking","thinking":""}}`), + []byte(`data: {"type":"content_block_delta","index":2,"delta":{"type":"thinking_delta","thinking":"Inspect first."}}`), + []byte(`data: {"type":"content_block_stop","index":2}`), + []byte(`data: {"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}}`), + []byte(`data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"Checking the workspace."}}`), + []byte(`data: {"type":"content_block_stop","index":3}`), + []byte(`data: {"type":"content_block_start","index":4,"content_block":{"type":"tool_use","id":"call_123","name":"exec_command","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":4,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"pwd\"}"}}`), + []byte(`data: {"type":"content_block_stop","index":4}`), + []byte(`data: {"type":"message_stop"}`), + } + + outputs := translateClaudeResponsesStreamThroughRegistry(chunks) + + seen := map[string]int{} + var completed gjson.Result + for _, output := range outputs { + event, data := parseClaudeResponsesSSEEvent(t, output) + var itemType string + var wantIndex int64 + switch { + case event == "response.output_item.added" || event == "response.output_item.done": + itemType = data.Get("item.type").String() + switch itemType { + case "reasoning": + wantIndex = 0 + case "message": + wantIndex = 1 + case "function_call": + wantIndex = 2 + default: + continue + } + case strings.HasPrefix(event, "response.reasoning_"): + itemType = "reasoning" + wantIndex = 0 + case strings.HasPrefix(event, "response.output_text.") || strings.HasPrefix(event, "response.content_part."): + itemType = "message" + wantIndex = 1 + case strings.HasPrefix(event, "response.function_call_arguments."): + itemType = "function_call" + wantIndex = 2 + case event == "response.completed": + completed = data + continue + default: + continue + } + + if !data.Get("output_index").Exists() { + t.Fatalf("%s %s event missing output_index: %s", itemType, event, data.Raw) + } + if got := data.Get("output_index").Int(); got != wantIndex { + t.Fatalf("%s %s output_index = %d, want %d", itemType, event, got, wantIndex) + } + seen[itemType]++ + } + + for _, itemType := range []string{"reasoning", "message", "function_call"} { + if seen[itemType] == 0 { + t.Fatalf("no indexed %s events observed", itemType) + } + } + if got := completed.Get("response.output.#").Int(); got != 3 { + t.Fatalf("completed output count = %d, want 3", got) + } + for index, wantType := range []string{"reasoning", "message", "function_call"} { + if got := completed.Get(fmt.Sprintf("response.output.%d.type", index)).String(); got != wantType { + t.Fatalf("completed output[%d].type = %q, want %q", index, got, wantType) + } + } +} + +func TestConvertClaudeResponseToOpenAIResponses_HiddenServerToolsDoNotCreateOutputIndexGaps(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":1,"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":"Searching. "}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"server_tool_use","id":"srv_123","name":"web_search","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"Qwen3\"}"}}`), + []byte(`data: {"type":"content_block_stop","index":1}`), + []byte(`data: {"type":"content_block_start","index":2,"content_block":{"type":"web_search_tool_result","tool_use_id":"srv_123","content":[]}}`), + []byte(`data: {"type":"content_block_stop","index":2}`), + []byte(`data: {"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}}`), + []byte(`data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"Found it."}}`), + []byte(`data: {"type":"content_block_stop","index":3}`), + []byte(`data: {"type":"content_block_start","index":4,"content_block":{"type":"tool_use","id":"call_123","name":"exec_command","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":4,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"pwd\"}"}}`), + []byte(`data: {"type":"content_block_stop","index":4}`), + []byte(`data: {"type":"message_stop"}`), + } + + outputs := translateClaudeResponsesStreamThroughRegistry(chunks) + + messageAddedCount := 0 + messageDoneCount := 0 + var outputTextDone gjson.Result + var completed gjson.Result + for _, output := range outputs { + event, data := parseClaudeResponsesSSEEvent(t, output) + switch { + case event == "response.output_item.added" && data.Get("item.type").String() == "message": + messageAddedCount++ + if got := data.Get("output_index").Int(); got != 0 { + t.Fatalf("message added output_index = %d, want 0", got) + } + case event == "response.output_item.done" && data.Get("item.type").String() == "message": + messageDoneCount++ + if got := data.Get("output_index").Int(); got != 0 { + t.Fatalf("message done output_index = %d, want 0", got) + } + case strings.HasPrefix(event, "response.output_text.") || strings.HasPrefix(event, "response.content_part."): + if got := data.Get("output_index").Int(); got != 0 { + t.Fatalf("%s output_index = %d, want 0", event, got) + } + if event == "response.output_text.done" { + outputTextDone = data + } + case event == "response.output_item.added" && data.Get("item.type").String() == "function_call", + event == "response.output_item.done" && data.Get("item.type").String() == "function_call", + strings.HasPrefix(event, "response.function_call_arguments."): + if got := data.Get("output_index").Int(); got != 1 { + t.Fatalf("%s output_index = %d, want 1", event, got) + } + case event == "response.completed": + completed = data + } + } + + if messageAddedCount != 1 || messageDoneCount != 1 { + t.Fatalf("message lifecycle counts: added=%d done=%d, want 1 each", messageAddedCount, messageDoneCount) + } + if got := outputTextDone.Get("text").String(); got != "Searching. Found it." { + t.Fatalf("aggregated message text = %q, want %q", got, "Searching. Found it.") + } + if got := completed.Get("response.output.#").Int(); got != 2 { + t.Fatalf("completed output count = %d, want 2", got) + } + if got := completed.Get("response.output.0.type").String(); got != "message" { + t.Fatalf("completed output[0].type = %q, want message", got) + } + if got := completed.Get("response.output.1.type").String(); got != "function_call" { + t.Fatalf("completed output[1].type = %q, want function_call", got) + } +} + func TestConvertClaudeResponseToOpenAIResponses_ReportsCacheTokens(t *testing.T) { chunks := [][]byte{ []byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":13,"output_tokens":1,"cache_read_input_tokens":100,"cache_creation_input_tokens":7}}}`), From 08848c351169bef53b2558acccb925cfc8d4ea39 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 17:46:01 -0400 Subject: [PATCH 4/4] fix(kiro): adapt executor responses for Responses API --- .../kiro/responses/kiro_responses.go | 86 ++++++++++++++++++- .../kiro/responses/kiro_responses_test.go | 82 ++++++++++++++++-- 2 files changed, 158 insertions(+), 10 deletions(-) diff --git a/internal/translator/kiro/responses/kiro_responses.go b/internal/translator/kiro/responses/kiro_responses.go index 3bb974366..a73c0f784 100644 --- a/internal/translator/kiro/responses/kiro_responses.go +++ b/internal/translator/kiro/responses/kiro_responses.go @@ -1,9 +1,13 @@ package responses import ( + "bytes" "context" clauderesponses "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/responses" + kiroopenai "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/kiro/openai" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) func ConvertOpenAIResponsesRequestToKiro(modelName string, inputRawJSON []byte, stream bool) []byte { @@ -11,9 +15,87 @@ func ConvertOpenAIResponsesRequestToKiro(modelName string, inputRawJSON []byte, } func ConvertKiroStreamToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { - return clauderesponses.ConvertClaudeResponseToOpenAIResponses(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param) + _, eventData := kiroopenai.ParseClaudeEvent(rawJSON) + if len(eventData) == 0 { + return [][]byte{} + } + normalized := make([]byte, 0, len(eventData)+len("data: ")) + normalized = append(normalized, "data: "...) + normalized = append(normalized, eventData...) + return clauderesponses.ConvertClaudeResponseToOpenAIResponses(ctx, modelName, originalRequestRawJSON, requestRawJSON, normalized, param) } func ConvertKiroNonStreamToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte { - return clauderesponses.ConvertClaudeResponseToOpenAIResponsesNonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param) + claudeSSE := convertKiroClaudeResponseToSSE(rawJSON) + return clauderesponses.ConvertClaudeResponseToOpenAIResponsesNonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, claudeSSE, param) +} + +func convertKiroClaudeResponseToSSE(rawJSON []byte) []byte { + root := gjson.ParseBytes(rawJSON) + if root.Get("type").String() != "message" { + return rawJSON + } + + var stream bytes.Buffer + appendClaudeDataEvent := func(event []byte) { + stream.WriteString("data: ") + stream.Write(event) + stream.WriteByte('\n') + } + + messageStart := []byte(`{"type":"message_start","message":{"id":"","usage":{}}}`) + messageStart, _ = sjson.SetBytes(messageStart, "message.id", root.Get("id").String()) + if usage := root.Get("usage"); usage.Exists() { + messageStart, _ = sjson.SetRawBytes(messageStart, "message.usage", []byte(usage.Raw)) + } + appendClaudeDataEvent(messageStart) + + for index, block := range root.Get("content").Array() { + blockStart := []byte(`{"type":"content_block_start","index":0,"content_block":{}}`) + blockStart, _ = sjson.SetBytes(blockStart, "index", index) + blockStart, _ = sjson.SetRawBytes(blockStart, "content_block", []byte(block.Raw)) + appendClaudeDataEvent(blockStart) + + switch block.Get("type").String() { + case "text": + delta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`) + delta, _ = sjson.SetBytes(delta, "index", index) + delta, _ = sjson.SetBytes(delta, "delta.text", block.Get("text").String()) + appendClaudeDataEvent(delta) + case "thinking": + delta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`) + delta, _ = sjson.SetBytes(delta, "index", index) + delta, _ = sjson.SetBytes(delta, "delta.thinking", block.Get("thinking").String()) + appendClaudeDataEvent(delta) + if signature := block.Get("signature"); signature.Exists() && signature.String() != "" { + signatureDelta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":""}}`) + signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", index) + signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", signature.String()) + appendClaudeDataEvent(signatureDelta) + } + case "tool_use": + input := block.Get("input").Raw + if input == "" { + input = "{}" + } + delta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`) + delta, _ = sjson.SetBytes(delta, "index", index) + delta, _ = sjson.SetBytes(delta, "delta.partial_json", input) + appendClaudeDataEvent(delta) + } + + blockStop := []byte(`{"type":"content_block_stop","index":0}`) + blockStop, _ = sjson.SetBytes(blockStop, "index", index) + appendClaudeDataEvent(blockStop) + } + + messageDelta := []byte(`{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{}}`) + messageDelta, _ = sjson.SetBytes(messageDelta, "delta.stop_reason", root.Get("stop_reason").String()) + if usage := root.Get("usage"); usage.Exists() { + messageDelta, _ = sjson.SetRawBytes(messageDelta, "usage", []byte(usage.Raw)) + } + appendClaudeDataEvent(messageDelta) + appendClaudeDataEvent([]byte(`{"type":"message_stop"}`)) + + return stream.Bytes() } diff --git a/internal/translator/kiro/responses/kiro_responses_test.go b/internal/translator/kiro/responses/kiro_responses_test.go index 04dc89321..9718d7bcc 100644 --- a/internal/translator/kiro/responses/kiro_responses_test.go +++ b/internal/translator/kiro/responses/kiro_responses_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + kiroclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/kiro/claude" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" ) @@ -82,14 +84,15 @@ func TestKiroResponsesTranslatorRegistrationAndRequestDelegation(t *testing.T) { func TestKiroResponsesStreamFinalizesMessageBeforeFunctionCall(t *testing.T) { chunks := [][]byte{ - []byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":1,"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":"Checking the workspace."}}`), - []byte(`data: {"type":"content_block_stop","index":0}`), - []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_123","name":"exec_command","input":{}}}`), - []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"pwd\"}"}}`), - []byte(`data: {"type":"content_block_stop","index":1}`), - []byte(`data: {"type":"message_stop"}`), + kiroclaude.BuildClaudeMessageStartEvent("kiro-claude-sonnet-4-6", 1), + kiroclaude.BuildClaudeContentBlockStartEvent(0, "text", "", ""), + kiroclaude.BuildClaudeStreamEvent("Checking the workspace.", 0), + kiroclaude.BuildClaudeContentBlockStopEvent(0), + kiroclaude.BuildClaudeContentBlockStartEvent(1, "tool_use", "call_123", "exec_command"), + kiroclaude.BuildClaudeInputJsonDeltaEvent(`{"cmd":"pwd"}`, 1), + kiroclaude.BuildClaudeContentBlockStopEvent(1), + kiroclaude.BuildClaudeMessageDeltaEvent("tool_use", usage.Detail{InputTokens: 1, OutputTokens: 8}), + kiroclaude.BuildClaudeMessageStopOnlyEvent(), } var param any @@ -150,3 +153,66 @@ func TestKiroResponsesStreamFinalizesMessageBeforeFunctionCall(t *testing.T) { t.Fatalf("completed output[1] type = %q, want function_call", got) } } + +func TestKiroResponsesNonStreamAcceptsExecutorClaudeResponse(t *testing.T) { + claudeResponse := kiroclaude.BuildClaudeResponse( + "Checking the workspace.", + []kiroclaude.KiroToolUse{{ + ToolUseID: "call_123", + Name: "exec_command", + Input: map[string]interface{}{"cmd": "pwd"}, + }}, + "kiro-claude-sonnet-4-6", + usage.Detail{ + InputTokens: 3, + OutputTokens: 11, + CacheReadTokens: 7, + CacheCreationTokens: 5, + }, + "tool_use", + ) + claudeRoot := gjson.ParseBytes(claudeResponse) + + out := sdktranslator.TranslateNonStream( + context.Background(), + sdktranslator.FromString("kiro"), + sdktranslator.FormatOpenAIResponse, + "kiro-claude-sonnet-4-6", + nil, + nil, + claudeResponse, + nil, + ) + root := gjson.ParseBytes(out) + + if got, want := root.Get("id").String(), claudeRoot.Get("id").String(); got != want { + t.Fatalf("response id = %q, want executor id %q. Output: %s", got, want, string(out)) + } + if got := root.Get("output.#").Int(); got != 2 { + t.Fatalf("output count = %d, want 2. Output: %s", got, string(out)) + } + if got := root.Get("output.0.type").String(); got != "message" { + t.Fatalf("output[0].type = %q, want message. Output: %s", got, string(out)) + } + if got := root.Get("output.0.content.0.text").String(); got != "Checking the workspace." { + t.Fatalf("message text = %q. Output: %s", got, string(out)) + } + if got := root.Get("output.1.type").String(); got != "function_call" { + t.Fatalf("output[1].type = %q, want function_call. Output: %s", got, string(out)) + } + if got := root.Get("output.1.arguments").String(); got != `{"cmd":"pwd"}` { + t.Fatalf("function arguments = %q. Output: %s", got, string(out)) + } + if got := root.Get("usage.input_tokens").Int(); got != 15 { + t.Fatalf("usage.input_tokens = %d, want 15. Output: %s", got, string(out)) + } + if got := root.Get("usage.input_tokens_details.cached_tokens").Int(); got != 7 { + t.Fatalf("usage cached_tokens = %d, want 7. Output: %s", got, string(out)) + } + if got := root.Get("usage.output_tokens").Int(); got != 11 { + t.Fatalf("usage.output_tokens = %d, want 11. Output: %s", got, string(out)) + } + if got := root.Get("usage.total_tokens").Int(); got != 26 { + t.Fatalf("usage.total_tokens = %d, want 26. Output: %s", got, string(out)) + } +}