Files
new-api/relaykit/relayconvert/internal/claude_messages/to_oai_chat_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

565 lines
19 KiB
Go

package claudemessages
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type ClaudeResponseInfo struct {
ResponseId string
Created int64
Model string
ResponseText strings.Builder
Usage *dto.Usage
Done bool
// Only snapshots synthesized from partial display usage may be refreshed by
// later display deltas. Serialized BillingUsage always remains authoritative.
billingUsageSynthesized bool
}
func StopReasonClaudeToOpenAI(reason string) string {
return reasonmap.ClaudeStopReasonToOpenAIFinishReason(reason)
}
func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse {
var response dto.ChatCompletionsStreamResponse
response.Object = "chat.completion.chunk"
response.Model = claudeResponse.Model
response.Choices = make([]dto.ChatCompletionsStreamResponseChoice, 0)
tools := make([]dto.ToolCallResponse, 0)
fcIdx := 0
if claudeResponse.Index != nil {
fcIdx = *claudeResponse.Index
}
var choice dto.ChatCompletionsStreamResponseChoice
if claudeResponse.Type == "message_start" {
if claudeResponse.Message != nil {
response.Id = claudeResponse.Message.Id
response.Model = claudeResponse.Message.Model
}
choice.Delta.SetContentString("")
choice.Delta.Role = "assistant"
} else if claudeResponse.Type == "content_block_start" {
if claudeResponse.ContentBlock != nil {
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
annotations, err := claudeCitationsToChat(claudeResponse.ContentBlock.Citations, *claudeResponse.ContentBlock.Text, 0)
if err == nil {
choice.Delta.Annotations, _ = marshalChatAnnotations(annotations)
}
}
if claudeResponse.ContentBlock.Type == "tool_use" {
tools = append(tools, dto.ToolCallResponse{
Index: kitutil.GetPointer(fcIdx),
ID: claudeResponse.ContentBlock.Id,
Type: "function",
Function: dto.FunctionResponse{
Name: claudeResponse.ContentBlock.Name,
Arguments: "",
},
})
}
} else {
return nil
}
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta != nil {
choice.Delta.Content = claudeResponse.Delta.Text
switch claudeResponse.Delta.Type {
case "input_json_delta":
tools = append(tools, dto.ToolCallResponse{
Type: "function",
Index: kitutil.GetPointer(fcIdx),
Function: dto.FunctionResponse{
Arguments: *claudeResponse.Delta.PartialJson,
},
})
case "signature_delta":
signatureContent := "\n"
choice.Delta.ReasoningContent = &signatureContent
case "thinking_delta":
choice.Delta.ReasoningContent = claudeResponse.Delta.Thinking
case "citations_delta":
if len(claudeResponse.Delta.Citation) > 0 {
raw, _ := kitutil.Marshal([]json.RawMessage{claudeResponse.Delta.Citation})
annotations, err := claudeCitationsToChat(raw, "", 0)
if err == nil {
choice.Delta.Annotations, _ = marshalChatAnnotations(annotations)
}
}
}
}
} else if claudeResponse.Type == "message_delta" {
if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {
finishReason := StopReasonClaudeToOpenAI(*claudeResponse.Delta.StopReason)
if finishReason != "null" {
choice.FinishReason = &finishReason
}
}
} else if claudeResponse.Type == "message_stop" {
return nil
} else {
return nil
}
if len(tools) > 0 {
choice.Delta.Content = nil
choice.Delta.ToolCalls = tools
}
response.Choices = append(response.Choices, choice)
return &response
}
// ClaudeToChatStreamState translates Anthropic content block indexes into the
// independent, dense index space used by Chat Completions tool_calls. Text and
// thinking blocks therefore do not create holes in the downstream tool array.
type ClaudeToChatStreamState struct {
toolIndexByContentBlock map[int]int
blockTypeByContentBlock map[int]string
nextToolIndex int
}
func NewClaudeToChatStreamState() *ClaudeToChatStreamState {
return &ClaudeToChatStreamState{
toolIndexByContentBlock: make(map[int]int),
blockTypeByContentBlock: make(map[int]string),
}
}
func (s *ClaudeToChatStreamState) ConvertChunk(claudeResponse *dto.ClaudeResponse) (*dto.ChatCompletionsStreamResponse, error) {
if s == nil {
return nil, fmt.Errorf("Claude-to-Chat stream state is required")
}
if claudeResponse == nil {
return nil, nil
}
if s.toolIndexByContentBlock == nil {
s.toolIndexByContentBlock = make(map[int]int)
}
if s.blockTypeByContentBlock == nil {
s.blockTypeByContentBlock = make(map[int]string)
}
converted := *claudeResponse
switch claudeResponse.Type {
case "content_block_start":
if claudeResponse.ContentBlock == nil {
break
}
blockType := strings.TrimSpace(claudeResponse.ContentBlock.Type)
if blockType == "" {
break
}
if claudeResponse.Index == nil {
return nil, fmt.Errorf("Claude content block stream start is missing index")
}
contentBlockIndex := *claudeResponse.Index
s.blockTypeByContentBlock[contentBlockIndex] = blockType
if blockType != "tool_use" {
if isClaudeHostedToolStreamBlock(blockType) {
return nil, nil
}
break
}
toolIndex, exists := s.toolIndexByContentBlock[contentBlockIndex]
if !exists {
toolIndex = s.nextToolIndex
s.nextToolIndex++
s.toolIndexByContentBlock[contentBlockIndex] = toolIndex
}
converted.Index = kitutil.GetPointer(toolIndex)
case "content_block_delta":
if claudeResponse.Delta == nil || claudeResponse.Delta.Type != "input_json_delta" {
break
}
if claudeResponse.Index == nil {
return nil, fmt.Errorf("Claude tool-use stream delta is missing content block index")
}
if claudeResponse.Delta.PartialJson == nil {
return nil, fmt.Errorf("Claude tool-use stream delta is missing partial JSON")
}
contentBlockIndex := *claudeResponse.Index
toolIndex, exists := s.toolIndexByContentBlock[contentBlockIndex]
if !exists {
if isClaudeHostedToolStreamBlock(s.blockTypeByContentBlock[contentBlockIndex]) {
return nil, nil
}
return nil, fmt.Errorf("Claude tool-use stream delta references unknown content block index %d", contentBlockIndex)
}
converted.Index = kitutil.GetPointer(toolIndex)
case "content_block_stop":
if claudeResponse.Index != nil {
delete(s.blockTypeByContentBlock, *claudeResponse.Index)
}
}
return StreamResponseClaude2OpenAI(&converted), nil
}
func isClaudeHostedToolStreamBlock(blockType string) bool {
switch blockType {
case "server_tool_use", "mcp_tool_use", "web_search_tool_result", "mcp_tool_result", "code_execution_tool_result", "web_fetch_tool_result":
return true
default:
return false
}
}
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
choices := make([]dto.OpenAITextResponseChoice, 0)
fullTextResponse := dto.OpenAITextResponse{
Id: fmt.Sprintf("chatcmpl-%s", kitutil.GetUUID()),
Object: "chat.completion",
Created: kitutil.GetTimestamp(),
}
var responseText strings.Builder
responseTextOffset := 0
var responseThinking string
if len(claudeResponse.Content) > 0 {
if claudeResponse.Content[0].Thinking != nil {
responseThinking = *claudeResponse.Content[0].Thinking
}
}
tools := make([]dto.ToolCallResponse, 0)
thinkingContent := ""
annotations := make([]any, 0)
fullTextResponse.Id = claudeResponse.Id
for _, message := range claudeResponse.Content {
switch message.Type {
case "tool_use":
args, _ := kitutil.Marshal(message.Input)
tools = append(tools, dto.ToolCallResponse{
ID: message.Id,
Type: "function",
Function: dto.FunctionResponse{
Name: message.Name,
Arguments: string(args),
},
})
case "thinking":
if message.Thinking != nil {
thinkingContent = *message.Thinking
}
case "text":
text := message.GetText()
offset := responseTextOffset
responseText.WriteString(text)
responseTextOffset += utf8.RuneCountInString(text)
converted, err := claudeCitationsToChat(message.Citations, text, offset)
if err == nil {
annotations = append(annotations, converted...)
}
}
}
choice := dto.OpenAITextResponseChoice{
Index: 0,
Message: dto.Message{
Role: "assistant",
},
FinishReason: StopReasonClaudeToOpenAI(claudeResponse.StopReason),
}
choice.SetStringContent(responseText.String())
if encodedAnnotations, err := marshalChatAnnotations(annotations); err == nil && len(encodedAnnotations) > 0 {
choice.Message.Annotations = encodedAnnotations
}
if len(responseThinking) > 0 {
choice.ReasoningContent = &responseThinking
}
if len(tools) > 0 {
choice.Message.SetToolCalls(tools)
}
if thinkingContent != "" {
choice.Message.ReasoningContent = &thinkingContent
}
fullTextResponse.Model = claudeResponse.Model
choices = append(choices, choice)
fullTextResponse.Choices = choices
return &fullTextResponse
}
func UsageFromClaudeAPIUsage(usage *dto.ClaudeUsage) *dto.Usage {
if usage == nil {
return &dto.Usage{}
}
semanticUsage := &dto.Usage{
PromptTokens: usage.InputTokens,
CompletionTokens: usage.OutputTokens,
UsageSemantic: "anthropic",
UsageSource: "anthropic",
BillingUsage: dto.CloneBillingUsage(usage.BillingUsage),
}
if semanticUsage.BillingUsage == nil {
semanticUsage.BillingUsage = dto.NewClaudeMessagesBillingUsage(usage)
}
semanticUsage.PromptTokensDetails.CachedTokens = usage.CacheReadInputTokens
semanticUsage.PromptTokensDetails.CachedCreationTokens = usage.CacheCreationInputTokens
semanticUsage.ClaudeCacheCreation5mTokens = usage.GetCacheCreation5mTokens()
semanticUsage.ClaudeCacheCreation1hTokens = usage.GetCacheCreation1hTokens()
return UsageFromClaudeUsage(semanticUsage)
}
func UsageFromClaudeUsage(usage *dto.Usage) *dto.Usage {
mapped := buildOpenAIStyleUsageFromClaudeUsage(usage)
return &mapped
}
func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int {
if usage == nil {
return 0
}
splitCacheCreationTokens := usage.ClaudeCacheCreation5mTokens + usage.ClaudeCacheCreation1hTokens
if splitCacheCreationTokens == 0 {
return usage.PromptTokensDetails.CachedCreationTokens
}
if usage.PromptTokensDetails.CachedCreationTokens > splitCacheCreationTokens {
return usage.PromptTokensDetails.CachedCreationTokens
}
return splitCacheCreationTokens
}
func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
if usage == nil {
return dto.Usage{}
}
clone := *usage
clone.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = sharedclaude.NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
// Expose the standard OpenAI cache-write field alongside the legacy
// cached_creation_tokens so OpenAI-format clients can bill cache writes.
clone.PromptTokensDetails.CacheWriteTokens = cacheCreationTokens
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
clone.PromptTokens = totalInputTokens
clone.InputTokens = totalInputTokens
clone.TotalTokens = totalInputTokens + usage.CompletionTokens
clone.UsageSemantic = "openai"
clone.UsageSource = "anthropic"
return clone
}
func BuildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage {
usage := &dto.ClaudeUsage{}
if claudeResponse != nil && claudeResponse.Usage != nil {
*usage = *claudeResponse.Usage
}
if claudeInfo == nil || claudeInfo.Usage == nil {
return usage
}
if usage.InputTokens == 0 && claudeInfo.Usage.PromptTokens > 0 {
usage.InputTokens = claudeInfo.Usage.PromptTokens
}
if usage.CacheReadInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedTokens > 0 {
usage.CacheReadInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedTokens
}
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
}
cacheCreation5m := 0
cacheCreation1h := 0
if usage.CacheCreation != nil {
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
} else {
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
}
cacheCreation5m, cacheCreation1h = sharedclaude.NormalizeCacheCreationSplit(
usage.CacheCreationInputTokens,
cacheCreation5m,
cacheCreation1h,
)
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
}
if usage.CacheCreation != nil {
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
}
return usage
}
func claudeBillingUsageFromSemanticUsage(usage *dto.Usage) *dto.BillingUsage {
if usage == nil {
return nil
}
cacheCreation5m, cacheCreation1h := sharedclaude.NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
claudeUsage := &dto.ClaudeUsage{
InputTokens: usage.PromptTokens,
CacheCreationInputTokens: usage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens,
OutputTokens: usage.CompletionTokens,
}
if cacheCreation5m > 0 || cacheCreation1h > 0 {
claudeUsage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
return dto.NewClaudeMessagesBillingUsage(claudeUsage)
}
func updateClaudeStreamBillingUsage(claudeUsage *dto.ClaudeUsage, claudeInfo *ClaudeResponseInfo, terminal bool) {
if claudeUsage == nil || claudeInfo == nil || claudeInfo.Usage == nil {
return
}
if billingUsage := dto.CloneBillingUsage(claudeUsage.BillingUsage); billingUsage != nil {
claudeInfo.Usage.BillingUsage = billingUsage
if terminal || claudeUsage.OutputTokens > 0 {
claudeInfo.billingUsageSynthesized = false
return
}
claudeInfo.billingUsageSynthesized = true
return
}
if claudeInfo.Usage.BillingUsage != nil && !claudeInfo.billingUsageSynthesized {
return
}
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
claudeInfo.billingUsageSynthesized = claudeInfo.Usage.BillingUsage != nil
}
// FinalizeClaudeStreamBillingUsage refreshes only a locally synthesized
// snapshot after the host has applied its missing-usage fallback. A snapshot
// received on the wire remains authoritative and is never rewritten.
func FinalizeClaudeStreamBillingUsage(claudeInfo *ClaudeResponseInfo) {
if claudeInfo == nil || claudeInfo.Usage == nil {
return
}
if claudeInfo.Usage.BillingUsage != nil && !claudeInfo.billingUsageSynthesized {
return
}
billingUsage := claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
if billingUsage != nil && !claudeInfo.Done {
billingUsage.Estimated = true
}
claudeInfo.Usage.BillingUsage = billingUsage
claudeInfo.billingUsageSynthesized = billingUsage != nil
}
func PatchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
if data == "" || usage == nil {
return data
}
data = setMessageDeltaUsageInt(data, "usage.input_tokens", usage.InputTokens)
data = setMessageDeltaUsageInt(data, "usage.cache_read_input_tokens", usage.CacheReadInputTokens)
data = setMessageDeltaUsageInt(data, "usage.cache_creation_input_tokens", usage.CacheCreationInputTokens)
if usage.CacheCreation != nil {
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_5m_input_tokens", usage.CacheCreation.Ephemeral5mInputTokens)
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_1h_input_tokens", usage.CacheCreation.Ephemeral1hInputTokens)
}
return data
}
func setMessageDeltaUsageInt(data string, path string, localValue int) string {
if localValue <= 0 {
return data
}
upstreamValue := gjson.Get(data, path)
if upstreamValue.Exists() && upstreamValue.Int() > 0 {
return data
}
patchedData, err := sjson.Set(data, path, localValue)
if err != nil {
return data
}
return patchedData
}
func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
if claudeInfo == nil {
return false
}
if claudeInfo.Usage == nil {
claudeInfo.Usage = &dto.Usage{}
}
if claudeResponse.Type == "message_start" {
if claudeResponse.Message != nil {
claudeInfo.ResponseId = claudeResponse.Message.Id
claudeInfo.Model = claudeResponse.Message.Model
}
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
messageUsage := claudeResponse.Message.Usage
claudeInfo.Usage.PromptTokens = messageUsage.InputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
claudeInfo.Usage.PromptTokensDetails.CachedTokens = messageUsage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = messageUsage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = messageUsage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = messageUsage.GetCacheCreation1hTokens()
claudeInfo.Usage.CompletionTokens = messageUsage.OutputTokens
updateClaudeStreamBillingUsage(messageUsage, claudeInfo, false)
}
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta != nil {
if claudeResponse.Delta.Text != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
}
if claudeResponse.Delta.Thinking != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
}
}
} else if claudeResponse.Type == "message_delta" {
if claudeResponse.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
if claudeResponse.Usage.InputTokens > 0 {
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
}
if claudeResponse.Usage.CacheReadInputTokens > 0 {
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
}
if claudeResponse.Usage.CacheCreationInputTokens > 0 {
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
}
if cacheCreation5m := claudeResponse.Usage.GetCacheCreation5mTokens(); cacheCreation5m > 0 {
claudeInfo.Usage.ClaudeCacheCreation5mTokens = cacheCreation5m
}
if cacheCreation1h := claudeResponse.Usage.GetCacheCreation1hTokens(); cacheCreation1h > 0 {
claudeInfo.Usage.ClaudeCacheCreation1hTokens = cacheCreation1h
}
if claudeResponse.Usage.OutputTokens > 0 {
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
updateClaudeStreamBillingUsage(claudeResponse.Usage, claudeInfo, true)
}
claudeInfo.Done = true
} else if claudeResponse.Type == "content_block_start" {
} else {
return false
}
if oaiResponse != nil {
oaiResponse.Id = claudeInfo.ResponseId
oaiResponse.Created = claudeInfo.Created
oaiResponse.Model = claudeInfo.Model
}
return true
}