fix(translator): add unit tests to validate output_item.done fallback logic for Gemini and Claude
This commit is contained in:
@@ -26,6 +26,8 @@ type ConvertCodexResponseToClaudeParams struct {
|
|||||||
HasToolCall bool
|
HasToolCall bool
|
||||||
BlockIndex int
|
BlockIndex int
|
||||||
HasReceivedArgumentsDelta bool
|
HasReceivedArgumentsDelta bool
|
||||||
|
HasTextDelta bool
|
||||||
|
TextBlockOpen bool
|
||||||
ThinkingBlockOpen bool
|
ThinkingBlockOpen bool
|
||||||
ThinkingStopPending bool
|
ThinkingStopPending bool
|
||||||
ThinkingSignature string
|
ThinkingSignature string
|
||||||
@@ -104,9 +106,11 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
|||||||
} else if typeStr == "response.content_part.added" {
|
} else if typeStr == "response.content_part.added" {
|
||||||
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
|
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
|
||||||
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
||||||
|
params.TextBlockOpen = true
|
||||||
|
|
||||||
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
|
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
|
||||||
} else if typeStr == "response.output_text.delta" {
|
} else if typeStr == "response.output_text.delta" {
|
||||||
|
params.HasTextDelta = true
|
||||||
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
|
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
|
||||||
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
||||||
template, _ = sjson.SetBytes(template, "delta.text", rootResult.Get("delta").String())
|
template, _ = sjson.SetBytes(template, "delta.text", rootResult.Get("delta").String())
|
||||||
@@ -115,6 +119,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
|||||||
} else if typeStr == "response.content_part.done" {
|
} else if typeStr == "response.content_part.done" {
|
||||||
template = []byte(`{"type":"content_block_stop","index":0}`)
|
template = []byte(`{"type":"content_block_stop","index":0}`)
|
||||||
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
||||||
|
params.TextBlockOpen = false
|
||||||
params.BlockIndex++
|
params.BlockIndex++
|
||||||
|
|
||||||
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
|
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
|
||||||
@@ -172,7 +177,49 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
|||||||
} else if typeStr == "response.output_item.done" {
|
} else if typeStr == "response.output_item.done" {
|
||||||
itemResult := rootResult.Get("item")
|
itemResult := rootResult.Get("item")
|
||||||
itemType := itemResult.Get("type").String()
|
itemType := itemResult.Get("type").String()
|
||||||
if itemType == "function_call" {
|
if itemType == "message" {
|
||||||
|
if params.HasTextDelta {
|
||||||
|
return [][]byte{output}
|
||||||
|
}
|
||||||
|
contentResult := itemResult.Get("content")
|
||||||
|
if !contentResult.Exists() || !contentResult.IsArray() {
|
||||||
|
return [][]byte{output}
|
||||||
|
}
|
||||||
|
var textBuilder strings.Builder
|
||||||
|
contentResult.ForEach(func(_, part gjson.Result) bool {
|
||||||
|
if part.Get("type").String() != "output_text" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if txt := part.Get("text").String(); txt != "" {
|
||||||
|
textBuilder.WriteString(txt)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
text := textBuilder.String()
|
||||||
|
if text == "" {
|
||||||
|
return [][]byte{output}
|
||||||
|
}
|
||||||
|
|
||||||
|
output = append(output, finalizeCodexThinkingBlock(params)...)
|
||||||
|
if !params.TextBlockOpen {
|
||||||
|
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
|
||||||
|
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
||||||
|
params.TextBlockOpen = true
|
||||||
|
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
|
||||||
|
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
||||||
|
template, _ = sjson.SetBytes(template, "delta.text", text)
|
||||||
|
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
|
||||||
|
|
||||||
|
template = []byte(`{"type":"content_block_stop","index":0}`)
|
||||||
|
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
||||||
|
params.TextBlockOpen = false
|
||||||
|
params.BlockIndex++
|
||||||
|
params.HasTextDelta = true
|
||||||
|
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
|
||||||
|
} else if itemType == "function_call" {
|
||||||
template = []byte(`{"type":"content_block_stop","index":0}`)
|
template = []byte(`{"type":"content_block_stop","index":0}`)
|
||||||
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
||||||
params.BlockIndex++
|
params.BlockIndex++
|
||||||
|
|||||||
@@ -280,3 +280,40 @@ func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *test
|
|||||||
t.Fatalf("unexpected thinking text: %q", got)
|
t.Fatalf("unexpected thinking text: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
originalRequest := []byte(`{"tools":[]}`)
|
||||||
|
var param any
|
||||||
|
|
||||||
|
chunks := [][]byte{
|
||||||
|
[]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5\"}}"),
|
||||||
|
[]byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}"),
|
||||||
|
[]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputs [][]byte
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
foundText := false
|
||||||
|
for _, out := range outputs {
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if !strings.HasPrefix(line, "data: ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := gjson.Parse(strings.TrimPrefix(line, "data: "))
|
||||||
|
if data.Get("type").String() == "content_block_delta" && data.Get("delta.type").String() == "text_delta" && data.Get("delta.text").String() == "ok" {
|
||||||
|
foundText = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundText {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundText {
|
||||||
|
t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type ConvertCodexResponseToGeminiParams struct {
|
|||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
ResponseID string
|
ResponseID string
|
||||||
LastStorageOutput []byte
|
LastStorageOutput []byte
|
||||||
|
HasOutputTextDelta bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format.
|
// ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format.
|
||||||
@@ -46,6 +47,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
|
|||||||
CreatedAt: 0,
|
CreatedAt: 0,
|
||||||
ResponseID: "",
|
ResponseID: "",
|
||||||
LastStorageOutput: nil,
|
LastStorageOutput: nil,
|
||||||
|
HasOutputTextDelta: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,18 +60,18 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
|
|||||||
typeResult := rootResult.Get("type")
|
typeResult := rootResult.Get("type")
|
||||||
typeStr := typeResult.String()
|
typeStr := typeResult.String()
|
||||||
|
|
||||||
|
params := (*param).(*ConvertCodexResponseToGeminiParams)
|
||||||
|
|
||||||
// Base Gemini response template
|
// Base Gemini response template
|
||||||
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"}`)
|
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"}`)
|
||||||
if len((*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput) > 0 && typeStr == "response.output_item.done" {
|
{
|
||||||
template = append([]byte(nil), (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput...)
|
template, _ = sjson.SetBytes(template, "modelVersion", params.Model)
|
||||||
} else {
|
|
||||||
template, _ = sjson.SetBytes(template, "modelVersion", (*param).(*ConvertCodexResponseToGeminiParams).Model)
|
|
||||||
createdAtResult := rootResult.Get("response.created_at")
|
createdAtResult := rootResult.Get("response.created_at")
|
||||||
if createdAtResult.Exists() {
|
if createdAtResult.Exists() {
|
||||||
(*param).(*ConvertCodexResponseToGeminiParams).CreatedAt = createdAtResult.Int()
|
params.CreatedAt = createdAtResult.Int()
|
||||||
template, _ = sjson.SetBytes(template, "createTime", time.Unix((*param).(*ConvertCodexResponseToGeminiParams).CreatedAt, 0).Format(time.RFC3339Nano))
|
template, _ = sjson.SetBytes(template, "createTime", time.Unix(params.CreatedAt, 0).Format(time.RFC3339Nano))
|
||||||
}
|
}
|
||||||
template, _ = sjson.SetBytes(template, "responseId", (*param).(*ConvertCodexResponseToGeminiParams).ResponseID)
|
template, _ = sjson.SetBytes(template, "responseId", params.ResponseID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle function call completion
|
// Handle function call completion
|
||||||
@@ -101,7 +103,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
|
|||||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", functionCall)
|
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", functionCall)
|
||||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
|
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
|
||||||
|
|
||||||
(*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput = append([]byte(nil), template...)
|
params.LastStorageOutput = append([]byte(nil), template...)
|
||||||
|
|
||||||
// Use this return to storage message
|
// Use this return to storage message
|
||||||
return [][]byte{}
|
return [][]byte{}
|
||||||
@@ -111,15 +113,45 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
|
|||||||
if typeStr == "response.created" { // Handle response creation - set model and response ID
|
if typeStr == "response.created" { // Handle response creation - set model and response ID
|
||||||
template, _ = sjson.SetBytes(template, "modelVersion", rootResult.Get("response.model").String())
|
template, _ = sjson.SetBytes(template, "modelVersion", rootResult.Get("response.model").String())
|
||||||
template, _ = sjson.SetBytes(template, "responseId", rootResult.Get("response.id").String())
|
template, _ = sjson.SetBytes(template, "responseId", rootResult.Get("response.id").String())
|
||||||
(*param).(*ConvertCodexResponseToGeminiParams).ResponseID = rootResult.Get("response.id").String()
|
params.ResponseID = rootResult.Get("response.id").String()
|
||||||
} else if typeStr == "response.reasoning_summary_text.delta" { // Handle reasoning/thinking content delta
|
} else if typeStr == "response.reasoning_summary_text.delta" { // Handle reasoning/thinking content delta
|
||||||
part := []byte(`{"thought":true,"text":""}`)
|
part := []byte(`{"thought":true,"text":""}`)
|
||||||
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
|
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
|
||||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
|
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
|
||||||
} else if typeStr == "response.output_text.delta" { // Handle regular text content delta
|
} else if typeStr == "response.output_text.delta" { // Handle regular text content delta
|
||||||
|
params.HasOutputTextDelta = true
|
||||||
part := []byte(`{"text":""}`)
|
part := []byte(`{"text":""}`)
|
||||||
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
|
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
|
||||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
|
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
|
||||||
|
} else if typeStr == "response.output_item.done" { // Fallback: emit final message text when no delta chunks were received
|
||||||
|
itemResult := rootResult.Get("item")
|
||||||
|
if itemResult.Get("type").String() != "message" || params.HasOutputTextDelta {
|
||||||
|
return [][]byte{}
|
||||||
|
}
|
||||||
|
contentResult := itemResult.Get("content")
|
||||||
|
if !contentResult.Exists() || !contentResult.IsArray() {
|
||||||
|
return [][]byte{}
|
||||||
|
}
|
||||||
|
wroteText := false
|
||||||
|
contentResult.ForEach(func(_, partResult gjson.Result) bool {
|
||||||
|
if partResult.Get("type").String() != "output_text" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
text := partResult.Get("text").String()
|
||||||
|
if text == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
part := []byte(`{"text":""}`)
|
||||||
|
part, _ = sjson.SetBytes(part, "text", text)
|
||||||
|
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
|
||||||
|
wroteText = true
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if wroteText {
|
||||||
|
params.HasOutputTextDelta = true
|
||||||
|
return [][]byte{template}
|
||||||
|
}
|
||||||
|
return [][]byte{}
|
||||||
} else if typeStr == "response.completed" { // Handle response completion with usage metadata
|
} else if typeStr == "response.completed" { // Handle response completion with usage metadata
|
||||||
template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", rootResult.Get("response.usage.input_tokens").Int())
|
template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", rootResult.Get("response.usage.input_tokens").Int())
|
||||||
template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", rootResult.Get("response.usage.output_tokens").Int())
|
template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", rootResult.Get("response.usage.output_tokens").Int())
|
||||||
@@ -129,11 +161,10 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
|
|||||||
return [][]byte{}
|
return [][]byte{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len((*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput) > 0 {
|
if len(params.LastStorageOutput) > 0 {
|
||||||
return [][]byte{
|
stored := append([]byte(nil), params.LastStorageOutput...)
|
||||||
append([]byte(nil), (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput...),
|
params.LastStorageOutput = nil
|
||||||
template,
|
return [][]byte{stored, template}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [][]byte{template}
|
return [][]byte{template}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package gemini
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertCodexResponseToGemini_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
originalRequest := []byte(`{"tools":[]}`)
|
||||||
|
var param any
|
||||||
|
|
||||||
|
chunks := [][]byte{
|
||||||
|
[]byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}"),
|
||||||
|
[]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputs [][]byte
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
outputs = append(outputs, ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, ¶m)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, out := range outputs {
|
||||||
|
if gjson.GetBytes(out, "candidates.0.content.parts.0.text").String() == "ok" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user