Files
new-api/service/relayconvert/internal/claude_messages/to_oai_chat_req.go
T
Calcium-Ion c36418c863 feat: enhance text protocol conversion and advanced custom routing (#5825)
* refactor: consolidate relay protocol converters

* refactor relayconvert text converters

* feat: refine relay converters and advanced custom routing

* refactor: enhance logging and add thought signature handling for Gemini requests

* refactor: enhance channel cache and pricing endpoint handling for advanced custom models

* feat: preserve billing usage semantics

* feat: add protocol-aware billing usage

* Delete useless files

* chore: update action versions in workflow files

* chore: update Docker action versions in workflow files

* fix: harden billing usage settlement and hot-path route matching

- estimate Gemini completion tokens locally when billable usageMetadata is
  prompt-only but output content was received (e.g. client aborts the stream
  before the final chunk), and rebuild the attached billing_usage as estimated
  so settlement does not bill zero output tokens
- guard NewClaudeMessagesBillingUsage against all-zero ClaudeUsage, matching
  the OpenAI/Gemini constructors, so a zero billing_usage cannot override a
  non-zero top-level usage during settlement
- cache compiled advanced-custom route model regexes; they run on the request
  hot path and were recompiled per request
- move the effectiveBillingUsage remap to PostTextConsumeQuota only, and
  document that calculateTextQuotaSummary expects remapped usage
- document the updatePricingLock -> channelSyncLock lock ordering that
  InitChannelCache/CacheUpdateChannel rely on, and the aux-struct pitfall in
  GeminiChatResponse.UnmarshalJSON
2026-07-11 20:44:12 +08:00

222 lines
6.7 KiB
Go

package claudemessages
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relaymeta "github.com/QuantumNous/new-api/service/relayconvert/internal/meta"
)
const (
webSearchMaxUsesLow = 1
webSearchMaxUsesMedium = 5
webSearchMaxUsesHigh = 10
)
type openRouterRequestReasoning struct {
Enabled bool `json:"enabled"`
Effort string `json:"effort,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Exclude bool `json:"exclude,omitempty"`
}
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
openAIRequest := dto.GeneralOpenAIRequest{
Model: claudeRequest.Model,
Temperature: claudeRequest.Temperature,
}
if claudeRequest.MaxTokens != nil {
openAIRequest.MaxTokens = common.GetPointer(*claudeRequest.MaxTokens)
}
if claudeRequest.TopP != nil {
openAIRequest.TopP = common.GetPointer(*claudeRequest.TopP)
}
if claudeRequest.TopK != nil {
openAIRequest.TopK = common.GetPointer(*claudeRequest.TopK)
}
if claudeRequest.Stream != nil {
openAIRequest.Stream = common.GetPointer(*claudeRequest.Stream)
}
isOpenRouter := relaymeta.RelayInfoChannelType(info) == constant.ChannelTypeOpenRouter
if isOpenRouter {
if effort := claudeRequest.GetEfforts(); effort != "" {
effortBytes, _ := common.Marshal(effort)
openAIRequest.Verbosity = effortBytes
}
if claudeRequest.Thinking != nil {
var reasoningConfig openRouterRequestReasoning
if claudeRequest.Thinking.Type == "enabled" {
reasoningConfig = openRouterRequestReasoning{
Enabled: true,
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
}
} else if claudeRequest.Thinking.Type == "adaptive" {
reasoningConfig = openRouterRequestReasoning{
Enabled: true,
}
}
reasoningJSON, err := common.Marshal(reasoningConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
}
openAIRequest.Reasoning = reasoningJSON
}
} else if info != nil {
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
}
}
if len(claudeRequest.StopSequences) == 1 {
openAIRequest.Stop = claudeRequest.StopSequences[0]
} else if len(claudeRequest.StopSequences) > 1 {
openAIRequest.Stop = claudeRequest.StopSequences
}
tools, _ := common.Any2Type[[]dto.Tool](claudeRequest.Tools)
openAITools := make([]dto.ToolCallRequest, 0)
for _, claudeTool := range tools {
openAITool := dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: claudeTool.Name,
Description: claudeTool.Description,
Parameters: claudeTool.InputSchema,
},
}
openAITools = append(openAITools, openAITool)
}
openAIRequest.Tools = openAITools
openAIMessages := make([]dto.Message, 0)
if claudeRequest.System != nil {
if claudeRequest.IsStringSystem() && claudeRequest.GetStringSystem() != "" {
openAIMessage := dto.Message{
Role: "system",
}
openAIMessage.SetStringContent(claudeRequest.GetStringSystem())
openAIMessages = append(openAIMessages, openAIMessage)
} else {
systems := claudeRequest.ParseSystem()
if len(systems) > 0 {
openAIMessage := dto.Message{
Role: "system",
}
isOpenRouterClaude := isOpenRouter && strings.HasPrefix(relaymeta.RelayInfoUpstreamModelName(info), "anthropic/claude")
if isOpenRouterClaude {
systemMediaMessages := make([]dto.MediaContent, 0, len(systems))
for _, system := range systems {
message := dto.MediaContent{
Type: "text",
Text: system.GetText(),
CacheControl: system.CacheControl,
}
systemMediaMessages = append(systemMediaMessages, message)
}
openAIMessage.SetMediaContent(systemMediaMessages)
} else {
systemStr := ""
for _, system := range systems {
if system.Text != nil {
systemStr += *system.Text
}
}
openAIMessage.SetStringContent(systemStr)
}
openAIMessages = append(openAIMessages, openAIMessage)
}
}
}
for _, claudeMessage := range claudeRequest.Messages {
openAIMessage := dto.Message{
Role: claudeMessage.Role,
}
if claudeMessage.IsStringContent() {
openAIMessage.SetStringContent(claudeMessage.GetStringContent())
} else {
content, err := claudeMessage.ParseContent()
if err != nil {
return nil, err
}
var toolCalls []dto.ToolCallRequest
mediaMessages := make([]dto.MediaContent, 0, len(content))
for _, mediaMsg := range content {
switch mediaMsg.Type {
case "text", "input_text":
message := dto.MediaContent{
Type: "text",
Text: mediaMsg.GetText(),
CacheControl: mediaMsg.CacheControl,
}
mediaMessages = append(mediaMessages, message)
case "image":
imageData := fmt.Sprintf("data:%s;base64,%s", mediaMsg.Source.MediaType, mediaMsg.Source.Data)
mediaMessage := dto.MediaContent{
Type: "image_url",
ImageUrl: &dto.MessageImageUrl{Url: imageData},
}
mediaMessages = append(mediaMessages, mediaMessage)
case "tool_use":
toolCall := dto.ToolCallRequest{
ID: mediaMsg.Id,
Type: "function",
Function: dto.FunctionRequest{
Name: mediaMsg.Name,
Arguments: requestToJSONString(mediaMsg.Input),
},
}
toolCalls = append(toolCalls, toolCall)
case "tool_result":
toolName := mediaMsg.Name
if toolName == "" {
toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId)
}
oaiToolMessage := dto.Message{
Role: "tool",
Name: &toolName,
ToolCallId: mediaMsg.ToolUseId,
}
if mediaMsg.IsStringContent() {
oaiToolMessage.SetStringContent(mediaMsg.GetStringContent())
} else {
mediaContents := mediaMsg.ParseMediaContent()
encodedJSON, _ := common.Marshal(mediaContents)
oaiToolMessage.SetStringContent(string(encodedJSON))
}
openAIMessages = append(openAIMessages, oaiToolMessage)
}
}
if len(toolCalls) > 0 {
openAIMessage.SetToolCalls(toolCalls)
}
if len(mediaMessages) > 0 && len(toolCalls) == 0 {
openAIMessage.SetMediaContent(mediaMessages)
}
}
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 {
openAIMessages = append(openAIMessages, openAIMessage)
}
}
openAIRequest.Messages = openAIMessages
return &openAIRequest, nil
}
func requestToJSONString(v interface{}) string {
b, err := common.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}