This commit is contained in:
rechao
2026-08-30 21:49:27 +08:00
committed by GitHub
4 changed files with 1353 additions and 326 deletions
+27
View File
@@ -57,11 +57,38 @@ type ClaudeConvertInfo struct {
Usage *dto.Usage
FinishReason string
Done bool
MessageStarted bool
// PendingToolCalls buffers one complete OpenAI tool-call segment before it
// is emitted as Claude content blocks. The map key is an internal stable key;
// explicit upstream indexes, ids and no-index positions resolve through the
// auxiliary maps so distinct parallel tools are never merged accidentally.
PendingToolCalls map[int]*ClaudeToolCallBuffer
PendingToolOrder []int
ToolCallByIndex map[int]int
ToolCallByID map[string]int
ToolCallByPos map[int]int
NextToolCallKey int
ToolBufferBytes int
// Deprecated: retained for source compatibility with relaykit embedders.
// The buffered converter no longer derives downstream lifecycle/indexes from
// these upstream-offset fields.
ToolCallBaseIndex int
ToolCallMaxIndexOffset int
}
// ClaudeToolCallBuffer is the protocol-level accumulator for one streamed
// OpenAI tool call. Providers may send arguments before id/name, fragment
// id/name, or replay cumulative fields; the converter merges those chunks and
// emits the block only after it has enough metadata to form a valid tool_use.
type ClaudeToolCallBuffer struct {
ID string
Name string
ArgumentFragments []string
UpstreamIndex *int
}
const (
LastMessageTypeNone = "none"
LastMessageTypeText = "text"
@@ -1,15 +1,38 @@
package oaichat
import (
"fmt"
"sort"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/samber/lo"
)
// maxToolCallBlockIndex bounds upstream-provided tool_call.index values so a
// malicious or malformed huge index cannot grow conversion state without
// limit. Upstream indexes are lookup keys only and are never exposed as Claude
// content-block indexes.
const maxToolCallBlockIndex = 1024
// maxToolCallBufferBytes caps all tool metadata/argument bytes observed during
// one converted stream. Buffering tool segments is required for correctness,
// but must not allow a malformed upstream to grow memory without bound.
const maxToolCallBufferBytes = 8 << 20
const ambiguousToolPosition = -1
func isIgnorableTrailingToolText(state *convmeta.ClaudeConvertInfo, content string) bool {
return state != nil &&
state.LastMessagesType == convmeta.LastMessageTypeTools &&
content != "" &&
strings.TrimSpace(content) == ""
}
func generateStopBlock(index int) *dto.ClaudeResponse {
return &dto.ClaudeResponse{
Type: "content_block_stop",
@@ -17,24 +40,399 @@ func generateStopBlock(index int) *dto.ClaudeResponse {
}
}
func stopOpenBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
// mergeMetadataFragment is intentionally limited to id/name metadata. Tool
// arguments use lossless fragment storage and JSON validation instead of
// guessing whether a prefix is a delta or a cumulative snapshot.
func mergeMetadataFragment(current string, fragment string) string {
if fragment == "" {
return current
}
if current == "" {
return fragment
}
if strings.HasPrefix(fragment, current) {
return fragment
}
if strings.HasPrefix(current, fragment) {
return current
}
return current + fragment
}
func resetPendingToolCalls(state *convmeta.ClaudeConvertInfo) {
if state == nil {
return
}
state.PendingToolCalls = nil
state.PendingToolOrder = nil
state.ToolCallByIndex = nil
state.ToolCallByID = nil
state.ToolCallByPos = nil
state.LastMessagesType = convmeta.LastMessageTypeNone
}
func ensurePendingToolState(state *convmeta.ClaudeConvertInfo) {
if state.PendingToolCalls == nil {
state.PendingToolCalls = make(map[int]*convmeta.ClaudeToolCallBuffer)
}
if state.ToolCallByIndex == nil {
state.ToolCallByIndex = make(map[int]int)
}
if state.ToolCallByID == nil {
state.ToolCallByID = make(map[string]int)
}
if state.ToolCallByPos == nil {
state.ToolCallByPos = make(map[int]int)
}
}
func newPendingToolCall(state *convmeta.ClaudeConvertInfo) (int, *convmeta.ClaudeToolCallBuffer) {
key := state.NextToolCallKey
state.NextToolCallKey++
buffer := &convmeta.ClaudeToolCallBuffer{}
state.PendingToolCalls[key] = buffer
state.PendingToolOrder = append(state.PendingToolOrder, key)
return key, buffer
}
func looksLikeStableToolID(value string) bool {
return (strings.HasPrefix(value, "call_") && len(value) > len("call_")) ||
(strings.HasPrefix(value, "tool_") && len(value) > len("tool_")) ||
(strings.HasPrefix(value, "toolu_") && len(value) > len("toolu_")) ||
len(value) >= 16
}
func toolKeyHasDifferentPosition(state *convmeta.ClaudeConvertInfo, key int, position int) bool {
for knownPosition, knownKey := range state.ToolCallByPos {
if knownKey == key && knownPosition != position {
return true
}
}
return false
}
func pendingNoIndexToolCount(state *convmeta.ClaudeConvertInfo) int {
count := 0
for _, buffer := range state.PendingToolCalls {
if buffer != nil && buffer.UpstreamIndex == nil {
count++
}
}
return count
}
func hasMetadataPrefixRelation(current string, incoming string) bool {
return current == incoming || strings.HasPrefix(current, incoming) || strings.HasPrefix(incoming, current)
}
func validatePositionOnlyToolIdentity(buffer *convmeta.ClaudeToolCallBuffer, toolCall dto.ToolCallResponse, position int) error {
if buffer == nil {
return fmt.Errorf("missing tool buffer at position %d", position)
}
if buffer.ID != "" && toolCall.ID != "" {
if buffer.ID != toolCall.ID &&
(looksLikeStableToolID(buffer.ID) && looksLikeStableToolID(toolCall.ID) ||
!hasMetadataPrefixRelation(buffer.ID, toolCall.ID)) {
return fmt.Errorf("ambiguous position-only tool ids %q and %q at position %d", buffer.ID, toolCall.ID, position)
}
if buffer.Name != "" && toolCall.Function.Name != "" && !hasMetadataPrefixRelation(buffer.Name, toolCall.Function.Name) {
return fmt.Errorf("conflicting names for position-only tool id %q at position %d", buffer.ID, position)
}
return nil
}
if buffer.ID == "" && toolCall.ID == "" && buffer.Name != "" && toolCall.Function.Name != "" && buffer.Name != toolCall.Function.Name {
return fmt.Errorf("ambiguous position-only tool names %q and %q at position %d", buffer.Name, toolCall.Function.Name, position)
}
return nil
}
func resolvePendingToolCall(state *convmeta.ClaudeConvertInfo, position int, chunkSize int, toolCall dto.ToolCallResponse, duplicateID bool) (int, *convmeta.ClaudeToolCallBuffer, error) {
ensurePendingToolState(state)
if toolCall.Index == nil && position > maxToolCallBlockIndex {
return 0, nil, fmt.Errorf("invalid no-index tool position %d", position)
}
indexKey, hasIndexKey := 0, false
if toolCall.Index != nil {
if *toolCall.Index < 0 || *toolCall.Index > maxToolCallBlockIndex {
return 0, nil, fmt.Errorf("invalid upstream tool index %d", *toolCall.Index)
}
indexKey, hasIndexKey = state.ToolCallByIndex[*toolCall.Index]
}
idKey, hasIDKey := 0, false
useIDIdentity := toolCall.ID != "" && !duplicateID && (toolCall.Index == nil || looksLikeStableToolID(toolCall.ID))
if useIDIdentity {
idKey, hasIDKey = state.ToolCallByID[toolCall.ID]
if hasIDKey && idKey == ambiguousToolPosition {
hasIDKey = false
}
if hasIDKey && toolCall.Index == nil && !looksLikeStableToolID(toolCall.ID) && toolKeyHasDifferentPosition(state, idKey, position) {
return 0, nil, fmt.Errorf("ambiguous fragmented tool id %q at position %d", toolCall.ID, position)
}
}
if hasIndexKey && hasIDKey && indexKey != idKey {
return 0, nil, fmt.Errorf("conflicting tool identity for index %d and id %q", *toolCall.Index, toolCall.ID)
}
key := 0
var buffer *convmeta.ClaudeToolCallBuffer
matchedByPositionOnly := false
switch {
case hasIndexKey:
key = indexKey
buffer = state.PendingToolCalls[key]
case hasIDKey:
key = idKey
buffer = state.PendingToolCalls[key]
case toolCall.Index == nil:
positionKey, ok := state.ToolCallByPos[position]
if ok && positionKey == ambiguousToolPosition {
return 0, nil, fmt.Errorf("ambiguous no-index tool fragment at position %d", position)
}
noIndexCount := pendingNoIndexToolCount(state)
if ok && !hasIDKey && noIndexCount > 1 && chunkSize < noIndexCount {
return 0, nil, fmt.Errorf("cannot disambiguate %d no-index tools from a %d-call subset", noIndexCount, chunkSize)
}
if ok {
key = positionKey
buffer = state.PendingToolCalls[key]
matchedByPositionOnly = true
} else {
key, buffer = newPendingToolCall(state)
}
default:
key, buffer = newPendingToolCall(state)
}
if buffer == nil {
return 0, nil, fmt.Errorf("missing tool buffer for internal key %d", key)
}
if matchedByPositionOnly {
if err := validatePositionOnlyToolIdentity(buffer, toolCall, position); err != nil {
return 0, nil, err
}
}
if toolCall.Index != nil {
if buffer.UpstreamIndex != nil && *buffer.UpstreamIndex != *toolCall.Index {
return 0, nil, fmt.Errorf("tool id %q changed upstream index from %d to %d", toolCall.ID, *buffer.UpstreamIndex, *toolCall.Index)
}
index := *toolCall.Index
buffer.UpstreamIndex = &index
state.ToolCallByIndex[index] = key
} else {
positionKey, ok := state.ToolCallByPos[position]
if !ok {
state.ToolCallByPos[position] = key
} else if positionKey != key {
// An exact id can safely relocate when a provider emits only a subset
// of parallel calls in a later chunk. Future id-less fragments at this
// position are no longer distinguishable and therefore fail closed.
state.ToolCallByPos[position] = ambiguousToolPosition
}
}
if toolCall.ID != "" {
oldID := buffer.ID
if buffer.ID != "" &&
looksLikeStableToolID(buffer.ID) &&
looksLikeStableToolID(toolCall.ID) &&
!strings.HasPrefix(buffer.ID, toolCall.ID) &&
!strings.HasPrefix(toolCall.ID, buffer.ID) {
return 0, nil, fmt.Errorf("conflicting tool ids %q and %q", buffer.ID, toolCall.ID)
}
buffer.ID = mergeMetadataFragment(buffer.ID, toolCall.ID)
if oldID != "" && oldID != buffer.ID {
if oldKey, exists := state.ToolCallByID[oldID]; exists && oldKey == key {
delete(state.ToolCallByID, oldID)
}
}
registerCurrentID := !duplicateID && (toolCall.Index == nil || looksLikeStableToolID(buffer.ID))
if registerCurrentID {
if otherKey, exists := state.ToolCallByID[buffer.ID]; exists && otherKey != key {
return 0, nil, fmt.Errorf("tool id %q resolves to multiple calls", buffer.ID)
}
state.ToolCallByID[buffer.ID] = key
}
}
buffer.Name = mergeMetadataFragment(buffer.Name, toolCall.Function.Name)
return key, buffer, nil
}
func bufferToolCallDeltas(state *convmeta.ClaudeConvertInfo, toolCalls []dto.ToolCallResponse) error {
idCounts := make(map[string]int, len(toolCalls))
for _, toolCall := range toolCalls {
if toolCall.ID != "" {
idCounts[toolCall.ID]++
}
}
for position, toolCall := range toolCalls {
addedBytes := len(toolCall.ID) + len(toolCall.Function.Name) + len(toolCall.Function.Arguments)
if addedBytes > maxToolCallBufferBytes-state.ToolBufferBytes {
return fmt.Errorf("tool-call buffer exceeds %d bytes", maxToolCallBufferBytes)
}
state.ToolBufferBytes += addedBytes
_, buffer, err := resolvePendingToolCall(state, position, len(toolCalls), toolCall, toolCall.ID != "" && idCounts[toolCall.ID] > 1)
if err != nil {
return err
}
if toolCall.Function.Arguments != "" {
buffer.ArgumentFragments = append(buffer.ArgumentFragments, toolCall.Function.Arguments)
}
}
return nil
}
func parseJSONObject(value string) bool {
var object map[string]any
if err := kitutil.Unmarshal([]byte(value), &object); err != nil {
return false
}
return object != nil
}
func resolveToolArguments(fragments []string) (string, error) {
if len(fragments) == 0 {
return "", nil
}
joined := strings.Join(fragments, "")
if strings.TrimSpace(joined) == "" {
return "", nil
}
if parseJSONObject(joined) {
return joined, nil
}
best := ""
for _, fragment := range fragments {
if !parseJSONObject(fragment) {
continue
}
// Prefer the longest valid cumulative snapshot; equal-length later
// snapshots win, matching providers that replay corrected JSON.
if len(fragment) >= len(best) {
best = fragment
}
}
if best != "" {
return best, nil
}
return "", fmt.Errorf("tool arguments do not form a JSON object")
}
type preparedClaudeToolCall struct {
buffer *convmeta.ClaudeToolCallBuffer
arguments string
}
// flushPendingToolCalls emits only complete tool calls. Each valid call is
// emitted atomically as start -> optional delta -> stop, and downstream indexes
// are allocated contiguously regardless of upstream identity/index shape.
func flushPendingToolCalls(state *convmeta.ClaudeConvertInfo) ([]*dto.ClaudeResponse, error) {
if state == nil || len(state.PendingToolCalls) == 0 {
if state != nil {
resetPendingToolCalls(state)
}
return nil, nil
}
orderedKeys := append([]int(nil), state.PendingToolOrder...)
allExplicit := len(orderedKeys) > 0
for _, key := range orderedKeys {
if buffer := state.PendingToolCalls[key]; buffer == nil || buffer.UpstreamIndex == nil {
allExplicit = false
break
}
}
if allExplicit {
sort.SliceStable(orderedKeys, func(i, j int) bool {
return *state.PendingToolCalls[orderedKeys[i]].UpstreamIndex < *state.PendingToolCalls[orderedKeys[j]].UpstreamIndex
})
}
prepared := make([]preparedClaudeToolCall, 0, len(orderedKeys))
for _, key := range orderedKeys {
buffer := state.PendingToolCalls[key]
if buffer == nil || strings.TrimSpace(buffer.ID) == "" || strings.TrimSpace(buffer.Name) == "" {
return nil, fmt.Errorf("incomplete tool call missing id or name")
}
arguments, err := resolveToolArguments(buffer.ArgumentFragments)
if err != nil {
return nil, fmt.Errorf("tool %q: %w", buffer.Name, err)
}
prepared = append(prepared, preparedClaudeToolCall{buffer: buffer, arguments: arguments})
}
responses := make([]*dto.ClaudeResponse, 0, len(prepared)*3)
for _, toolCall := range prepared {
blockIndex := state.Index
responses = append(responses, &dto.ClaudeResponse{
Index: &blockIndex,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: toolCall.buffer.ID,
Type: "tool_use",
Name: toolCall.buffer.Name,
Input: map[string]interface{}{},
},
})
if toolCall.arguments != "" {
arguments := toolCall.arguments
responses = append(responses, &dto.ClaudeResponse{
Index: &blockIndex,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &arguments,
},
})
}
responses = append(responses, generateStopBlock(blockIndex))
state.Index++
}
resetPendingToolCalls(state)
return responses, nil
}
func closeActiveClaudeBlocks(state *convmeta.ClaudeConvertInfo) ([]*dto.ClaudeResponse, error) {
if state == nil {
return nil, nil
}
switch state.LastMessagesType {
case convmeta.LastMessageTypeText, convmeta.LastMessageTypeThinking:
return []*dto.ClaudeResponse{generateStopBlock(state.Index)}
responses := []*dto.ClaudeResponse{generateStopBlock(state.Index)}
state.Index++
state.LastMessagesType = convmeta.LastMessageTypeNone
return responses, nil
case convmeta.LastMessageTypeTools:
responses := make([]*dto.ClaudeResponse, 0, state.ToolCallMaxIndexOffset+1)
for offset := 0; offset <= state.ToolCallMaxIndexOffset; offset++ {
responses = append(responses, generateStopBlock(state.ToolCallBaseIndex+offset))
}
return responses
return flushPendingToolCalls(state)
default:
return nil
return nil, nil
}
}
func claudeConversionError(message string) *dto.ClaudeResponse {
return &dto.ClaudeResponse{
Type: "error",
Error: types.ClaudeError{
Type: "api_error",
Message: "relay stream conversion error: " + message,
},
}
}
func abortClaudeConversion(state *convmeta.ClaudeConvertInfo, message string) []*dto.ClaudeResponse {
var responses []*dto.ClaudeResponse
if state != nil {
if state.LastMessagesType == convmeta.LastMessageTypeText || state.LastMessagesType == convmeta.LastMessageTypeThinking {
responses = append(responses, generateStopBlock(state.Index))
state.Index++
}
resetPendingToolCalls(state)
state.Done = true
}
return append(responses, claudeConversionError(message))
}
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
if oaiUsage == nil {
return nil
@@ -95,42 +493,21 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
info = &convmeta.Values{}
}
state := info.EnsureClaudeConvertInfo()
if state.Done {
if state.Done || openAIResponse == nil {
return nil
}
var claudeResponses []*dto.ClaudeResponse
// stopOpenBlocks emits the required content_block_stop event(s) for the currently open block(s)
// according to Anthropic's SSE streaming state machine:
// content_block_start -> content_block_delta* -> content_block_stop (per index).
//
// For text/thinking, there is at most one open block at state.Index.
// For tools, OpenAI tool_calls can stream multiple parallel tool_use blocks (indexed from 0),
// so we may have multiple open blocks and must stop each one explicitly.
appendStopOpenBlocks := func() {
claudeResponses = append(claudeResponses, stopOpenBlocks(state)...)
fail := func(err error) []*dto.ClaudeResponse {
return append(claudeResponses, abortClaudeConversion(state, err.Error())...)
}
// stopOpenBlocksAndAdvance closes the currently open block(s) and advances the content block index
// to the next available slot for subsequent content_block_start events.
//
// This prevents invalid streams where a content_block_delta (e.g. thinking_delta) is emitted for an
// index whose active content_block type is different (the typical cause of "Mismatched content block type").
stopOpenBlocksAndAdvance := func() {
if state.LastMessagesType == convmeta.LastMessageTypeNone {
return
}
appendStopOpenBlocks()
switch state.LastMessagesType {
case convmeta.LastMessageTypeTools:
state.Index = state.ToolCallBaseIndex + state.ToolCallMaxIndexOffset + 1
state.ToolCallBaseIndex = 0
state.ToolCallMaxIndexOffset = 0
default:
state.Index++
}
state.LastMessagesType = convmeta.LastMessageTypeNone
closeBlocks := func() error {
responses, err := closeActiveClaudeBlocks(state)
claudeResponses = append(claudeResponses, responses...)
return err
}
if info.GetSendResponseCount() == 1 {
if !state.MessageStarted {
msg := &dto.ClaudeMediaMessage{
Id: openAIResponse.Id,
Model: openAIResponse.Model,
@@ -146,128 +523,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
Type: "message_start",
Message: msg,
})
//claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
// Type: "ping",
//})
if openAIResponse.IsToolCall() {
state.LastMessagesType = convmeta.LastMessageTypeTools
state.ToolCallBaseIndex = 0
state.ToolCallMaxIndexOffset = 0
var toolCall dto.ToolCallResponse
if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.ToolCalls) > 0 {
toolCall = openAIResponse.Choices[0].Delta.ToolCalls[0]
} else {
first := openAIResponse.GetFirstToolCall()
if first != nil {
toolCall = *first
} else {
toolCall = dto.ToolCallResponse{}
}
}
resp := &dto.ClaudeResponse{
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: toolCall.ID,
Type: "tool_use",
Name: toolCall.Function.Name,
Input: map[string]interface{}{},
},
}
resp.SetIndex(0)
claudeResponses = append(claudeResponses, resp)
// 首块包含工具 delta,则追加 input_json_delta
if toolCall.Function.Arguments != "" {
idx := 0
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &toolCall.Function.Arguments,
},
})
}
} else {
}
// 判断首个响应是否存在内容(非标准的 OpenAI 响应)
if len(openAIResponse.Choices) > 0 {
reasoning := openAIResponse.Choices[0].Delta.GetReasoningContent()
content := openAIResponse.Choices[0].Delta.GetContentString()
if reasoning != "" {
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
stopOpenBlocksAndAdvance()
}
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer[string](""),
},
})
idx2 := idx
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx2,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "thinking_delta",
Thinking: &reasoning,
},
})
state.LastMessagesType = convmeta.LastMessageTypeThinking
} else if content != "" {
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
}
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
idx2 := idx
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx2,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "text_delta",
Text: kitutil.GetPointer[string](content),
},
})
state.LastMessagesType = convmeta.LastMessageTypeText
}
}
// A first chunk can carry finish_reason before usage; defer terminal events until usage arrives.
if len(openAIResponse.Choices) > 0 && openAIResponse.Choices[0].FinishReason != nil && *openAIResponse.Choices[0].FinishReason != "" {
state.FinishReason = *openAIResponse.Choices[0].FinishReason
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
if oaiUsage == nil {
return claudeResponses
}
appendStopOpenBlocks()
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
state.Done = true
}
return claudeResponses
state.MessageStarted = true
}
if len(openAIResponse.Choices) == 0 {
@@ -276,166 +532,135 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
if oaiUsage == nil {
oaiUsage = state.Usage
}
if oaiUsage != nil {
appendStopOpenBlocks()
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
if oaiUsage == nil {
return claudeResponses
}
if err := closeBlocks(); err != nil {
return fail(err)
}
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
claudeResponses = append(claudeResponses,
&dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReason),
Delta: &dto.ClaudeMediaMessage{StopReason: kitutil.GetPointer[string](stopReason)},
},
&dto.ClaudeResponse{Type: "message_stop"},
)
state.Done = true
return claudeResponses
}
chosenChoice := openAIResponse.Choices[0]
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
if doneChunk {
state.FinishReason = *chosenChoice.FinishReason
}
// Preserve all fields from mixed provider chunks in deterministic semantic
// order: reasoning -> tools -> text. The finish flag is handled only after
// every delta field, so a final tool-argument fragment cannot be dropped.
reasoning := chosenChoice.Delta.GetReasoningContent()
if reasoning != "" {
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
if err := closeBlocks(); err != nil {
return fail(err)
}
blockIndex := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &blockIndex,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer[string](""),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
state.Done = true
}
return claudeResponses
} else {
chosenChoice := openAIResponse.Choices[0]
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
if doneChunk {
state.FinishReason = *chosenChoice.FinishReason
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Defer closing until usage is available so the final message_delta carries it.
return claudeResponses
}
state.LastMessagesType = convmeta.LastMessageTypeThinking
}
blockIndex := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &blockIndex,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{Type: "thinking_delta", Thinking: &reasoning},
})
}
var claudeResponse dto.ClaudeResponse
var isEmpty bool
claudeResponse.Type = "content_block_delta"
if len(chosenChoice.Delta.ToolCalls) > 0 {
toolCalls := chosenChoice.Delta.ToolCalls
if state.LastMessagesType != convmeta.LastMessageTypeTools {
stopOpenBlocksAndAdvance()
state.ToolCallBaseIndex = state.Index
state.ToolCallMaxIndexOffset = 0
if len(chosenChoice.Delta.ToolCalls) > 0 {
if state.LastMessagesType != convmeta.LastMessageTypeTools {
if err := closeBlocks(); err != nil {
return fail(err)
}
state.LastMessagesType = convmeta.LastMessageTypeTools
base := state.ToolCallBaseIndex
maxOffset := state.ToolCallMaxIndexOffset
for i, toolCall := range toolCalls {
offset := 0
if toolCall.Index != nil {
offset = *toolCall.Index
} else {
offset = i
}
if offset > maxOffset {
maxOffset = offset
}
blockIndex := base + offset
idx := blockIndex
if toolCall.Function.Name != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: toolCall.ID,
Type: "tool_use",
Name: toolCall.Function.Name,
Input: map[string]interface{}{},
},
})
}
if len(toolCall.Function.Arguments) > 0 {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &toolCall.Function.Arguments,
},
})
}
}
state.ToolCallMaxIndexOffset = maxOffset
state.Index = base + maxOffset
} else {
reasoning := chosenChoice.Delta.GetReasoningContent()
textContent := chosenChoice.Delta.GetContentString()
if reasoning != "" || textContent != "" {
if reasoning != "" {
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeThinking
claudeResponse.Delta = &dto.ClaudeMediaMessage{
Type: "thinking_delta",
Thinking: &reasoning,
}
} else {
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeText
claudeResponse.Delta = &dto.ClaudeMediaMessage{
Type: "text_delta",
Text: kitutil.GetPointer[string](textContent),
}
}
} else {
isEmpty = true
}
}
claudeResponse.Index = kitutil.GetPointer[int](state.Index)
if !isEmpty && claudeResponse.Delta != nil {
claudeResponses = append(claudeResponses, &claudeResponse)
}
if doneChunk || state.Done {
appendStopOpenBlocks()
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
},
})
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
state.Done = true
return claudeResponses
if err := bufferToolCallDeltas(state, chosenChoice.Delta.ToolCalls); err != nil {
return fail(err)
}
}
textContent := chosenChoice.Delta.GetContentString()
if isIgnorableTrailingToolText(state, textContent) {
textContent = ""
}
if textContent != "" {
if state.LastMessagesType != convmeta.LastMessageTypeText {
if err := closeBlocks(); err != nil {
return fail(err)
}
blockIndex := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &blockIndex,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
state.LastMessagesType = convmeta.LastMessageTypeText
}
blockIndex := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &blockIndex,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{Type: "text_delta", Text: &textContent},
})
}
if !doneChunk {
return claudeResponses
}
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
// Finish is a protocol boundary for buffered tools even when usage comes in
// a later chunk: flush them now, after the final delta above. Text/thinking
// may remain open until the usage chunk/finalizer to preserve the established
// terminal-tail contract.
if state.LastMessagesType == convmeta.LastMessageTypeTools {
if err := closeBlocks(); err != nil {
return fail(err)
}
}
if oaiUsage == nil {
return claudeResponses
}
if err := closeBlocks(); err != nil {
return fail(err)
}
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
claudeResponses = append(claudeResponses,
&dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{StopReason: kitutil.GetPointer[string](stopReason)},
},
&dto.ClaudeResponse{Type: "message_stop"},
)
state.Done = true
return claudeResponses
}
@@ -448,11 +673,14 @@ func FinalizeStreamResponseOpenAI2Claude(info convmeta.Meta) []*dto.ClaudeRespon
return nil
}
responses, err := closeActiveClaudeBlocks(state)
if err != nil {
return abortClaudeConversion(state, err.Error())
}
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
responses := stopOpenBlocks(state)
responses = append(responses,
&dto.ClaudeResponse{
Type: "message_delta",
@@ -1,6 +1,8 @@
package oaichat
import (
"fmt"
"strings"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
@@ -170,13 +172,9 @@ func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T
},
},
}, info)
require.Len(t, toolResponses, 3)
require.Len(t, toolResponses, 1)
assert.Equal(t, "content_block_stop", toolResponses[0].Type)
assert.Equal(t, 1, toolResponses[0].GetIndex())
assert.Equal(t, "content_block_start", toolResponses[1].Type)
assert.Equal(t, 2, toolResponses[1].GetIndex())
assert.Equal(t, "tool_use", toolResponses[1].ContentBlock.Type)
assert.Equal(t, "content_block_delta", toolResponses[2].Type)
info.SendResponseCount = 4
finishResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
@@ -191,17 +189,19 @@ func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T
TotalTokens: 10,
},
}, info)
require.Len(t, finishResponses, 3)
assert.Equal(t, "content_block_stop", finishResponses[0].Type)
require.Len(t, finishResponses, 5)
assert.Equal(t, "content_block_start", finishResponses[0].Type)
assert.Equal(t, 2, finishResponses[0].GetIndex())
assert.Equal(t, "message_delta", finishResponses[1].Type)
assert.Equal(t, "tool_use", *finishResponses[1].Delta.StopReason)
require.NotNil(t, finishResponses[1].Usage)
require.NotNil(t, finishResponses[1].Usage.BillingUsage)
require.NotNil(t, finishResponses[1].Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, 7, finishResponses[1].Usage.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 3, finishResponses[1].Usage.BillingUsage.OpenAIUsage.CompletionTokens)
assert.Equal(t, "message_stop", finishResponses[2].Type)
assert.Equal(t, "content_block_delta", finishResponses[1].Type)
assert.Equal(t, "content_block_stop", finishResponses[2].Type)
assert.Equal(t, "message_delta", finishResponses[3].Type)
assert.Equal(t, "tool_use", *finishResponses[3].Delta.StopReason)
require.NotNil(t, finishResponses[3].Usage)
require.NotNil(t, finishResponses[3].Usage.BillingUsage)
require.NotNil(t, finishResponses[3].Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, 7, finishResponses[3].Usage.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 3, finishResponses[3].Usage.BillingUsage.OpenAIUsage.CompletionTokens)
assert.Equal(t, "message_stop", finishResponses[4].Type)
}
func TestNormalizeCacheCreationSplit(t *testing.T) {
@@ -214,6 +214,776 @@ func TestNormalizeCacheCreationSplit(t *testing.T) {
assert.Equal(t, 1, cache1h)
}
// TestStreamResponseOpenAI2ClaudeParallelToolCallsHaveValidBlockLifecycle
// drives two parallel tool_use blocks (e.g. GLM-5.2 packing multiple tool
// calls per chunk) through the OpenAI→Claude stream converter and asserts the
// Anthropic SSE state machine stays valid: every content_block_delta/stop
// targets an actively-open block index, no block starts twice, and every
// started block is stopped (#4389).
func TestStreamResponseOpenAI2ClaudeParallelToolCallsHaveValidBlockLifecycle(t *testing.T) {
info := &convmeta.Values{
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{},
}
info.SendResponseCount = 1
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1", Model: "glm",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: ptr(0), ID: "call_weather", Function: dto.FunctionResponse{Name: "get_weather"}},
{Index: ptr(1), ID: "call_time", Function: dto.FunctionResponse{Name: "get_time"}},
}},
}},
}, info)
info.SendResponseCount = 2
events = append(events, StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: ptr(0), Function: dto.FunctionResponse{Arguments: `{"city":"Tokyo"}`}},
{Index: ptr(1), Function: dto.FunctionResponse{Arguments: `{}`}},
}},
}},
}, info)...)
info.SendResponseCount = 3
finishReason := "tool_calls"
events = append(events, StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{FinishReason: &finishReason}},
Usage: &dto.Usage{},
}, info)...)
started := map[int]bool{}
stopped := map[int]bool{}
// capture argument payloads by block index so a converter that drops deltas
// (not just reorders them) still fails the test.
deltas := map[int][]string{}
for _, event := range events {
if event.Index == nil {
continue
}
idx := *event.Index
switch event.Type {
case "content_block_start":
require.False(t, started[idx], "block %d started twice", idx)
started[idx] = true
case "content_block_delta":
assert.True(t, started[idx], "block %d received delta before start", idx)
assert.False(t, stopped[idx], "block %d received delta after stop", idx)
if event.Delta != nil && event.Delta.PartialJson != nil {
deltas[idx] = append(deltas[idx], *event.Delta.PartialJson)
}
case "content_block_stop":
assert.True(t, started[idx], "block %d stopped before start", idx)
require.False(t, stopped[idx], "block %d stopped twice", idx)
stopped[idx] = true
}
}
assert.Equal(t, map[int]bool{0: true, 1: true}, started)
assert.Equal(t, started, stopped)
assert.Equal(t, []string{`{"city":"Tokyo"}`}, deltas[0], "block 0 must deliver its argument payload")
assert.Equal(t, []string{`{}`}, deltas[1], "block 1 must deliver its argument payload")
}
// TestStreamResponseOpenAI2ClaudeReplayedToolNameDoesNotDuplicateStart covers
// providers that echo the full tool_call (id+name) in every delta instead of
// streaming incremental fragments: a replayed name for an already-open index
// must not emit a second content_block_start.
func TestStreamResponseOpenAI2ClaudeReplayedToolNameDoesNotDuplicateStart(t *testing.T) {
info := &convmeta.Values{
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{},
}
info.SendResponseCount = 1
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1", Model: "glm",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: ptr(0), ID: "call_weather", Function: dto.FunctionResponse{Name: "get_weather"}},
}},
}},
}, info)
info.SendResponseCount = 2
// upstream re-echoes name+id alongside an arguments fragment
second := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: ptr(0), ID: "call_weather", Function: dto.FunctionResponse{Name: "get_weather", Arguments: `{"city":"Tokyo"}`}},
}},
}},
}, info)
info.SendResponseCount = 3
finishReason := "tool_calls"
third := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{FinishReason: &finishReason}},
Usage: &dto.Usage{},
}, info)
// collect every content_block_start index; a replayed name must not start a
// new block at any index (e.g. a spurious index 1), so assert the exact set.
var startIndexes []int
for _, event := range append(append(first, second...), third...) {
if event.Type != "content_block_start" || event.Index == nil {
continue
}
startIndexes = append(startIndexes, *event.Index)
}
assert.Equal(t, []int{0}, startIndexes, "only block 0 may start despite replayed name")
}
func TestStreamResponseOpenAI2ClaudeDiscardsWhitespaceOnlyTextAfterToolUse(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
info.SendResponseCount = 1
toolResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_deepseek", Model: "deepseek-v4-flash",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Index: ptr(0), ID: "call_1", Type: "function",
Function: dto.FunctionResponse{Name: "lookup", Arguments: `{"q":"x"}`},
}}},
}},
}, info)
require.Len(t, toolResponses, 1)
assert.Equal(t, "message_start", toolResponses[0].Type)
info.SendResponseCount = 2
trailingResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("\n")},
}},
}, info)
assert.Empty(t, trailingResponses, "DeepSeek trailing whitespace must not create a text block")
info.SendResponseCount = 3
finishResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{FinishReason: ptr("tool_calls")}},
Usage: &dto.Usage{},
}, info)
require.Len(t, finishResponses, 5)
assert.Equal(t, "content_block_start", finishResponses[0].Type)
assert.Equal(t, 0, finishResponses[0].GetIndex())
assert.Equal(t, "content_block_delta", finishResponses[1].Type)
assert.Equal(t, "content_block_stop", finishResponses[2].Type)
assert.Equal(t, "message_delta", finishResponses[3].Type)
assert.Equal(t, "message_stop", finishResponses[4].Type)
}
func TestStreamResponseOpenAI2ClaudePreservesTextAfterToolUse(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
info.SendResponseCount = 1
StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_mixed", Model: "openai-compatible",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Index: ptr(0), ID: "call_1", Type: "function",
Function: dto.FunctionResponse{Name: "lookup", Arguments: `{}`},
}}},
}},
}, info)
info.SendResponseCount = 2
textResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("Tool call queued.")},
}},
}, info)
require.Len(t, textResponses, 5)
assert.Equal(t, "content_block_start", textResponses[0].Type)
assert.Equal(t, 0, textResponses[0].GetIndex())
assert.Equal(t, "content_block_delta", textResponses[1].Type)
assert.Equal(t, "content_block_stop", textResponses[2].Type)
assert.Equal(t, "content_block_start", textResponses[3].Type)
assert.Equal(t, 1, textResponses[3].GetIndex())
assert.Equal(t, "text", textResponses[3].ContentBlock.Type)
assert.Equal(t, "content_block_delta", textResponses[4].Type)
require.NotNil(t, textResponses[4].Delta)
require.NotNil(t, textResponses[4].Delta.Text)
assert.Equal(t, "Tool call queued.", *textResponses[4].Delta.Text)
}
func TestStreamResponseOpenAI2ClaudeBuffersArgsBeforeMetadataUntilFinish(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
info.SendResponseCount = 1
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_args_first", Model: "deepseek",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Index: ptr(7), Function: dto.FunctionResponse{Arguments: `{"q":`},
}}},
}},
}, info)
require.Equal(t, []string{"message_start"}, claudeEventSignatures(first))
info.SendResponseCount = 2
second := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Index: ptr(7), ID: "call_1", Function: dto.FunctionResponse{Name: "lookup", Arguments: `"x"}`},
}}},
}},
}, info)
assert.Empty(t, second, "tool segment must remain buffered before a protocol boundary")
final := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{FinishReason: ptr("tool_calls")}},
Usage: &dto.Usage{},
}, info)
assert.Equal(t, []string{
"start:0:tool_use:lookup:call_1",
`delta:0:input_json_delta:{"q":"x"}`,
"stop:0",
"message_delta:tool_use",
"message_stop",
}, claudeEventSignatures(final))
assertValidClaudeEventSequence(t, append(append(first, second...), final...))
}
func TestStreamResponseOpenAI2ClaudeSparseParallelReplayUsesContiguousIndexes(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
info.SendResponseCount = 1
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_sparse", Model: "compatible",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: ptr(9), Function: dto.FunctionResponse{Arguments: `{"b":`}},
{Index: ptr(2), Function: dto.FunctionResponse{Arguments: `{"a":`}},
}},
}},
}, info)
info.SendResponseCount = 2
events = append(events, StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: ptr(9), ID: "call_", Function: dto.FunctionResponse{Name: "get_", Arguments: `{"b":2}`}},
{Index: ptr(2), ID: "call_", Function: dto.FunctionResponse{Name: "get_", Arguments: `{"a":1}`}},
}},
}},
}, info)...)
info.SendResponseCount = 3
events = append(events, StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
// Cumulative metadata/arguments are intentionally replayed.
{Index: ptr(2), ID: "call_alpha", Function: dto.FunctionResponse{Name: "get_alpha", Arguments: `{"a":1}`}},
{Index: ptr(9), ID: "call_beta", Function: dto.FunctionResponse{Name: "get_beta", Arguments: `{"b":2}`}},
}},
}},
}, info)...)
info.SendResponseCount = 4
events = append(events, StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{FinishReason: ptr("tool_calls")}},
Usage: &dto.Usage{},
}, info)...)
assert.Equal(t, []string{
"message_start",
"start:0:tool_use:get_alpha:call_alpha",
`delta:0:input_json_delta:{"a":1}`,
"stop:0",
"start:1:tool_use:get_beta:call_beta",
`delta:1:input_json_delta:{"b":2}`,
"stop:1",
"message_delta:tool_use",
"message_stop",
}, claudeEventSignatures(events))
assertValidClaudeEventSequence(t, events)
}
func TestStreamResponseOpenAI2ClaudeFinishChunkKeepsFinalToolDeltaWithoutUsage(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
info.SendResponseCount = 1
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_finish_delta", Model: "deepseek",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Index: ptr(0), ID: "call_1", Function: dto.FunctionResponse{Name: "lookup", Arguments: `{"q":`},
}}},
}},
}, info)
info.SendResponseCount = 2
finishEvents := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("tool_calls"),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Index: ptr(0), Function: dto.FunctionResponse{Arguments: `"x"}`},
}}},
}},
}, info)
events = append(events, finishEvents...)
assert.Equal(t, []string{
"start:0:tool_use:lookup:call_1",
`delta:0:input_json_delta:{"q":"x"}`,
"stop:0",
}, claudeEventSignatures(finishEvents))
assert.False(t, info.ClaudeConvertInfo.Done, "terminal events wait for the later usage chunk")
info.SendResponseCount = 3
terminal := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{Usage: &dto.Usage{}}, info)
events = append(events, terminal...)
assert.Equal(t, []string{"message_delta:tool_use", "message_stop"}, claudeEventSignatures(terminal))
assertValidClaudeEventSequence(t, events)
}
func TestStreamResponseOpenAI2ClaudeIncompleteToolFailsClosed(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
info.SendResponseCount = 1
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_incomplete", Model: "compatible",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("tool_calls"),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Index: ptr(99), ID: "call_without_name", Function: dto.FunctionResponse{Arguments: `{}`},
}}},
}},
Usage: &dto.Usage{},
}, info)
assert.Equal(t, []string{"message_start", "error:relay stream conversion error: incomplete tool call missing id or name"}, claudeEventSignatures(events))
assert.Zero(t, info.ClaudeConvertInfo.Index, "discarded malformed tools must not consume a downstream index")
assertValidClaudeEventSequence(t, events)
}
func TestResolveToolArgumentsDoesNotGuessPrefixDeltas(t *testing.T) {
t.Run("true delta that is also an earlier prefix", func(t *testing.T) {
arguments, err := resolveToolArguments([]string{`{"x":"`, `{"}`})
require.NoError(t, err)
assert.Equal(t, `{"x":"{"}`, arguments)
})
t.Run("cumulative snapshots fall back to longest valid object", func(t *testing.T) {
arguments, err := resolveToolArguments([]string{`{"x":`, `{"x":1}`, `{"x":1}`})
require.NoError(t, err)
assert.Equal(t, `{"x":1}`, arguments)
})
t.Run("non object fails closed", func(t *testing.T) {
_, err := resolveToolArguments([]string{`[1,2]`})
require.Error(t, err)
})
}
func TestStreamResponseOpenAI2ClaudeMessageStartDoesNotDependOnSendCount(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_no_counter", Model: "deepseek",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
}, info)
assert.Equal(t, []string{
"message_start",
"start:0:text::",
"delta:0:text_delta:hello",
}, claudeEventSignatures(events))
assert.True(t, info.ClaudeConvertInfo.MessageStarted)
}
func TestStreamResponseOpenAI2ClaudeUsageOnlyWithoutFinishKeepsEndTurnFallback(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_usage_only", Model: "compatible",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
}, info)
events = append(events, StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Usage: &dto.Usage{},
}, info)...)
assert.Equal(t, []string{
"message_start",
"start:0:text::",
"delta:0:text_delta:hello",
"stop:0",
"message_delta:end_turn",
"message_stop",
}, claudeEventSignatures(events))
assertValidClaudeEventSequence(t, events)
}
func TestStreamResponseOpenAI2ClaudeNoIndexParallelUsesStableIDs(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_no_index", Model: "compatible",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{ID: "call_alpha", Function: dto.FunctionResponse{Name: "alpha", Arguments: `{"a":`}},
{ID: "call_beta", Function: dto.FunctionResponse{Name: "beta", Arguments: `{"b":`}},
}},
}},
}, info)
// Only beta appears at position zero in this chunk. Its stable ID must route
// to beta rather than being merged into alpha's original position-zero call.
events = append(events, StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{ID: "call_beta", Function: dto.FunctionResponse{Arguments: `2}`}},
}},
}},
}, info)...)
events = append(events, StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("tool_calls"),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{ID: "call_alpha", Function: dto.FunctionResponse{Arguments: `1}`}},
}},
}},
Usage: &dto.Usage{},
}, info)...)
assert.Equal(t, []string{
"message_start",
"start:0:tool_use:alpha:call_alpha",
`delta:0:input_json_delta:{"a":1}`,
"stop:0",
"start:1:tool_use:beta:call_beta",
`delta:1:input_json_delta:{"b":2}`,
"stop:1",
"message_delta:tool_use",
"message_stop",
}, claudeEventSignatures(events))
assertValidClaudeEventSequence(t, events)
}
func TestStreamResponseOpenAI2ClaudeDuplicatePartialIDsDoNotMergeParallelTools(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{ID: "call_", Function: dto.FunctionResponse{Name: "alpha", Arguments: `{"a":`}},
{ID: "call_", Function: dto.FunctionResponse{Name: "beta", Arguments: `{"b":`}},
}},
}},
}, info)
events = append(events, StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("tool_calls"),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{ID: "call_alpha", Function: dto.FunctionResponse{Arguments: `1}`}},
{ID: "call_beta", Function: dto.FunctionResponse{Arguments: `2}`}},
}},
}},
Usage: &dto.Usage{},
}, info)...)
assert.Equal(t, []string{
"message_start",
"start:0:tool_use:alpha:call_alpha",
`delta:0:input_json_delta:{"a":1}`,
"stop:0",
"start:1:tool_use:beta:call_beta",
`delta:1:input_json_delta:{"b":2}`,
"stop:1",
"message_delta:tool_use",
"message_stop",
}, claudeEventSignatures(events))
assertValidClaudeEventSequence(t, events)
}
func TestStreamResponseOpenAI2ClaudeAmbiguousNoIndexFragmentFailsClosed(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{ID: "call_alpha", Function: dto.FunctionResponse{Name: "alpha"}},
{ID: "call_beta", Function: dto.FunctionResponse{Name: "beta"}},
}},
}},
}, info)
// Relocate beta to position zero using its stable id; position zero becomes
// ambiguous for any later fragment that has neither id nor index.
StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
ID: "call_beta", Function: dto.FunctionResponse{Arguments: `{}`},
}}},
}},
}, info)
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Function: dto.FunctionResponse{Arguments: `{}`},
}}},
}},
}, info)
assert.Equal(t, []string{
"error:relay stream conversion error: ambiguous no-index tool fragment at position 0",
}, claudeEventSignatures(events))
}
func TestStreamResponseOpenAI2ClaudeArgsBeforeIDSubsetFailsClosed(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Function: dto.FunctionResponse{Arguments: `{"a":`}},
{Function: dto.FunctionResponse{Arguments: `{"b":`}},
}},
}},
}, info)
// Only one newly identified call returns, but neither prior args-only buffer
// had an id/index. Position zero is not enough evidence to choose safely.
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
ID: "call_beta", Function: dto.FunctionResponse{Name: "beta", Arguments: `2}`},
}}},
}},
}, info)
assert.Equal(t, []string{
"error:relay stream conversion error: cannot disambiguate 2 no-index tools from a 1-call subset",
}, claudeEventSignatures(events))
}
func TestStreamResponseOpenAI2ClaudeConflictingIndexAndIDFailsClosed(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: ptr(0), ID: "call_alpha", Function: dto.FunctionResponse{Name: "alpha"}},
{Index: ptr(1), ID: "call_beta", Function: dto.FunctionResponse{Name: "beta"}},
}},
}},
}, info)
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Index: ptr(0), ID: "call_beta", Function: dto.FunctionResponse{Arguments: `{}`},
}}},
}},
}, info)
assert.Equal(t, []string{
`error:relay stream conversion error: conflicting tool identity for index 0 and id "call_beta"`,
}, claudeEventSignatures(events))
}
func TestStreamResponseOpenAI2ClaudePositionOnlyDifferentIdentityFailsClosed(t *testing.T) {
t.Run("different id and name", func(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
ID: "a", Function: dto.FunctionResponse{Name: "foo", Arguments: `{"x":`},
}}},
}},
}, info)
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
ID: "b", Function: dto.FunctionResponse{Name: "bar", Arguments: `1}`},
}}},
}},
}, info)
assert.Equal(t, []string{
`error:relay stream conversion error: ambiguous position-only tool ids "a" and "b" at position 0`,
}, claudeEventSignatures(events))
})
t.Run("different stable ids with prefix relation", func(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
ID: "call_a", Function: dto.FunctionResponse{Name: "foo", Arguments: `{"x":`},
}}},
}},
}, info)
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
ID: "call_ab", Function: dto.FunctionResponse{Name: "bar", Arguments: `1}`},
}}},
}},
}, info)
assert.Equal(t, []string{
`error:relay stream conversion error: ambiguous position-only tool ids "call_a" and "call_ab" at position 0`,
}, claudeEventSignatures(events))
})
t.Run("name fallback without ids", func(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Function: dto.FunctionResponse{Name: "foo", Arguments: `{"x":`},
}}},
}},
}, info)
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Function: dto.FunctionResponse{Name: "bar", Arguments: `1}`},
}}},
}},
}, info)
assert.Equal(t, []string{
`error:relay stream conversion error: ambiguous position-only tool names "foo" and "bar" at position 0`,
}, claudeEventSignatures(events))
})
}
func TestStreamResponseOpenAI2ClaudeCumulativeIDReplacesPriorMapAliases(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
cumulativeIDs := []string{"call_a", "call_ab", "call_abc", "call_abcd"}
for position, id := range cumulativeIDs {
name := ""
if position == 0 {
name = "lookup"
}
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Index: ptr(0), ID: id, Function: dto.FunctionResponse{Name: name},
}}},
}},
}, info)
if position == 0 {
require.Equal(t, []string{"message_start"}, claudeEventSignatures(events))
} else {
require.Empty(t, events)
}
}
lastID := cumulativeIDs[len(cumulativeIDs)-1]
require.Len(t, info.ClaudeConvertInfo.ToolCallByID, 1, "cumulative ids must replace, not retain, prior map keys")
assert.Equal(t, 0, info.ClaudeConvertInfo.ToolCallByID[lastID])
assert.Equal(t, lastID, info.ClaudeConvertInfo.PendingToolCalls[0].ID)
}
func TestStreamResponseOpenAI2ClaudeMixedChunkPreservesReasoningToolsTextOrder(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_mixed_fields", Model: "compatible",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("tool_calls"),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ReasoningContent: ptr("think"),
ToolCalls: []dto.ToolCallResponse{{
Index: ptr(4), ID: "call_1", Function: dto.FunctionResponse{Name: "lookup", Arguments: `{}`},
}},
Content: ptr("answer"),
},
}},
Usage: &dto.Usage{},
}, info)
assert.Equal(t, []string{
"message_start",
"start:0:thinking::",
"delta:0:thinking_delta:think",
"stop:0",
"start:1:tool_use:lookup:call_1",
"delta:1:input_json_delta:{}",
"stop:1",
"start:2:text::",
"delta:2:text_delta:answer",
"stop:2",
"message_delta:tool_use",
"message_stop",
}, claudeEventSignatures(events))
assertValidClaudeEventSequence(t, events)
}
func TestStreamResponseOpenAI2ClaudeToolBufferLimitReturnsError(t *testing.T) {
info := &convmeta.Values{ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{}}
events := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Index: ptr(0), ID: "call_1", Function: dto.FunctionResponse{
Name: "lookup",
Arguments: strings.Repeat("x", maxToolCallBufferBytes),
},
}}},
}},
}, info)
require.Len(t, events, 2)
assert.Equal(t, "message_start", events[0].Type)
assert.Contains(t, claudeEventSignatures(events)[1], "tool-call buffer exceeds")
assert.True(t, info.ClaudeConvertInfo.Done)
}
func claudeEventSignatures(events []*dto.ClaudeResponse) []string {
signatures := make([]string, 0, len(events))
for _, event := range events {
if event == nil {
signatures = append(signatures, "<nil>")
continue
}
switch event.Type {
case "content_block_start":
signatures = append(signatures, fmt.Sprintf("start:%d:%s:%s:%s", event.GetIndex(), event.ContentBlock.Type, event.ContentBlock.Name, event.ContentBlock.Id))
case "content_block_delta":
value := ""
if event.Delta != nil {
switch {
case event.Delta.PartialJson != nil:
value = *event.Delta.PartialJson
case event.Delta.Text != nil:
value = *event.Delta.Text
case event.Delta.Thinking != nil:
value = *event.Delta.Thinking
}
}
signatures = append(signatures, fmt.Sprintf("delta:%d:%s:%s", event.GetIndex(), event.Delta.Type, value))
case "content_block_stop":
signatures = append(signatures, fmt.Sprintf("stop:%d", event.GetIndex()))
case "message_delta":
stopReason := ""
if event.Delta != nil && event.Delta.StopReason != nil {
stopReason = *event.Delta.StopReason
}
signatures = append(signatures, "message_delta:"+stopReason)
case "error":
message := ""
if claudeError := event.GetClaudeError(); claudeError != nil {
message = claudeError.Message
}
signatures = append(signatures, "error:"+message)
default:
signatures = append(signatures, event.Type)
}
}
return signatures
}
func assertValidClaudeEventSequence(t *testing.T, events []*dto.ClaudeResponse) {
t.Helper()
open := make(map[int]bool)
nextIndex := 0
for _, event := range events {
if event == nil {
continue
}
switch event.Type {
case "content_block_start":
require.NotNil(t, event.Index)
idx := *event.Index
assert.Equal(t, nextIndex, idx, "content block indexes must be contiguous")
require.False(t, open[idx], "block %d started twice", idx)
open[idx] = true
nextIndex++
case "content_block_delta":
require.NotNil(t, event.Index)
assert.True(t, open[*event.Index], "block %d received delta without start", *event.Index)
case "content_block_stop":
require.NotNil(t, event.Index)
idx := *event.Index
require.True(t, open[idx], "block %d stopped without start", idx)
delete(open, idx)
case "message_stop":
assert.Empty(t, open, "message stopped with open content blocks")
}
}
assert.Empty(t, open, "stream ended with open content blocks")
}
func ptr[T any](value T) *T {
return &value
}
+4 -2
View File
@@ -100,8 +100,10 @@ func TestStreamResponseConverterFacadesAcceptTypedNilRelayInfo(t *testing.T) {
}
claudeResponses := StreamResponseOpenAI2Claude(streamResp, info)
require.NotEmpty(t, claudeResponses)
assert.Equal(t, "content_block_start", claudeResponses[0].Type)
require.Len(t, claudeResponses, 3)
assert.Equal(t, "message_start", claudeResponses[0].Type)
assert.Equal(t, "content_block_start", claudeResponses[1].Type)
assert.Equal(t, "content_block_delta", claudeResponses[2].Type)
geminiResp := StreamResponseOpenAI2Gemini(streamResp, info)
require.NotNil(t, geminiResp)