mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-11 02:46:47 +08:00
feat(translator): enhance content block handling and add stream-specific test
- Refactored content block start/stop logic into `startCodexTextBlock` and `stopCodexTextBlock` for better readability and reusability. - Updated logic to ensure proper handling of "output_text" block events to avoid ghost stop emissions. - Added `TestConvertCodexResponseToClaude_StreamTextBeforeToolCallsDoesNotEmitGhostStop` to validate content block start/stop behavior in streamed responses.
This commit is contained in:
@@ -104,25 +104,22 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
||||
case "response.reasoning_summary_part.done":
|
||||
params.ThinkingStopPending = true
|
||||
case "response.content_part.added":
|
||||
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)
|
||||
if rootResult.Get("part.type").String() == "output_text" {
|
||||
output = append(output, startCodexTextBlock(params)...)
|
||||
}
|
||||
case "response.output_text.delta":
|
||||
params.HasTextDelta = true
|
||||
output = append(output, finalizeCodexThinkingBlock(params)...)
|
||||
output = append(output, startCodexTextBlock(params)...)
|
||||
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", rootResult.Get("delta").String())
|
||||
|
||||
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
|
||||
case "response.content_part.done":
|
||||
template = []byte(`{"type":"content_block_stop","index":0}`)
|
||||
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
||||
params.TextBlockOpen = false
|
||||
params.BlockIndex++
|
||||
|
||||
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
|
||||
if rootResult.Get("part.type").String() == "output_text" {
|
||||
output = append(output, stopCodexTextBlock(params)...)
|
||||
}
|
||||
case "response.web_search_call.searching", "response.web_search_call.completed", "response.web_search_call.in_progress":
|
||||
// Wait for populated web_search_call items on output_item.done.
|
||||
case "response.completed", "response.incomplete":
|
||||
@@ -145,6 +142,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
||||
switch itemType {
|
||||
case "function_call":
|
||||
output = append(output, finalizeCodexThinkingBlock(params)...)
|
||||
output = append(output, stopCodexTextBlock(params)...)
|
||||
params.HasToolCall = true
|
||||
params.HasReceivedArgumentsDelta = false
|
||||
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
|
||||
@@ -199,24 +197,15 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
output = append(output, startCodexTextBlock(params)...)
|
||||
|
||||
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++
|
||||
output = append(output, stopCodexTextBlock(params)...)
|
||||
params.HasTextDelta = true
|
||||
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
|
||||
case "function_call":
|
||||
template = []byte(`{"type":"content_block_stop","index":0}`)
|
||||
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
||||
@@ -517,6 +506,31 @@ func ClaudeTokenCount(_ context.Context, count int64) []byte {
|
||||
return translatorcommon.ClaudeInputTokensJSON(count)
|
||||
}
|
||||
|
||||
func startCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte {
|
||||
if params.TextBlockOpen {
|
||||
return nil
|
||||
}
|
||||
|
||||
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
|
||||
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
||||
params.TextBlockOpen = true
|
||||
|
||||
return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2)
|
||||
}
|
||||
|
||||
func stopCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte {
|
||||
if !params.TextBlockOpen {
|
||||
return nil
|
||||
}
|
||||
|
||||
template := []byte(`{"type":"content_block_stop","index":0}`)
|
||||
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
|
||||
params.TextBlockOpen = false
|
||||
params.BlockIndex++
|
||||
|
||||
return translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", template, 2)
|
||||
}
|
||||
|
||||
func startCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
|
||||
if params.ThinkingBlockOpen {
|
||||
return nil
|
||||
|
||||
@@ -472,6 +472,65 @@ func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToClaude_StreamTextBeforeToolCallsDoesNotEmitGhostStop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[{"name":"Read","description":"read"}]}`)
|
||||
var param any
|
||||
|
||||
chunks := [][]byte{
|
||||
[]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"grok-composer-2.5-fast"}}`),
|
||||
[]byte(`data: {"type":"response.output_item.added","item":{"type":"message","status":"in_progress"},"output_index":1}`),
|
||||
[]byte(`data: {"type":"response.content_part.added","part":{"type":"output_text"},"content_index":0,"output_index":1}`),
|
||||
[]byte(`data: {"type":"response.output_text.delta","delta":"查看项目的 README 和核心入口,以便准确说明项目用途。\n","output_index":1}`),
|
||||
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read","status":"in_progress"},"output_index":2}`),
|
||||
[]byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"path\":\"/tmp/README.md\"}","output_index":2}`),
|
||||
[]byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"path\":\"/tmp/README.md\"}","output_index":2}`),
|
||||
[]byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"path\":\"/tmp/README.md\"}"},"output_index":2}`),
|
||||
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_b","name":"Read","status":"in_progress"},"output_index":3}`),
|
||||
[]byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"path\":\"/tmp/main.go\"}","output_index":3}`),
|
||||
[]byte(`data: {"type":"response.content_part.done","part":{"type":"output_text"},"content_index":0,"output_index":1}`),
|
||||
[]byte(`data: {"type":"response.output_item.done","item":{"type":"message","status":"completed"},"output_index":1}`),
|
||||
[]byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"path\":\"/tmp/main.go\"}","output_index":3}`),
|
||||
[]byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_b","name":"Read","arguments":"{\"path\":\"/tmp/main.go\"}"},"output_index":3}`),
|
||||
[]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)...)
|
||||
}
|
||||
|
||||
var startIndices []int64
|
||||
var stopIndices []int64
|
||||
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: "))
|
||||
switch data.Get("type").String() {
|
||||
case "content_block_start":
|
||||
startIndices = append(startIndices, data.Get("index").Int())
|
||||
case "content_block_stop":
|
||||
stopIndices = append(stopIndices, data.Get("index").Int())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(startIndices) != 3 {
|
||||
t.Fatalf("expected 3 content_block_start events (text + 2 tools), got %v", startIndices)
|
||||
}
|
||||
if len(stopIndices) != 3 {
|
||||
t.Fatalf("expected 3 content_block_stop events, got %v", stopIndices)
|
||||
}
|
||||
if startIndices[0] != 0 || startIndices[1] != 1 || startIndices[2] != 2 {
|
||||
t.Fatalf("unexpected start indices: %v", startIndices)
|
||||
}
|
||||
if stopIndices[0] != 0 || stopIndices[1] != 1 || stopIndices[2] != 2 {
|
||||
t.Fatalf("unexpected stop indices: %v", stopIndices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[]}`)
|
||||
|
||||
Reference in New Issue
Block a user