Skip to content

Commit fcc59d6

Browse files
committed
fix(translator): add unit tests to validate output_item.done fallback logic for Gemini and Claude
1 parent 91e7591 commit fcc59d6

4 files changed

Lines changed: 173 additions & 23 deletions

File tree

internal/translator/codex/claude/codex_claude_response.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type ConvertCodexResponseToClaudeParams struct {
2626
HasToolCall bool
2727
BlockIndex int
2828
HasReceivedArgumentsDelta bool
29+
HasTextDelta bool
30+
TextBlockOpen bool
2931
ThinkingBlockOpen bool
3032
ThinkingStopPending bool
3133
ThinkingSignature string
@@ -104,9 +106,11 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
104106
} else if typeStr == "response.content_part.added" {
105107
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
106108
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
109+
params.TextBlockOpen = true
107110

108111
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
109112
} else if typeStr == "response.output_text.delta" {
113+
params.HasTextDelta = true
110114
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
111115
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
112116
template, _ = sjson.SetBytes(template, "delta.text", rootResult.Get("delta").String())
@@ -115,6 +119,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
115119
} else if typeStr == "response.content_part.done" {
116120
template = []byte(`{"type":"content_block_stop","index":0}`)
117121
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
122+
params.TextBlockOpen = false
118123
params.BlockIndex++
119124

120125
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
@@ -172,7 +177,49 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
172177
} else if typeStr == "response.output_item.done" {
173178
itemResult := rootResult.Get("item")
174179
itemType := itemResult.Get("type").String()
175-
if itemType == "function_call" {
180+
if itemType == "message" {
181+
if params.HasTextDelta {
182+
return [][]byte{output}
183+
}
184+
contentResult := itemResult.Get("content")
185+
if !contentResult.Exists() || !contentResult.IsArray() {
186+
return [][]byte{output}
187+
}
188+
var textBuilder strings.Builder
189+
contentResult.ForEach(func(_, part gjson.Result) bool {
190+
if part.Get("type").String() != "output_text" {
191+
return true
192+
}
193+
if txt := part.Get("text").String(); txt != "" {
194+
textBuilder.WriteString(txt)
195+
}
196+
return true
197+
})
198+
text := textBuilder.String()
199+
if text == "" {
200+
return [][]byte{output}
201+
}
202+
203+
output = append(output, finalizeCodexThinkingBlock(params)...)
204+
if !params.TextBlockOpen {
205+
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
206+
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
207+
params.TextBlockOpen = true
208+
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
209+
}
210+
211+
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
212+
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
213+
template, _ = sjson.SetBytes(template, "delta.text", text)
214+
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
215+
216+
template = []byte(`{"type":"content_block_stop","index":0}`)
217+
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
218+
params.TextBlockOpen = false
219+
params.BlockIndex++
220+
params.HasTextDelta = true
221+
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
222+
} else if itemType == "function_call" {
176223
template = []byte(`{"type":"content_block_stop","index":0}`)
177224
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
178225
params.BlockIndex++

internal/translator/codex/claude/codex_claude_response_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,40 @@ func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *test
280280
t.Fatalf("unexpected thinking text: %q", got)
281281
}
282282
}
283+
284+
func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) {
285+
ctx := context.Background()
286+
originalRequest := []byte(`{"tools":[]}`)
287+
var param any
288+
289+
chunks := [][]byte{
290+
[]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5\"}}"),
291+
[]byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}"),
292+
[]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
293+
}
294+
295+
var outputs [][]byte
296+
for _, chunk := range chunks {
297+
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, &param)...)
298+
}
299+
300+
foundText := false
301+
for _, out := range outputs {
302+
for _, line := range strings.Split(string(out), "\n") {
303+
if !strings.HasPrefix(line, "data: ") {
304+
continue
305+
}
306+
data := gjson.Parse(strings.TrimPrefix(line, "data: "))
307+
if data.Get("type").String() == "content_block_delta" && data.Get("delta.type").String() == "text_delta" && data.Get("delta.text").String() == "ok" {
308+
foundText = true
309+
break
310+
}
311+
}
312+
if foundText {
313+
break
314+
}
315+
}
316+
if !foundText {
317+
t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs)
318+
}
319+
}

internal/translator/codex/gemini/codex_gemini_response.go

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ var (
2020

2121
// ConvertCodexResponseToGeminiParams holds parameters for response conversion.
2222
type ConvertCodexResponseToGeminiParams struct {
23-
Model string
24-
CreatedAt int64
25-
ResponseID string
26-
LastStorageOutput []byte
23+
Model string
24+
CreatedAt int64
25+
ResponseID string
26+
LastStorageOutput []byte
27+
HasOutputTextDelta bool
2728
}
2829

2930
// ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format.
@@ -42,10 +43,11 @@ type ConvertCodexResponseToGeminiParams struct {
4243
func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
4344
if *param == nil {
4445
*param = &ConvertCodexResponseToGeminiParams{
45-
Model: modelName,
46-
CreatedAt: 0,
47-
ResponseID: "",
48-
LastStorageOutput: nil,
46+
Model: modelName,
47+
CreatedAt: 0,
48+
ResponseID: "",
49+
LastStorageOutput: nil,
50+
HasOutputTextDelta: false,
4951
}
5052
}
5153

@@ -58,18 +60,18 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
5860
typeResult := rootResult.Get("type")
5961
typeStr := typeResult.String()
6062

63+
params := (*param).(*ConvertCodexResponseToGeminiParams)
64+
6165
// Base Gemini response template
6266
template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T02:52:03.884209Z","responseId":"06CeaPH7NaCU48APvNXDyA4"}`)
63-
if len((*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput) > 0 && typeStr == "response.output_item.done" {
64-
template = append([]byte(nil), (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput...)
65-
} else {
66-
template, _ = sjson.SetBytes(template, "modelVersion", (*param).(*ConvertCodexResponseToGeminiParams).Model)
67+
{
68+
template, _ = sjson.SetBytes(template, "modelVersion", params.Model)
6769
createdAtResult := rootResult.Get("response.created_at")
6870
if createdAtResult.Exists() {
69-
(*param).(*ConvertCodexResponseToGeminiParams).CreatedAt = createdAtResult.Int()
70-
template, _ = sjson.SetBytes(template, "createTime", time.Unix((*param).(*ConvertCodexResponseToGeminiParams).CreatedAt, 0).Format(time.RFC3339Nano))
71+
params.CreatedAt = createdAtResult.Int()
72+
template, _ = sjson.SetBytes(template, "createTime", time.Unix(params.CreatedAt, 0).Format(time.RFC3339Nano))
7173
}
72-
template, _ = sjson.SetBytes(template, "responseId", (*param).(*ConvertCodexResponseToGeminiParams).ResponseID)
74+
template, _ = sjson.SetBytes(template, "responseId", params.ResponseID)
7375
}
7476

7577
// Handle function call completion
@@ -101,7 +103,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
101103
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", functionCall)
102104
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
103105

104-
(*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput = append([]byte(nil), template...)
106+
params.LastStorageOutput = append([]byte(nil), template...)
105107

106108
// Use this return to storage message
107109
return [][]byte{}
@@ -111,15 +113,45 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
111113
if typeStr == "response.created" { // Handle response creation - set model and response ID
112114
template, _ = sjson.SetBytes(template, "modelVersion", rootResult.Get("response.model").String())
113115
template, _ = sjson.SetBytes(template, "responseId", rootResult.Get("response.id").String())
114-
(*param).(*ConvertCodexResponseToGeminiParams).ResponseID = rootResult.Get("response.id").String()
116+
params.ResponseID = rootResult.Get("response.id").String()
115117
} else if typeStr == "response.reasoning_summary_text.delta" { // Handle reasoning/thinking content delta
116118
part := []byte(`{"thought":true,"text":""}`)
117119
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
118120
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
119121
} else if typeStr == "response.output_text.delta" { // Handle regular text content delta
122+
params.HasOutputTextDelta = true
120123
part := []byte(`{"text":""}`)
121124
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
122125
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
126+
} else if typeStr == "response.output_item.done" { // Fallback: emit final message text when no delta chunks were received
127+
itemResult := rootResult.Get("item")
128+
if itemResult.Get("type").String() != "message" || params.HasOutputTextDelta {
129+
return [][]byte{}
130+
}
131+
contentResult := itemResult.Get("content")
132+
if !contentResult.Exists() || !contentResult.IsArray() {
133+
return [][]byte{}
134+
}
135+
wroteText := false
136+
contentResult.ForEach(func(_, partResult gjson.Result) bool {
137+
if partResult.Get("type").String() != "output_text" {
138+
return true
139+
}
140+
text := partResult.Get("text").String()
141+
if text == "" {
142+
return true
143+
}
144+
part := []byte(`{"text":""}`)
145+
part, _ = sjson.SetBytes(part, "text", text)
146+
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
147+
wroteText = true
148+
return true
149+
})
150+
if wroteText {
151+
params.HasOutputTextDelta = true
152+
return [][]byte{template}
153+
}
154+
return [][]byte{}
123155
} else if typeStr == "response.completed" { // Handle response completion with usage metadata
124156
template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", rootResult.Get("response.usage.input_tokens").Int())
125157
template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", rootResult.Get("response.usage.output_tokens").Int())
@@ -129,11 +161,10 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
129161
return [][]byte{}
130162
}
131163

132-
if len((*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput) > 0 {
133-
return [][]byte{
134-
append([]byte(nil), (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput...),
135-
template,
136-
}
164+
if len(params.LastStorageOutput) > 0 {
165+
stored := append([]byte(nil), params.LastStorageOutput...)
166+
params.LastStorageOutput = nil
167+
return [][]byte{stored, template}
137168
}
138169
return [][]byte{template}
139170
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package gemini
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/tidwall/gjson"
8+
)
9+
10+
func TestConvertCodexResponseToGemini_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) {
11+
ctx := context.Background()
12+
originalRequest := []byte(`{"tools":[]}`)
13+
var param any
14+
15+
chunks := [][]byte{
16+
[]byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}"),
17+
[]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
18+
}
19+
20+
var outputs [][]byte
21+
for _, chunk := range chunks {
22+
outputs = append(outputs, ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, &param)...)
23+
}
24+
25+
found := false
26+
for _, out := range outputs {
27+
if gjson.GetBytes(out, "candidates.0.content.parts.0.text").String() == "ok" {
28+
found = true
29+
break
30+
}
31+
}
32+
if !found {
33+
t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs)
34+
}
35+
}

0 commit comments

Comments
 (0)