mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-08-31 02:41:34 +00:00
Merge c44e43230f into b518d0033b
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user