mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-14 00:01:53 +00:00
Deferred follow-ups from the relaykit-tools review cycle, verified by live end-to-end billing tests: - billing: normalize Gemini modality keys consistently between stream merge and settlement (case/whitespace variants no longer drop independent audio/image pricing) and sum duplicate modality entries on both paths - billing: sync legacy flat Claude cache-creation fields from the CacheCreation sub-object (including zeroing) and fall back to flat fields only when the snapshot never carried a sub-object, closing a stale 1h-cache overcharge path in cascaded deployments - relay: move Chat-to-Claude and Chat-to-Gemini stream conversion state from gin.Context onto RelayInfo and reset it with SendResponseCount in InitChannelMeta, so channel retries start clean while per-request state (stream error collection, conversion diagnostics, channel chain, billing accumulators) survives - relay: Claude channel now serves Gemini-format clients (request via registry conversion, response and stream composed through the Chat pivot), removing the last unimplemented conversion direction - relaykit: recognize legacy pseudo tool names (googleSearch, codeExecution, urlContext) in the toolconv decode stage and drop the string-matching bypass in the Chat-to-Gemini converter; native Gemini tool output is restored and non-Gemini targets follow standard loss diagnostics - relaykit: attach upstream Gemini usage (with billing_usage sidecar) to intermediate stream chunks so converted Claude streams report upstream truth from message_start, and preserve the sidecar through Claude stream usage merges; billing settlement unchanged - billing: clamp negative Total-Prompt completion derivation, OR the Estimated flag across cross-dialect snapshot replacement, and fill canonical OpenAI prompt details via field-wise merge
504 lines
16 KiB
Go
504 lines
16 KiB
Go
package oaichat
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
"github.com/QuantumNous/new-api/relaykit/reasonmap"
|
|
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
|
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
|
|
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
|
)
|
|
|
|
func generateStopBlock(index int) *dto.ClaudeResponse {
|
|
return &dto.ClaudeResponse{
|
|
Type: "content_block_stop",
|
|
Index: kitutil.GetPointer[int](index),
|
|
}
|
|
}
|
|
|
|
func stopOpenBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
switch state.LastMessagesType {
|
|
case convmeta.LastMessageTypeText, convmeta.LastMessageTypeThinking:
|
|
return []*dto.ClaudeResponse{generateStopBlock(state.Index)}
|
|
case convmeta.LastMessageTypeTools:
|
|
if len(state.ToolCalls) == 0 {
|
|
responses := make([]*dto.ClaudeResponse, 0, state.ToolCallMaxIndexOffset+1)
|
|
for offset := 0; offset <= state.ToolCallMaxIndexOffset; offset++ {
|
|
responses = append(responses, generateStopBlock(state.ToolCallBaseIndex+offset))
|
|
}
|
|
return responses
|
|
}
|
|
responses := make([]*dto.ClaudeResponse, 0, len(state.ToolCalls))
|
|
for _, tool := range state.ToolCalls {
|
|
if tool != nil && tool.Started {
|
|
responses = append(responses, generateStopBlock(tool.BlockIndex))
|
|
}
|
|
}
|
|
return responses
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func startPendingToolBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
|
|
if state == nil || state.LastMessagesType != convmeta.LastMessageTypeTools {
|
|
return nil
|
|
}
|
|
responses := make([]*dto.ClaudeResponse, 0)
|
|
for _, tool := range state.ToolCalls {
|
|
if tool == nil || tool.Started || tool.Name == "" {
|
|
continue
|
|
}
|
|
if tool.ID == "" {
|
|
tool.ID = fmt.Sprintf("toolu_%s", kitutil.GetUUID())
|
|
}
|
|
idx := tool.BlockIndex
|
|
responses = append(responses, &dto.ClaudeResponse{
|
|
Index: &idx,
|
|
Type: "content_block_start",
|
|
ContentBlock: &dto.ClaudeMediaMessage{
|
|
Id: tool.ID,
|
|
Type: "tool_use",
|
|
Name: tool.Name,
|
|
Input: map[string]interface{}{},
|
|
},
|
|
})
|
|
tool.Started = true
|
|
if tool.PendingArguments != "" {
|
|
arguments := tool.PendingArguments
|
|
responses = append(responses, &dto.ClaudeResponse{
|
|
Index: &idx,
|
|
Type: "content_block_delta",
|
|
Delta: &dto.ClaudeMediaMessage{
|
|
Type: "input_json_delta",
|
|
PartialJson: &arguments,
|
|
},
|
|
})
|
|
tool.PendingArguments = ""
|
|
}
|
|
}
|
|
return responses
|
|
}
|
|
|
|
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
|
return sharedclaude.UsageFromOpenAI(oaiUsage)
|
|
}
|
|
|
|
func clientVisibleClaudeStreamUsage(state *convmeta.ClaudeConvertInfo, incoming *dto.Usage) *dto.ClaudeUsage {
|
|
prior := buildClaudeUsageFromOpenAIUsage(state.Usage)
|
|
converted := buildClaudeUsageFromOpenAIUsage(incoming)
|
|
if incoming != nil {
|
|
state.Usage = dto.MergeUsageNonZero(state.Usage, incoming)
|
|
}
|
|
if prior == nil {
|
|
return converted
|
|
}
|
|
if converted == nil {
|
|
return prior
|
|
}
|
|
return dto.MergeClaudeUsageNonZero(prior, converted)
|
|
}
|
|
|
|
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
|
return sharedclaude.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
|
|
}
|
|
|
|
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) []*dto.ClaudeResponse {
|
|
if info == nil {
|
|
info = &convmeta.Values{}
|
|
}
|
|
state := info.EnsureClaudeConvertInfo()
|
|
if state.Done {
|
|
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, startPendingToolBlocks(state)...)
|
|
claudeResponses = append(claudeResponses, stopOpenBlocks(state)...)
|
|
}
|
|
// 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 + len(state.ToolCalls)
|
|
state.ToolCallBaseIndex = 0
|
|
state.ToolCallMaxIndexOffset = 0
|
|
state.ToolCalls = nil
|
|
state.ToolCallByIndex = nil
|
|
state.ToolCallByID = nil
|
|
default:
|
|
state.Index++
|
|
}
|
|
state.LastMessagesType = convmeta.LastMessageTypeNone
|
|
}
|
|
appendCitationDeltas := func(raw []byte) {
|
|
citations := chatAnnotationsToClaude(raw, "")
|
|
if len(citations) == 0 {
|
|
return
|
|
}
|
|
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
|
|
}
|
|
for _, citation := range citations {
|
|
idx := state.Index
|
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
|
Index: &idx,
|
|
Type: "content_block_delta",
|
|
Delta: &dto.ClaudeMediaMessage{
|
|
Type: "citations_delta",
|
|
Citation: citation,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
if info.GetSendResponseCount() == 1 {
|
|
// Client-visible Claude stream usage matches billing merge: first
|
|
// frame records first, a later non-zero field overrides, and a later
|
|
// zero/missing field never erases a first-frame positive. Anthropic
|
|
// clients treat message_delta as authoritative for the fields it
|
|
// carries, including a corrected input_tokens.
|
|
startUsage := &dto.ClaudeUsage{
|
|
InputTokens: info.GetEstimatePromptTokens(),
|
|
OutputTokens: 0,
|
|
}
|
|
if openAIResponse.Usage != nil && dto.HasOpenAIUsageTokens(openAIResponse.Usage) {
|
|
if real := buildClaudeUsageFromOpenAIUsage(openAIResponse.Usage); real != nil {
|
|
startUsage = real
|
|
}
|
|
state.Usage = dto.MergeUsageNonZero(state.Usage, openAIResponse.Usage)
|
|
}
|
|
msg := &dto.ClaudeMediaMessage{
|
|
Id: openAIResponse.Id,
|
|
Model: openAIResponse.Model,
|
|
Type: "message",
|
|
Role: "assistant",
|
|
Usage: startUsage,
|
|
}
|
|
msg.SetContent(make([]any, 0))
|
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
|
Type: "message_start",
|
|
Message: msg,
|
|
})
|
|
}
|
|
|
|
if len(openAIResponse.Choices) == 0 {
|
|
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
|
|
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
|
|
if oaiUsage != nil {
|
|
appendStopOpenBlocks()
|
|
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
|
|
if stopReason == "" {
|
|
stopReason = "end_turn"
|
|
}
|
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
|
Type: "message_delta",
|
|
Usage: oaiUsage,
|
|
Delta: &dto.ClaudeMediaMessage{
|
|
StopReason: kitutil.GetPointer[string](stopReason),
|
|
},
|
|
})
|
|
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
|
|
}
|
|
|
|
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
|
|
state.ToolCalls = nil
|
|
state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
|
|
state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
|
|
}
|
|
state.LastMessagesType = convmeta.LastMessageTypeTools
|
|
if state.ToolCallByIndex == nil {
|
|
state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
|
|
}
|
|
if state.ToolCallByID == nil {
|
|
state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
|
|
}
|
|
for i, toolCall := range toolCalls {
|
|
toolIndex := i
|
|
if toolCall.Index != nil {
|
|
toolIndex = *toolCall.Index
|
|
}
|
|
incomingID := strings.TrimSpace(toolCall.ID)
|
|
var tool *convmeta.ClaudeStreamToolCall
|
|
if incomingID != "" {
|
|
tool = state.ToolCallByID[incomingID]
|
|
}
|
|
if tool == nil {
|
|
tool = state.ToolCallByIndex[toolIndex]
|
|
}
|
|
if tool != nil && incomingID != "" && tool.ID != "" && tool.ID != incomingID {
|
|
tool = nil
|
|
}
|
|
if tool == nil {
|
|
tool = &convmeta.ClaudeStreamToolCall{
|
|
BlockIndex: state.ToolCallBaseIndex + len(state.ToolCalls),
|
|
}
|
|
state.ToolCalls = append(state.ToolCalls, tool)
|
|
}
|
|
state.ToolCallByIndex[toolIndex] = tool
|
|
if tool.ID == "" && incomingID != "" {
|
|
tool.ID = incomingID
|
|
state.ToolCallByID[incomingID] = tool
|
|
}
|
|
if tool.Name == "" && strings.TrimSpace(toolCall.Function.Name) != "" {
|
|
tool.Name = strings.TrimSpace(toolCall.Function.Name)
|
|
}
|
|
if !tool.Started {
|
|
tool.PendingArguments += toolCall.Function.Arguments
|
|
}
|
|
|
|
idx := tool.BlockIndex
|
|
if !tool.Started && tool.ID != "" && tool.Name != "" {
|
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
|
Index: &idx,
|
|
Type: "content_block_start",
|
|
ContentBlock: &dto.ClaudeMediaMessage{
|
|
Id: tool.ID,
|
|
Type: "tool_use",
|
|
Name: tool.Name,
|
|
Input: map[string]interface{}{},
|
|
},
|
|
})
|
|
tool.Started = true
|
|
if tool.PendingArguments != "" {
|
|
arguments := tool.PendingArguments
|
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
|
Index: &idx,
|
|
Type: "content_block_delta",
|
|
Delta: &dto.ClaudeMediaMessage{
|
|
Type: "input_json_delta",
|
|
PartialJson: &arguments,
|
|
},
|
|
})
|
|
tool.PendingArguments = ""
|
|
}
|
|
continue
|
|
}
|
|
|
|
if tool.Started && toolCall.Function.Arguments != "" {
|
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
|
Index: &idx,
|
|
Type: "content_block_delta",
|
|
Delta: &dto.ClaudeMediaMessage{
|
|
Type: "input_json_delta",
|
|
PartialJson: &toolCall.Function.Arguments,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
state.ToolCallMaxIndexOffset = len(state.ToolCalls) - 1
|
|
if len(state.ToolCalls) > 0 {
|
|
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls) - 1
|
|
}
|
|
} 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)
|
|
}
|
|
appendCitationDeltas(chosenChoice.Delta.Annotations)
|
|
|
|
if doneChunk || state.Done {
|
|
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
|
|
if oaiUsage == nil {
|
|
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
|
|
// Keep content blocks open until usage is available so the terminal message_delta
|
|
// can carry both usage and the final stop reason.
|
|
return claudeResponses
|
|
}
|
|
appendStopOpenBlocks()
|
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
|
Type: "message_delta",
|
|
Usage: oaiUsage,
|
|
Delta: &dto.ClaudeMediaMessage{
|
|
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
|
|
},
|
|
})
|
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
|
Type: "message_stop",
|
|
})
|
|
state.Done = true
|
|
return claudeResponses
|
|
}
|
|
}
|
|
|
|
return claudeResponses
|
|
}
|
|
|
|
func FinalizeStreamResponseOpenAI2Claude(info convmeta.Meta) []*dto.ClaudeResponse {
|
|
if info == nil {
|
|
info = &convmeta.Values{}
|
|
}
|
|
state := info.EnsureClaudeConvertInfo()
|
|
if state.Done {
|
|
return nil
|
|
}
|
|
|
|
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
|
|
if stopReason == "" {
|
|
stopReason = "end_turn"
|
|
}
|
|
responses := startPendingToolBlocks(state)
|
|
responses = append(responses, stopOpenBlocks(state)...)
|
|
responses = append(responses,
|
|
&dto.ClaudeResponse{
|
|
Type: "message_delta",
|
|
Usage: clientVisibleClaudeStreamUsage(state, nil),
|
|
Delta: &dto.ClaudeMediaMessage{
|
|
StopReason: kitutil.GetPointer[string](stopReason),
|
|
},
|
|
},
|
|
&dto.ClaudeResponse{Type: "message_stop"},
|
|
)
|
|
state.Done = true
|
|
return responses
|
|
}
|
|
|
|
func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.ClaudeResponse {
|
|
var stopReason string
|
|
contents := make([]dto.ClaudeMediaMessage, 0)
|
|
claudeResponse := &dto.ClaudeResponse{
|
|
Id: openAIResponse.Id,
|
|
Type: "message",
|
|
Role: "assistant",
|
|
Model: openAIResponse.Model,
|
|
}
|
|
for _, choice := range openAIResponse.Choices {
|
|
stopReason = stopReasonOpenAI2Claude(choice.FinishReason)
|
|
reasoningContent := choice.Message.GetReasoningContent()
|
|
textContent := choice.Message.StringContent()
|
|
toolCalls := choice.Message.ParseToolCalls()
|
|
if reasoningContent != "" {
|
|
claudeContent := dto.ClaudeMediaMessage{Type: "thinking"}
|
|
claudeContent.Thinking = kitutil.GetPointer(reasoningContent)
|
|
contents = append(contents, claudeContent)
|
|
}
|
|
if textContent != "" || (reasoningContent == "" && len(toolCalls) == 0) {
|
|
claudeContent := dto.ClaudeMediaMessage{}
|
|
claudeContent.Type = "text"
|
|
claudeContent.SetText(textContent)
|
|
citations := chatAnnotationsToClaude(choice.Message.Annotations, textContent)
|
|
if len(citations) > 0 {
|
|
claudeContent.Citations, _ = kitutil.Marshal(citations)
|
|
}
|
|
contents = append(contents, claudeContent)
|
|
}
|
|
for _, toolUse := range toolCalls {
|
|
claudeContent := dto.ClaudeMediaMessage{}
|
|
claudeContent.Type = "tool_use"
|
|
claudeContent.Id = toolUse.ID
|
|
claudeContent.Name = toolUse.Function.Name
|
|
mapParams := map[string]interface{}{}
|
|
if strings.TrimSpace(toolUse.Function.Arguments) != "" {
|
|
var parsed map[string]interface{}
|
|
if err := kitutil.Unmarshal([]byte(toolUse.Function.Arguments), &parsed); err == nil && parsed != nil {
|
|
mapParams = parsed
|
|
}
|
|
}
|
|
claudeContent.Input = mapParams
|
|
contents = append(contents, claudeContent)
|
|
}
|
|
}
|
|
claudeResponse.Content = contents
|
|
claudeResponse.StopReason = stopReason
|
|
claudeResponse.Usage = buildClaudeUsageFromOpenAIUsage(&openAIResponse.Usage)
|
|
|
|
return claudeResponse
|
|
}
|
|
|
|
func stopReasonOpenAI2Claude(reason string) string {
|
|
return reasonmap.OpenAIFinishReasonToClaudeStopReason(reason)
|
|
}
|