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
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:
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)
|
||||
}
|
||||
Reference in new issue
Block a user