mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
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:
@@ -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}}}`),
|
||||
|
||||
Reference in New Issue
Block a user