feat(translator): implement output indexing for OpenAI response generation

- Refactored state management to introduce deterministic output indexing for reasoning, messages, and function calls.
- Added new structures `MessageItems` and `ReasoningItems` to aggregate responses with consistent indices.
- Updated OpenAI response generation to include `output_index` for all outputs, ensuring correct order and no gaps.
- Enhanced tests to validate contiguous output indices and response event lifecycle.

Fixed: #4429
This commit is contained in:
Luis Pater
2026-07-19 17:15:04 +08:00
parent f175c084d3
commit b7299a3d86
2 changed files with 614 additions and 54 deletions
@@ -14,34 +14,52 @@ 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
MessageAnnotations []any
MessageItems []claudeResponsesMessageItem
// reasoning state
ReasoningActive bool
ReasoningItemID string
ReasoningBuf strings.Builder
ReasoningSignature string
ReasoningPartAdded bool
ReasoningIndex int
ReasoningItems []claudeResponsesReasoningItem
// usage aggregation
Usage claudeResponsesUsageTokens
}
type claudeResponsesMessageItem struct {
ID string
OutputIndex int
Text string
Annotations []any
}
type claudeResponsesReasoningItem struct {
ID string
OutputIndex int
Text string
Signature string
}
type claudeResponsesUsageTokens struct {
InputTokens int64
OutputTokens int64
@@ -124,21 +142,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 +190,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 {
@@ -154,17 +198,34 @@ func (st *claudeToResponsesState) finalizeAssistantMessage(nextSeq func() int) [
}
out = append(out, emitEvent("response.output_item.done", final))
st.MessageItems = append(st.MessageItems, claudeResponsesMessageItem{
ID: st.CurrentMsgID,
OutputIndex: outputIndex,
Text: fullText,
Annotations: append([]any(nil), st.MessageAnnotations...),
})
st.InTextBlock = false
st.MessageOpen = false
st.ContentPartOpen = false
st.CurrentMsgID = ""
st.MessageOutputIndex = -1
st.TextBuf.Reset()
st.CurrentTextBuf.Reset()
st.MessageAnnotations = nil
return out
}
// 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)
@@ -188,21 +249,25 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
st.TextBuf.Reset()
st.CurrentTextBuf.Reset()
st.MessageAnnotations = nil
st.MessageItems = 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.ReasoningPartAdded = false
st.ReasoningIndex = -1
st.ReasoningItems = nil
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 +292,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)
st.CurrentMsgID = fmt.Sprintf("msg_%s_%d", st.ResponseID, len(st.MessageItems))
}
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,16 +308,19 @@ 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
}
} 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()
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")
@@ -262,9 +332,10 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
st.FuncCallIDs[idx] = st.CurrentFCID
st.FuncNames[idx] = name
} else if typ == "thinking" {
out = append(out, st.finalizeAssistantMessage(nextSeq)...)
// 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() != "" {
@@ -273,7 +344,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))
@@ -281,9 +352,8 @@ 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
}
case "content_block_delta":
d := root.Get("delta")
@@ -296,6 +366,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
@@ -312,10 +383,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))
}
@@ -349,6 +421,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 {
@@ -358,12 +431,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)
@@ -389,14 +462,21 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
itemDone, _ = sjson.SetBytes(itemDone, "item.id", st.ReasoningItemID)
itemDone, _ = sjson.SetBytes(itemDone, "output_index", st.ReasoningIndex)
itemDone, _ = sjson.SetBytes(itemDone, "item.encrypted_content", st.ReasoningSignature)
if full != "" {
summary := []byte(`{"type":"summary_text","text":""}`)
summary, _ = sjson.SetBytes(summary, "text", full)
itemDone, _ = sjson.SetRawBytes(itemDone, "item.summary.-1", summary)
}
summary := []byte(`{"type":"summary_text","text":""}`)
summary, _ = sjson.SetBytes(summary, "text", full)
itemDone, _ = sjson.SetRawBytes(itemDone, "item.summary.-1", summary)
out = append(out, emitEvent("response.output_item.done", itemDone))
st.ReasoningItems = append(st.ReasoningItems, claudeResponsesReasoningItem{
ID: st.ReasoningItemID,
OutputIndex: st.ReasoningIndex,
Text: full,
Signature: st.ReasoningSignature,
})
st.ReasoningActive = false
st.ReasoningPartAdded = false
st.ReasoningItemID = ""
st.ReasoningBuf.Reset()
st.ReasoningSignature = ""
st.ReasoningIndex = -1
}
return noSSEOutput(out)
case "message_delta":
@@ -478,27 +558,25 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
// Build response.output from aggregated state
outputsWrapper := []byte(`{"arr":[]}`)
// reasoning item (if any)
if st.ReasoningBuf.Len() > 0 || st.ReasoningPartAdded || st.ReasoningSignature != "" {
// reasoning items
for _, reasoning := range st.ReasoningItems {
item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
item, _ = sjson.SetBytes(item, "id", st.ReasoningItemID)
item, _ = sjson.SetBytes(item, "encrypted_content", st.ReasoningSignature)
if st.ReasoningBuf.Len() > 0 {
summary := []byte(`{"type":"summary_text","text":""}`)
summary, _ = sjson.SetBytes(summary, "text", st.ReasoningBuf.String())
item, _ = sjson.SetRawBytes(item, "summary.-1", summary)
}
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
item, _ = sjson.SetBytes(item, "id", reasoning.ID)
item, _ = sjson.SetBytes(item, "encrypted_content", reasoning.Signature)
summary := []byte(`{"type":"summary_text","text":""}`)
summary, _ = sjson.SetBytes(summary, "text", reasoning.Text)
item, _ = sjson.SetRawBytes(item, "summary.-1", summary)
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", reasoning.OutputIndex), item)
}
// assistant message item (if any text)
if st.TextBuf.Len() > 0 || st.InTextBlock || st.CurrentMsgID != "" {
// assistant message items
for _, message := range st.MessageItems {
item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
item, _ = sjson.SetBytes(item, "id", st.CurrentMsgID)
item, _ = sjson.SetBytes(item, "content.0.text", st.TextBuf.String())
if len(st.MessageAnnotations) > 0 {
item, _ = sjson.SetBytes(item, "content.0.annotations", st.MessageAnnotations)
item, _ = sjson.SetBytes(item, "id", message.ID)
item, _ = sjson.SetBytes(item, "content.0.text", message.Text)
if len(message.Annotations) > 0 {
item, _ = sjson.SetBytes(item, "content.0.annotations", message.Annotations)
}
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", message.OutputIndex), item)
}
// function_call items (in ascending index order for determinism)
if len(st.FuncArgsBuf) > 0 {
@@ -516,8 +594,8 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
}
}
for _, idx := range idxs {
args := ""
if b := st.FuncArgsBuf[idx]; b != nil {
args := "{}"
if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 {
args = b.String()
}
callID := st.FuncCallIDs[idx]
@@ -530,17 +608,18 @@ 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 {
completed, _ = sjson.SetRawBytes(completed, "response.output", []byte(gjson.GetBytes(outputsWrapper, "arr").Raw))
}
reasoningTokens := int64(0)
if st.ReasoningBuf.Len() > 0 {
reasoningTokens = int64(st.ReasoningBuf.Len() / 4)
reasoningLength := 0
for _, reasoning := range st.ReasoningItems {
reasoningLength += len(reasoning.Text)
}
reasoningTokens := int64(reasoningLength / 4)
usagePresent := st.Usage.HasUsage || reasoningTokens > 0
if usagePresent {
inputTokens, outputTokens, totalTokens, cachedTokens := st.Usage.OpenAIResponsesUsage()
@@ -2,6 +2,7 @@ package responses
import (
"context"
"fmt"
"strings"
"testing"
@@ -166,6 +167,486 @@ 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_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_StartsNewMessageAfterFunctionCall(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":"Before tool."}}`),
[]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":"content_block_start","index":2,"content_block":{"type":"text","text":""}}`),
[]byte(`data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"After tool."}}`),
[]byte(`data: {"type":"content_block_stop","index":2}`),
[]byte(`data: {"type":"message_stop"}`),
}
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
var lifecycle []string
var messageIDs []string
var completed gjson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
if event == "response.output_item.added" || event == "response.output_item.done" {
itemType := data.Get("item.type").String()
lifecycle = append(lifecycle, fmt.Sprintf("%s:%d:%s", event, data.Get("output_index").Int(), itemType))
if event == "response.output_item.added" && itemType == "message" {
messageIDs = append(messageIDs, data.Get("item.id").String())
}
}
if event == "response.completed" {
completed = data
}
}
wantLifecycle := strings.Join([]string{
"response.output_item.added:0:message",
"response.output_item.done:0:message",
"response.output_item.added:1:function_call",
"response.output_item.done:1:function_call",
"response.output_item.added:2:message",
"response.output_item.done:2:message",
}, ",")
if got := strings.Join(lifecycle, ","); got != wantLifecycle {
t.Fatalf("item lifecycle = %q, want %q", got, wantLifecycle)
}
if len(messageIDs) != 2 || messageIDs[0] == messageIDs[1] {
t.Fatalf("message IDs = %v, want two unique IDs", messageIDs)
}
if got := completed.Get("response.output.#").Int(); got != 3 {
t.Fatalf("completed output count = %d, want 3", got)
}
for index, wantType := range []string{"message", "function_call", "message"} {
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)
}
}
if got := completed.Get("response.output.0.content.0.text").String(); got != "Before tool." {
t.Fatalf("first completed message text = %q", got)
}
if got := completed.Get("response.output.2.content.0.text").String(); got != "After tool." {
t.Fatalf("second completed message text = %q", got)
}
}
func TestConvertClaudeResponseToOpenAIResponses_FinalizesMessageBeforeReasoning(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":"Visible first."}}`),
[]byte(`data: {"type":"content_block_stop","index":0}`),
[]byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"thinking","thinking":""}}`),
[]byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"Reason later."}}`),
[]byte(`data: {"type":"content_block_stop","index":1}`),
[]byte(`data: {"type":"message_stop"}`),
}
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
var lifecycle []string
var completed gjson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
if event == "response.output_item.added" || event == "response.output_item.done" {
lifecycle = append(lifecycle, fmt.Sprintf("%s:%d:%s", event, data.Get("output_index").Int(), data.Get("item.type").String()))
}
if event == "response.completed" {
completed = data
}
}
wantLifecycle := strings.Join([]string{
"response.output_item.added:0:message",
"response.output_item.done:0:message",
"response.output_item.added:1:reasoning",
"response.output_item.done:1:reasoning",
}, ",")
if got := strings.Join(lifecycle, ","); got != wantLifecycle {
t.Fatalf("item lifecycle = %q, want %q", got, wantLifecycle)
}
for index, wantType := range []string{"message", "reasoning"} {
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_PreservesMultipleReasoningItems(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":"thinking","thinking":""}}`),
[]byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"First reason."}}`),
[]byte(`data: {"type":"content_block_stop","index":0}`),
[]byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"thinking","thinking":""}}`),
[]byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"Second reason."}}`),
[]byte(`data: {"type":"content_block_stop","index":1}`),
[]byte(`data: {"type":"content_block_start","index":2,"content_block":{"type":"text","text":""}}`),
[]byte(`data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"Visible response."}}`),
[]byte(`data: {"type":"content_block_stop","index":2}`),
[]byte(`data: {"type":"message_stop"}`),
}
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
reasoningDoneCount := 0
var completed gjson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
if event == "response.output_item.done" && data.Get("item.type").String() == "reasoning" {
if got := data.Get("output_index").Int(); got != int64(reasoningDoneCount) {
t.Fatalf("reasoning done output_index = %d, want %d", got, reasoningDoneCount)
}
reasoningDoneCount++
}
if event == "response.completed" {
completed = data
}
}
if reasoningDoneCount != 2 {
t.Fatalf("reasoning done count = %d, want 2", reasoningDoneCount)
}
if got := completed.Get("response.output.#").Int(); got != 3 {
t.Fatalf("completed output count = %d, want 3", got)
}
for index, wantType := range []string{"reasoning", "reasoning", "message"} {
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)
}
}
for index, wantText := range []string{"First reason.", "Second reason."} {
if got := completed.Get(fmt.Sprintf("response.output.%d.summary.0.text", index)).String(); got != wantText {
t.Fatalf("completed reasoning[%d] text = %q, want %q", index, got, wantText)
}
}
}
func TestConvertClaudeResponseToOpenAIResponses_NormalizesEmptyFunctionArguments(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":"tool_use","id":"call_123","name":"exec_command","input":{}}}`),
[]byte(`data: {"type":"content_block_stop","index":0}`),
[]byte(`data: {"type":"message_stop"}`),
}
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
var functionDone gjson.Result
var completed gjson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
if event == "response.output_item.done" && data.Get("item.type").String() == "function_call" {
functionDone = data
}
if event == "response.completed" {
completed = data
}
}
if got := functionDone.Get("item.arguments").String(); got != "{}" {
t.Fatalf("function done arguments = %q, want {}", got)
}
if got := completed.Get("response.output.0.arguments").String(); got != "{}" {
t.Fatalf("completed function arguments = %q, want {}", got)
}
}
func TestConvertClaudeResponseToOpenAIResponses_IncludesEmptyReasoningInCompletedOutput(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":"thinking","thinking":""}}`),
[]byte(`data: {"type":"content_block_stop","index":0}`),
[]byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`),
[]byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Visible response."}}`),
[]byte(`data: {"type":"content_block_stop","index":1}`),
[]byte(`data: {"type":"message_stop"}`),
}
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
var reasoningDone gjson.Result
var completed gjson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
if event == "response.output_item.done" && data.Get("item.type").String() == "reasoning" {
reasoningDone = data
}
if event == "response.completed" {
completed = data
}
}
if got := reasoningDone.Get("item.summary.#").Int(); got != 1 {
t.Fatalf("reasoning done summary count = %d, want 1", got)
}
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 != "reasoning" {
t.Fatalf("completed output[0].type = %q, want reasoning", got)
}
if got := completed.Get("response.output.0.summary.#").Int(); got != 1 {
t.Fatalf("completed reasoning summary count = %d, want 1", got)
}
if got := completed.Get("response.output.1.type").String(); got != "message" {
t.Fatalf("completed output[1].type = %q, want message", 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}}}`),