Files
new-api/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
T
Calcium-Ion 0ed497f066 feat(relay): hosted-tool conversion fidelity, reasoning normalization, and billing usage integrity (#7137)
* feat(relaykit): preserve hosted tools across conversions

- add protocol-neutral hosted-tool DTOs, conversion metadata, and loss policies
- bridge citations, grounding metadata, and hosted-tool stream lifecycles
- document the public conversion behavior and channel policy controls

* refactor(relaykit): normalize reasoning and thinking intent

- centralize provider-neutral reasoning intent, effort, and budget mappings
- parse model suffixes at the host entry boundary while preserving provider-owned tails
- keep adaptive Claude thinking and explicit zero-token compatibility consistent

* fix(billing): preserve authoritative usage across relay hops

- carry native BillingUsage sidecars through direct and streamed protocol bridges
- merge partial and terminal usage monotonically with safe fallback settlement
- retain cache metadata, penultimate usage, and per-call Gemini tool surcharges

* feat(relay): bridge Responses with Claude and Gemini protocols

- add direct request, response, and stream converters across supported relay formats
- expose Claude count_tokens and Chat-to-Responses compatibility endpoints
- carry conversion diagnostics through the host while retaining the curated public goldens

* fix(relay): wire relaykit conversions into host channels

- connect handlers, adaptors, and channel settings to the standalone conversion layer
- keep model mapping, pricing identity, retries, and provider-specific suffix behavior aligned
- ignore local audit artifacts and retain focused public regression coverage
2026-09-01 21:53:35 +08:00

483 lines
15 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 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 {
msg := &dto.ClaudeMediaMessage{
Id: openAIResponse.Id,
Model: openAIResponse.Model,
Type: "message",
Role: "assistant",
Usage: &dto.ClaudeUsage{
InputTokens: info.GetEstimatePromptTokens(),
OutputTokens: 0,
},
}
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 := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
if oaiUsage != nil {
appendStopOpenBlocks()
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),
},
})
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 := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.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: 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
}
}
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: buildClaudeUsageFromOpenAIUsage(state.Usage),
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)
}