mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-14 00:01:53 +00:00
* 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
483 lines
15 KiB
Go
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)
|
|
}
|