feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit bb7968b612
2381 files changed
+551494

No files matched your search

@@ -0,0 +1,58 @@
package claudemessages
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func claudeCitationsToChat(raw json.RawMessage, text string, textOffset int) ([]any, error) {
if len(raw) == 0 {
return nil, nil
}
var citations []map[string]any
if err := kitutil.Unmarshal(raw, &citations); err != nil {
return nil, fmt.Errorf("invalid Claude citations: %w", err)
}
annotations := make([]any, 0, len(citations))
for _, citation := range citations {
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
if url == "" {
continue
}
converted := map[string]any{
"url": url,
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
}
citedText := kitutil.Interface2String(citation["cited_text"])
if citedText != "" {
converted["cited_text"] = citedText
if before, _, ok := strings.Cut(text, citedText); ok {
startIndex := textOffset + utf8.RuneCountInString(before)
converted["start_index"] = startIndex
converted["end_index"] = startIndex + utf8.RuneCountInString(citedText)
}
}
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
converted["encrypted_index"] = encryptedIndex
}
if converted["title"] == "" {
delete(converted, "title")
}
annotations = append(annotations, map[string]any{
"type": "url_citation",
"url_citation": converted,
})
}
return annotations, nil
}
func marshalChatAnnotations(annotations []any) (json.RawMessage, error) {
if len(annotations) == 0 {
return nil, nil
}
return kitutil.Marshal(annotations)
}
@@ -0,0 +1,66 @@
package claudemessages
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMessageStartZeroOutputSidecarRemainsRefreshable(t *testing.T) {
t.Parallel()
info := &ClaudeResponseInfo{Usage: &dto.Usage{}}
ok := FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_start",
Message: &dto.ClaudeMediaMessage{
Id: "msg_1",
Model: "claude-test",
Usage: &dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 0,
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 0,
}),
},
},
}, nil, info)
require.True(t, ok)
ok = FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
OutputTokens: 42,
},
}, nil, info)
require.True(t, ok)
require.NotNil(t, info.Usage.BillingUsage)
require.NotNil(t, info.Usage.BillingUsage.ClaudeUsage)
assert.Equal(t, 42, info.Usage.BillingUsage.ClaudeUsage.OutputTokens)
}
func TestTerminalSidecarRemainsAuthoritativeAgainstFinalize(t *testing.T) {
t.Parallel()
info := &ClaudeResponseInfo{Usage: &dto.Usage{}}
ok := FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 7,
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 7,
}),
},
}, nil, info)
require.True(t, ok)
require.NotNil(t, info.Usage.BillingUsage)
require.NotNil(t, info.Usage.BillingUsage.ClaudeUsage)
info.Usage.CompletionTokens = 99
FinalizeClaudeStreamBillingUsage(info)
assert.Equal(t, 7, info.Usage.BillingUsage.ClaudeUsage.OutputTokens)
}
@@ -0,0 +1,239 @@
package claudemessages
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
const (
webSearchMaxUsesLow = 1
webSearchMaxUsesMedium = 5
webSearchMaxUsesHigh = 10
)
type openRouterRequestReasoning struct {
Enabled *bool `json:"enabled,omitempty"`
Effort string `json:"effort,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Exclude bool `json:"exclude,omitempty"`
}
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
openAIRequest := dto.GeneralOpenAIRequest{
Model: claudeRequest.Model,
Temperature: claudeRequest.Temperature,
}
if claudeRequest.MaxTokens != nil {
openAIRequest.MaxTokens = kitutil.GetPointer(*claudeRequest.MaxTokens)
}
if claudeRequest.TopP != nil {
openAIRequest.TopP = kitutil.GetPointer(*claudeRequest.TopP)
}
if claudeRequest.TopK != nil {
openAIRequest.TopK = kitutil.GetPointer(*claudeRequest.TopK)
}
if claudeRequest.Stream != nil {
openAIRequest.Stream = kitutil.GetPointer(*claudeRequest.Stream)
}
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
if err != nil {
return nil, reasoning.AsClientError(err)
}
isOpenRouter := convmeta.OptionsOf(info).OpenRouterDialect
if isOpenRouter {
if effort := claudeRequest.GetEfforts(); effort != "" {
effortBytes, _ := kitutil.Marshal(effort)
openAIRequest.Verbosity = effortBytes
}
if !reasoningIntent.IsEmpty() {
var reasoningConfig openRouterRequestReasoning
disabled := reasoningIntent.Mode == reasoning.ModeDisabled || reasoningIntent.Effort == reasoning.EffortNone
enabled := !disabled
reasoningConfig.Enabled = &enabled
if enabled && reasoningIntent.BudgetTokens != nil && reasoningIntent.Mode != reasoning.ModeAdaptive {
reasoningConfig = openRouterRequestReasoning{
Enabled: &enabled,
MaxTokens: *reasoningIntent.BudgetTokens,
}
} else if enabled {
reasoningConfig.Effort = string(reasoning.EffectiveEffort(reasoningIntent))
}
if reasoningIntent.IncludeThoughts != nil {
reasoningConfig.Exclude = !*reasoningIntent.IncludeThoughts
}
reasoningJSON, err := kitutil.Marshal(reasoningConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
}
openAIRequest.Reasoning = reasoningJSON
}
} else {
if err := reasoning.ApplyToOpenAIChat(&openAIRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if info != nil {
// Keep the outgoing -thinking suffix so a cascaded downstream
// new-api can recover reasoning intent from the model name. This
// is an emission-side policy, not converter-side suffix parsing.
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) &&
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
}
}
}
if info != nil && effectiveEffort != "" {
info.SetReasoningEffort(string(effectiveEffort))
}
if len(claudeRequest.StopSequences) == 1 {
openAIRequest.Stop = claudeRequest.StopSequences[0]
} else if len(claudeRequest.StopSequences) > 1 {
openAIRequest.Stop = claudeRequest.StopSequences
}
tools, _ := kitutil.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(convmeta.UpstreamModelName(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 {
var systemStr strings.Builder
for _, system := range systems {
if system.Text != nil {
systemStr.WriteString(*system.Text)
}
}
openAIMessage.SetStringContent(systemStr.String())
}
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, _ := kitutil.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 any) string {
b, err := kitutil.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
@@ -0,0 +1,564 @@
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
}
@@ -0,0 +1,194 @@
package claudemessages
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// ClaudeHostedStreamBridge keeps Anthropic server-executed tool blocks out of
// the Chat Completions pivot. Anthropic streams a hosted call's input after the
// content_block_start event, so the bridge owns those input_json_delta events
// until content_block_stop and only then starts the Responses output item.
type ClaudeHostedStreamBridge struct {
pending map[int]*claudeHostedStreamCall
}
type claudeHostedStreamCall struct {
blockType string
id string
name string
serverName string
caller []byte
startInput []byte
input strings.Builder
}
func NewClaudeHostedStreamBridge() *ClaudeHostedStreamBridge {
return &ClaudeHostedStreamBridge{pending: make(map[int]*claudeHostedStreamCall)}
}
// Convert consumes provider-hosted stream frames and reports whether the frame
// must be skipped by the ordinary Claude-to-Chat converter.
func (b *ClaudeHostedStreamBridge) Convert(response *dto.ClaudeResponse, state *oaichat.ChatToResponsesStreamState) ([]oaichat.ChatToResponsesStreamEvent, bool, error) {
if response == nil || state == nil {
return nil, false, nil
}
if b == nil {
return nil, false, fmt.Errorf("Claude hosted stream bridge is required")
}
if b.pending == nil {
b.pending = make(map[int]*claudeHostedStreamCall)
}
index := response.GetIndex()
switch response.Type {
case "content_block_start":
if response.ContentBlock == nil {
return nil, false, nil
}
block := response.ContentBlock
blockType := strings.TrimSpace(block.Type)
switch blockType {
case "server_tool_use", "mcp_tool_use":
if _, exists := b.pending[index]; exists {
return nil, true, fmt.Errorf("duplicate Claude hosted-tool content block index %d", index)
}
if blockType == "mcp_tool_use" && (strings.TrimSpace(block.Name) == "" || strings.TrimSpace(block.ServerName) == "") {
return nil, true, fmt.Errorf("Claude MCP tool use must include name and server_name")
}
if _, err := claudeHostedCallOutputType(blockType, block.Name); err != nil {
return nil, true, err
}
pending := &claudeHostedStreamCall{
blockType: blockType,
id: block.Id,
name: block.Name,
serverName: block.ServerName,
caller: append([]byte(nil), block.Caller...),
}
// Non-stream-shaped gateways occasionally include the complete input
// on the start frame. Preserve it as a fallback, while streamed deltas
// replace the placeholder at block completion.
if block.Input != nil {
input, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, true, fmt.Errorf("marshal Claude hosted-tool input: %w", err)
}
if string(input) != "{}" && string(input) != "null" {
pending.startInput = input
}
}
b.pending[index] = pending
return nil, true, nil
case "web_search_tool_result", "mcp_tool_result":
outputType, err := claudeHostedResultOutputType(blockType)
if err != nil {
return nil, true, err
}
var result []byte
if outputType != "web_search_call" {
result, err = kitutil.Marshal(block.Content)
if err != nil {
return nil, true, fmt.Errorf("marshal Claude hosted-tool result: %w", err)
}
}
events, err := state.CompleteHostedTool(oaichat.HostedToolStreamResult{
Type: outputType,
ID: block.ToolUseId,
Result: result,
ErrorCode: claudeHostedResultErrorCode(block.Content, block.ErrorCode),
IsError: block.IsError != nil && *block.IsError,
})
return events, true, err
default:
return nil, false, nil
}
case "content_block_delta":
pending := b.pending[index]
if pending == nil {
return nil, false, nil
}
if response.Delta != nil && response.Delta.Type == "input_json_delta" && response.Delta.PartialJson != nil {
pending.input.WriteString(*response.Delta.PartialJson)
}
return nil, true, nil
case "content_block_stop":
pending := b.pending[index]
if pending == nil {
return nil, false, nil
}
delete(b.pending, index)
action := []byte(pending.input.String())
if len(action) == 0 {
action = pending.startInput
}
if len(action) == 0 {
action = []byte("{}")
}
outputType, err := claudeHostedCallOutputType(pending.blockType, pending.name)
if err != nil {
return nil, true, err
}
events, err := state.StartHostedTool(oaichat.HostedToolStreamStart{
Type: outputType,
ID: pending.id,
Name: pending.name,
Action: action,
Caller: pending.caller,
ServerLabel: pending.serverName,
})
return events, true, err
default:
return nil, false, nil
}
}
func claudeHostedCallOutputType(blockType string, name string) (string, error) {
if blockType == "mcp_tool_use" {
return "mcp_call", nil
}
switch strings.TrimSpace(name) {
case "web_search":
return "web_search_call", nil
case "code_execution":
return "", fmt.Errorf("Claude code_execution has no valid OpenAI Responses mapping without a container_id")
case "web_fetch":
return "", fmt.Errorf("Claude web_fetch has no valid OpenAI Responses hosted-tool mapping")
default:
return "", fmt.Errorf("unknown Claude server tool %q cannot be represented as an OpenAI Responses hosted tool", name)
}
}
func claudeHostedResultOutputType(blockType string) (string, error) {
switch blockType {
case "web_search_tool_result":
return "web_search_call", nil
case "mcp_tool_result":
return "mcp_call", nil
case "code_execution_tool_result":
return "", fmt.Errorf("Claude code_execution result has no valid OpenAI Responses mapping without a container_id")
case "web_fetch_tool_result":
return "", fmt.Errorf("Claude web_fetch result has no valid OpenAI Responses hosted-tool mapping")
default:
return "", fmt.Errorf("unknown Claude hosted-tool result %q", blockType)
}
}
func claudeHostedResultErrorCode(content any, fallback string) string {
if strings.TrimSpace(fallback) != "" {
return strings.TrimSpace(fallback)
}
value, ok := content.(map[string]any)
if !ok {
return ""
}
contentType := strings.TrimSpace(kitutil.Interface2String(value["type"]))
if !strings.HasSuffix(contentType, "_error") {
return ""
}
return strings.TrimSpace(kitutil.Interface2String(value["error_code"]))
}
@@ -0,0 +1,326 @@
package claudemessages
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func ClaudeMessagesRequestToOpenAIResponses(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.OpenAIResponsesRequest, error) {
if strings.TrimSpace(claudeRequest.Model) == "" {
return nil, errors.New("model is required")
}
input, err := claudeMessagesToResponsesInput(claudeRequest.Messages)
if err != nil {
return nil, err
}
instructions, err := claudeSystemToResponsesInstructions(&claudeRequest)
if err != nil {
return nil, err
}
tools, err := claudeToolsToResponsesTools(claudeRequest.Tools)
if err != nil {
return nil, err
}
toolChoice, parallelToolCalls, err := claudeToolChoiceToResponses(claudeRequest.ToolChoice)
if err != nil {
return nil, err
}
// Claude context_management is an object containing protocol-specific edit
// strategies. Responses expects an array of compaction entries, so copying
// the raw Claude value would produce an invalid upstream request.
responsesRequest := &dto.OpenAIResponsesRequest{
Model: claudeRequest.Model,
Input: input,
Instructions: instructions,
Metadata: append(json.RawMessage(nil), claudeRequest.Metadata...),
ServiceTier: claudeRequest.ServiceTier,
Stream: claudeRequest.Stream,
Temperature: claudeRequest.Temperature,
Tools: tools,
ToolChoice: toolChoice,
ParallelToolCalls: parallelToolCalls,
TopP: claudeRequest.TopP,
}
if info != nil && !convmeta.OptionsOf(info).OpenRouterDialect {
// Keep the outgoing -thinking suffix so a cascaded downstream new-api
// can recover reasoning intent from the model name. This is an
// emission-side policy, not converter-side suffix parsing.
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) && !strings.HasSuffix(responsesRequest.Model, thinkingSuffix) {
responsesRequest.Model += thinkingSuffix
}
}
if claudeRequest.MaxTokens != nil {
maxOutputTokens := *claudeRequest.MaxTokens
responsesRequest.MaxOutputTokens = &maxOutputTokens
} else if claudeRequest.MaxTokensToSample != nil {
maxOutputTokens := *claudeRequest.MaxTokensToSample
responsesRequest.MaxOutputTokens = &maxOutputTokens
}
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := reasoning.ApplyToOpenAIResponses(responsesRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if info != nil && effectiveEffort != "" {
info.SetReasoningEffort(string(effectiveEffort))
}
return responsesRequest, nil
}
func claudeRequestReasoningIntent(claudeRequest *dto.ClaudeRequest, info convmeta.Meta) (reasoning.Intent, reasoning.Effort, error) {
reasoningIntent, err := reasoning.FromClaude(claudeRequest)
if err != nil {
return reasoning.Intent{}, "", err
}
sourceModel := claudeRequest.Model
if info != nil && info.GetOriginModelName() != "" {
sourceModel = info.GetOriginModelName()
}
if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModel)
if err != nil {
return reasoning.Intent{}, "", err
}
}
reasoningIntent = reasoning.ResolveClaudeDefault(sourceModel, reasoningIntent)
return reasoningIntent, reasoning.EffectiveEffort(reasoningIntent), nil
}
func claudeSystemToResponsesInstructions(request *dto.ClaudeRequest) (json.RawMessage, error) {
if request == nil || request.System == nil {
return nil, nil
}
if request.IsStringSystem() {
return kitutil.Marshal(request.GetStringSystem())
}
var instructions strings.Builder
systemBlocks, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](request.System)
if err != nil {
return nil, fmt.Errorf("invalid Claude system content: %w", err)
}
for _, block := range systemBlocks {
if block.Type == "text" || block.Type == "input_text" || block.Type == "" {
instructions.WriteString(block.GetText())
}
}
if instructions.Len() == 0 {
return nil, nil
}
return kitutil.Marshal(instructions.String())
}
func claudeMessagesToResponsesInput(messages []dto.ClaudeMessage) (json.RawMessage, error) {
input := make([]map[string]any, 0, len(messages))
for messageIndex := range messages {
message := messages[messageIndex]
role := strings.TrimSpace(message.Role)
if role == "" {
continue
}
if message.IsStringContent() {
input = append(input, map[string]any{
"role": role,
"content": message.GetStringContent(),
})
continue
}
blocks, err := message.ParseContent()
if err != nil {
return nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
contentParts := make([]map[string]any, 0, len(blocks))
flushContent := func() {
if len(contentParts) == 0 {
return
}
input = append(input, map[string]any{
"role": role,
"content": contentParts,
})
contentParts = nil
}
for blockIndex := range blocks {
block := blocks[blockIndex]
switch block.Type {
case "text", "input_text":
partType := "input_text"
if role == "assistant" {
partType = "output_text"
}
contentParts = append(contentParts, map[string]any{
"type": partType,
"text": block.GetText(),
})
case "image":
if source := claudeSourceURL(block.Source); source != "" {
contentParts = append(contentParts, map[string]any{
"type": "input_image",
"image_url": source,
})
}
case "document":
if source := claudeSourceURL(block.Source); source != "" {
contentParts = append(contentParts, map[string]any{
"type": "input_file",
"file_data": source,
})
}
case "tool_use":
flushContent()
arguments, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, fmt.Errorf("messages[%d].content[%d].input: %w", messageIndex, blockIndex, err)
}
if block.Input == nil {
arguments = []byte("{}")
}
input = append(input, map[string]any{
"type": "function_call",
"call_id": block.Id,
"name": block.Name,
"arguments": string(arguments),
})
case "tool_result":
flushContent()
output, err := claudeToolResultToResponsesOutput(block.Content)
if err != nil {
return nil, fmt.Errorf("messages[%d].content[%d].content: %w", messageIndex, blockIndex, err)
}
input = append(input, map[string]any{
"type": "function_call_output",
"call_id": block.ToolUseId,
"output": output,
})
}
}
flushContent()
}
return kitutil.Marshal(input)
}
func claudeToolsToResponsesTools(value any) (json.RawMessage, error) {
if value == nil {
return nil, nil
}
tools, err := kitutil.Any2Type[[]dto.Tool](value)
if err != nil {
return nil, fmt.Errorf("invalid Claude tools: %w", err)
}
converted := make([]map[string]any, 0, len(tools))
for _, tool := range tools {
function := map[string]any{
"type": "function",
"name": tool.Name,
"description": tool.Description,
"parameters": tool.InputSchema,
}
if tool.Strict != nil {
function["strict"] = *tool.Strict
}
converted = append(converted, function)
}
return kitutil.Marshal(converted)
}
func claudeToolChoiceToResponses(value any) (json.RawMessage, json.RawMessage, error) {
if value == nil {
return nil, nil, nil
}
choice, err := kitutil.Any2Type[dto.ClaudeToolChoice](value)
if err != nil {
return nil, nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
var converted any
switch choice.Type {
case "", "auto":
converted = "auto"
case "any":
converted = "required"
case "none":
converted = "none"
case "tool":
converted = map[string]any{"type": "function", "name": choice.Name}
default:
return nil, nil, fmt.Errorf("unsupported Claude tool_choice type %q", choice.Type)
}
toolChoice, err := kitutil.Marshal(converted)
if err != nil {
return nil, nil, err
}
var parallelToolCalls json.RawMessage
if choice.DisableParallelToolUse && choice.Type != "none" {
parallelToolCalls, err = kitutil.Marshal(false)
if err != nil {
return nil, nil, err
}
}
return toolChoice, parallelToolCalls, nil
}
func claudeToolResultToResponsesOutput(content any) (any, error) {
if content == nil {
return "", nil
}
if text, ok := content.(string); ok {
return text, nil
}
blocks, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](content)
if err != nil {
return content, nil
}
parts := make([]map[string]any, 0, len(blocks))
for _, block := range blocks {
switch block.Type {
case "text", "input_text":
parts = append(parts, map[string]any{"type": "input_text", "text": block.GetText()})
case "image":
if source := claudeSourceURL(block.Source); source != "" {
parts = append(parts, map[string]any{"type": "input_image", "image_url": source})
}
case "document":
if source := claudeSourceURL(block.Source); source != "" {
parts = append(parts, map[string]any{"type": "input_file", "file_data": source})
}
}
}
if len(parts) == 0 {
return content, nil
}
return parts, nil
}
func claudeSourceURL(source *dto.ClaudeMessageSource) string {
if source == nil {
return ""
}
if strings.TrimSpace(source.Url) != "" {
return source.Url
}
data := kitutil.Interface2String(source.Data)
if data == "" {
return ""
}
if strings.HasPrefix(data, "data:") {
return data
}
return fmt.Sprintf("data:%s;base64,%s", source.MediaType, data)
}