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,81 @@
package oaichat
import (
"encoding/json"
"strings"
"unicode/utf8"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func chatAnnotationsToClaude(raw json.RawMessage, text string) []json.RawMessage {
if len(raw) == 0 {
return nil
}
var annotations []map[string]any
if err := kitutil.Unmarshal(raw, &annotations); err != nil {
return nil
}
citations := make([]json.RawMessage, 0, len(annotations))
for _, annotation := range annotations {
if strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
continue
}
citation, ok := annotation["url_citation"].(map[string]any)
if !ok {
citation = annotation
}
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
if url == "" {
continue
}
converted := map[string]any{
"type": "web_search_result_location",
"url": url,
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
}
if citedText := kitutil.Interface2String(citation["cited_text"]); citedText != "" {
converted["cited_text"] = citedText
} else if citedText := citedTextFromAnnotation(text, citation); citedText != "" {
converted["cited_text"] = citedText
}
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
converted["encrypted_index"] = encryptedIndex
}
if converted["title"] == "" {
delete(converted, "title")
}
encoded, err := kitutil.Marshal(converted)
if err == nil {
citations = append(citations, encoded)
}
}
return citations
}
func citedTextFromAnnotation(text string, citation map[string]any) string {
start, startOK := annotationIndex(citation["start_index"])
end, endOK := annotationIndex(citation["end_index"])
if !startOK || !endOK || start < 0 || end <= start {
return ""
}
if end > utf8.RuneCountInString(text) {
return ""
}
runes := []rune(text)
return string(runes[start:end])
}
func annotationIndex(value any) (int, bool) {
switch number := value.(type) {
case float64:
return int(number), number >= 0 && number == float64(int(number))
case int:
return number, number >= 0
case json.Number:
parsed, err := number.Int64()
return int(parsed), err == nil && parsed >= 0
default:
return 0, false
}
}
@@ -0,0 +1,324 @@
package oaichat
import (
"fmt"
"strings"
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
opts := convmeta.OptionsOf(info)
claudeTools := make([]any, 0, len(textRequest.Tools))
for _, tool := range textRequest.Tools {
if _, ok := tool.Function.Parameters.(map[string]any); !ok && tool.Type != "function" {
continue
}
claudeTools = append(claudeTools, &dto.Tool{
Name: tool.Function.Name,
Description: tool.Function.Description,
InputSchema: sharedclaude.FunctionParametersToInputSchema(tool.Function.Parameters),
})
}
if textRequest.WebSearchOptions != nil {
webSearchTool := dto.ClaudeWebSearchTool{
Type: "web_search_20250305",
Name: "web_search",
}
if textRequest.WebSearchOptions.UserLocation != nil {
anthropicUserLocation := &dto.ClaudeWebSearchUserLocation{
Type: "approximate",
}
var userLocationMap map[string]any
if err := kitutil.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
if approximateData, ok := userLocationMap["approximate"].(map[string]any); ok {
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
anthropicUserLocation.Timezone = timezone
}
if country, ok := approximateData["country"].(string); ok && country != "" {
anthropicUserLocation.Country = country
}
if region, ok := approximateData["region"].(string); ok && region != "" {
anthropicUserLocation.Region = region
}
if city, ok := approximateData["city"].(string); ok && city != "" {
anthropicUserLocation.City = city
}
}
}
webSearchTool.UserLocation = anthropicUserLocation
}
claudeTools = append(claudeTools, &webSearchTool)
}
claudeRequest := dto.ClaudeRequest{
Model: textRequest.Model,
StopSequences: nil,
Temperature: textRequest.Temperature,
}
if len(claudeTools) > 0 {
claudeRequest.Tools = claudeTools
}
if textRequest.MaxCompletionTokens != nil && *textRequest.MaxCompletionTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*textRequest.MaxCompletionTokens)
} else if textRequest.MaxTokens != nil && *textRequest.MaxTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*textRequest.MaxTokens)
}
if textRequest.TopP != nil {
claudeRequest.TopP = kitutil.GetPointer(*textRequest.TopP)
}
if textRequest.TopK != nil {
claudeRequest.TopK = kitutil.GetPointer(*textRequest.TopK)
}
if textRequest.IsStream(nil) {
claudeRequest.Stream = kitutil.GetPointer(true)
}
if textRequest.ToolChoice != nil || textRequest.ParallelTooCalls != nil {
claudeToolChoice := sharedclaude.MapOpenAIToolChoice(textRequest.ToolChoice, textRequest.ParallelTooCalls)
if claudeToolChoice != nil {
claudeRequest.ToolChoice = claudeToolChoice
}
}
sourceReasoning, err := reasoning.FromOpenAIChat(&textRequest)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := sharedclaude.ApplyReasoning(c, &claudeRequest, info, sourceReasoning, true); err != nil {
return nil, reasoning.AsClientError(err)
}
if claudeRequest.MaxTokens == nil {
if defaultMaxTokens, configured := opts.Claude.DefaultMaxTokensFor(claudeRequest.Model); configured {
value := uint(defaultMaxTokens)
claudeRequest.MaxTokens = &value
}
}
if textRequest.Stop != nil {
switch stop := textRequest.Stop.(type) {
case string:
claudeRequest.StopSequences = []string{stop}
case []any:
stopSequences := make([]string, 0)
for _, item := range stop {
stopSequences = append(stopSequences, item.(string))
}
claudeRequest.StopSequences = stopSequences
}
}
formatMessages := make([]dto.Message, 0)
lastMessage := dto.Message{
Role: "tool",
}
for _, message := range textRequest.Messages {
switch message.Role {
case "":
message.Role = "user"
case "developer":
message.Role = "system"
case "function":
if message.ToolCallId != "" {
message.Role = "tool"
} else {
message.Role = "user"
}
case "tool":
if message.ToolCallId == "" {
message.Role = "user"
}
case "system", "user", "assistant":
default:
message.Role = "user"
}
fmtMessage := dto.Message{
Role: message.Role,
Content: message.Content,
}
if message.Role == "tool" {
fmtMessage.ToolCallId = message.ToolCallId
}
if message.Role == "assistant" && message.ToolCalls != nil {
fmtMessage.ToolCalls = message.ToolCalls
}
if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
if lastMessage.IsStringContent() && message.IsStringContent() {
fmtMessage.SetStringContent(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()))
formatMessages = formatMessages[:len(formatMessages)-1]
}
}
if fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") {
fmtMessage.SetStringContent("...")
}
formatMessages = append(formatMessages, fmtMessage)
lastMessage = fmtMessage
}
claudeMessages := make([]dto.ClaudeMessage, 0)
isFirstMessage := true
var systemMessages []dto.ClaudeMediaMessage
placeholderUserMessage := dto.ClaudeMessage{
Role: "user",
Content: []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer[string]("..."),
},
},
}
for _, message := range formatMessages {
if message.Role == "system" {
if message.IsStringContent() {
if text := message.StringContent(); text != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](text),
})
}
} else {
for _, ctx := range message.ParseContent() {
if ctx.Type == "text" && ctx.Text != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](ctx.Text),
})
}
}
}
continue
}
if isFirstMessage {
isFirstMessage = false
if message.Role != "user" {
claudeMessages = append(claudeMessages, placeholderUserMessage)
}
}
claudeMessage := dto.ClaudeMessage{
Role: message.Role,
}
if message.Role == "tool" {
if len(claudeMessages) > 0 && claudeMessages[len(claudeMessages)-1].Role == "user" {
lastClaudeMessage := claudeMessages[len(claudeMessages)-1]
if content, ok := lastClaudeMessage.Content.(string); ok {
lastClaudeMessage.Content = []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer[string](content),
},
}
}
lastClaudeMessage.Content = append(lastClaudeMessage.Content.([]dto.ClaudeMediaMessage), dto.ClaudeMediaMessage{
Type: "tool_result",
ToolUseId: message.ToolCallId,
Content: message.Content,
})
claudeMessages[len(claudeMessages)-1] = lastClaudeMessage
continue
}
claudeMessage.Role = "user"
claudeMessage.Content = []dto.ClaudeMediaMessage{
{
Type: "tool_result",
ToolUseId: message.ToolCallId,
Content: message.Content,
},
}
} else if message.IsStringContent() && message.ToolCalls == nil {
text := message.StringContent()
if text == "" {
text = "..."
}
claudeMessage.Content = text
} else {
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
for _, mediaMessage := range message.ParseContent() {
switch mediaMessage.Type {
case "text":
if mediaMessage.Text != "" {
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](mediaMessage.Text),
})
}
default:
source := mediaMessage.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
}
claudeMediaMessage := dto.ClaudeMediaMessage{
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
}
if strings.HasPrefix(mimeType, "application/pdf") {
claudeMediaMessage.Type = "document"
} else {
claudeMediaMessage.Type = "image"
}
claudeMediaMessage.Source.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
continue
}
}
if message.ToolCalls != nil {
for _, toolCall := range message.ParseToolCalls() {
inputObj := make(map[string]any)
if args := toolCall.Function.Arguments; args != "" {
if err := kitutil.Unmarshal([]byte(args), &inputObj); err != nil {
kitutil.LogInfo("tool call function arguments is not a map[string]any: " + fmt.Sprintf("%v", toolCall.Function.Arguments))
}
}
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "tool_use",
Id: toolCall.ID,
Name: toolCall.Function.Name,
Input: inputObj,
})
}
}
claudeMessage.Content = claudeMediaMessages
}
claudeMessages = append(claudeMessages, claudeMessage)
}
if len(claudeMessages) == 0 && len(systemMessages) > 0 {
claudeMessages = append(claudeMessages, placeholderUserMessage)
}
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
}
claudeRequest.Prompt = ""
claudeRequest.Messages = claudeMessages
// Checked last so every injection path (default hook, thinking adapter
// floor) has had its chance to satisfy the required field.
if claudeRequest.MaxTokens == nil {
return nil, sharedclaude.ErrMissingMaxTokens
}
return &claudeRequest, nil
}
@@ -0,0 +1,81 @@
package oaichat
import (
"context"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOpenAIChatRequestToClaudeMessagesNormalizesToolInputSchema(t *testing.T) {
tests := []struct {
name string
parameters any
wantSchema map[string]any
}{
{
name: "omitted parameters",
parameters: nil,
wantSchema: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
{
name: "missing type and properties",
parameters: map[string]any{
"additionalProperties": false,
},
wantSchema: map[string]any{
"type": "object",
"properties": map[string]any{},
"additionalProperties": false,
},
},
{
name: "non-string type",
parameters: map[string]any{
"type": 123,
"properties": map[string]any{},
},
wantSchema: map[string]any{
"type": 123,
"properties": map[string]any{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
maxTokens := uint(1024)
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), nil, dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: &maxTokens,
Messages: []dto.Message{
{Role: "user", Content: "Call the tool."},
},
Tools: []dto.ToolCallRequest{
{
Type: "function",
Function: dto.FunctionRequest{
Name: "get_current_time",
Description: "Get the current time",
Parameters: tt.parameters,
},
},
},
})
require.NoError(t, err)
tools, ok := got.Tools.([]any)
require.True(t, ok)
require.Len(t, tools, 1)
tool, ok := tools[0].(*dto.Tool)
require.True(t, ok)
assert.Equal(t, "get_current_time", tool.Name)
assert.Equal(t, tt.wantSchema, tool.InputSchema)
})
}
}
@@ -0,0 +1,503 @@
package oaichat
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func generateStopBlock(index int) *dto.ClaudeResponse {
return &dto.ClaudeResponse{
Type: "content_block_stop",
Index: kitutil.GetPointer[int](index),
}
}
func stopOpenBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
if state == nil {
return nil
}
switch state.LastMessagesType {
case convmeta.LastMessageTypeText, convmeta.LastMessageTypeThinking:
return []*dto.ClaudeResponse{generateStopBlock(state.Index)}
case convmeta.LastMessageTypeTools:
if len(state.ToolCalls) == 0 {
responses := make([]*dto.ClaudeResponse, 0, state.ToolCallMaxIndexOffset+1)
for offset := 0; offset <= state.ToolCallMaxIndexOffset; offset++ {
responses = append(responses, generateStopBlock(state.ToolCallBaseIndex+offset))
}
return responses
}
responses := make([]*dto.ClaudeResponse, 0, len(state.ToolCalls))
for _, tool := range state.ToolCalls {
if tool != nil && tool.Started {
responses = append(responses, generateStopBlock(tool.BlockIndex))
}
}
return responses
default:
return nil
}
}
func startPendingToolBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
if state == nil || state.LastMessagesType != convmeta.LastMessageTypeTools {
return nil
}
responses := make([]*dto.ClaudeResponse, 0)
for _, tool := range state.ToolCalls {
if tool == nil || tool.Started || tool.Name == "" {
continue
}
if tool.ID == "" {
tool.ID = fmt.Sprintf("toolu_%s", kitutil.GetUUID())
}
idx := tool.BlockIndex
responses = append(responses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: tool.ID,
Type: "tool_use",
Name: tool.Name,
Input: map[string]any{},
},
})
tool.Started = true
if tool.PendingArguments != "" {
arguments := tool.PendingArguments
responses = append(responses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &arguments,
},
})
tool.PendingArguments = ""
}
}
return responses
}
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
return sharedclaude.UsageFromOpenAI(oaiUsage)
}
func clientVisibleClaudeStreamUsage(state *convmeta.ClaudeConvertInfo, incoming *dto.Usage) *dto.ClaudeUsage {
prior := buildClaudeUsageFromOpenAIUsage(state.Usage)
converted := buildClaudeUsageFromOpenAIUsage(incoming)
if incoming != nil {
state.Usage = dto.MergeUsageNonZero(state.Usage, incoming)
}
if prior == nil {
return converted
}
if converted == nil {
return prior
}
return dto.MergeClaudeUsageNonZero(prior, converted)
}
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
return sharedclaude.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) []*dto.ClaudeResponse {
if info == nil {
info = &convmeta.Values{}
}
state := info.EnsureClaudeConvertInfo()
if state.Done {
return nil
}
var claudeResponses []*dto.ClaudeResponse
// stopOpenBlocks emits the required content_block_stop event(s) for the currently open block(s)
// according to Anthropic's SSE streaming state machine:
// content_block_start -> content_block_delta* -> content_block_stop (per index).
//
// For text/thinking, there is at most one open block at state.Index.
// For tools, OpenAI tool_calls can stream multiple parallel tool_use blocks (indexed from 0),
// so we may have multiple open blocks and must stop each one explicitly.
appendStopOpenBlocks := func() {
claudeResponses = append(claudeResponses, startPendingToolBlocks(state)...)
claudeResponses = append(claudeResponses, stopOpenBlocks(state)...)
}
// stopOpenBlocksAndAdvance closes the currently open block(s) and advances the content block index
// to the next available slot for subsequent content_block_start events.
//
// This prevents invalid streams where a content_block_delta (e.g. thinking_delta) is emitted for an
// index whose active content_block type is different (the typical cause of "Mismatched content block type").
stopOpenBlocksAndAdvance := func() {
if state.LastMessagesType == convmeta.LastMessageTypeNone {
return
}
appendStopOpenBlocks()
switch state.LastMessagesType {
case convmeta.LastMessageTypeTools:
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls)
state.ToolCallBaseIndex = 0
state.ToolCallMaxIndexOffset = 0
state.ToolCalls = nil
state.ToolCallByIndex = nil
state.ToolCallByID = nil
default:
state.Index++
}
state.LastMessagesType = convmeta.LastMessageTypeNone
}
appendCitationDeltas := func(raw []byte) {
citations := chatAnnotationsToClaude(raw, "")
if len(citations) == 0 {
return
}
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
state.LastMessagesType = convmeta.LastMessageTypeText
}
for _, citation := range citations {
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "citations_delta",
Citation: citation,
},
})
}
}
if info.GetSendResponseCount() == 1 {
// Client-visible Claude stream usage matches billing merge: first
// frame records first, a later non-zero field overrides, and a later
// zero/missing field never erases a first-frame positive. Anthropic
// clients treat message_delta as authoritative for the fields it
// carries, including a corrected input_tokens.
startUsage := &dto.ClaudeUsage{
InputTokens: info.GetEstimatePromptTokens(),
OutputTokens: 0,
}
if openAIResponse.Usage != nil && dto.HasOpenAIUsageTokens(openAIResponse.Usage) {
if real := buildClaudeUsageFromOpenAIUsage(openAIResponse.Usage); real != nil {
startUsage = real
}
state.Usage = dto.MergeUsageNonZero(state.Usage, openAIResponse.Usage)
}
msg := &dto.ClaudeMediaMessage{
Id: openAIResponse.Id,
Model: openAIResponse.Model,
Type: "message",
Role: "assistant",
Usage: startUsage,
}
msg.SetContent(make([]any, 0))
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_start",
Message: msg,
})
}
if len(openAIResponse.Choices) == 0 {
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
if oaiUsage != nil {
appendStopOpenBlocks()
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: oaiUsage,
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReason),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
state.Done = true
}
return claudeResponses
} else {
chosenChoice := openAIResponse.Choices[0]
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
if doneChunk {
state.FinishReason = *chosenChoice.FinishReason
}
var claudeResponse dto.ClaudeResponse
var isEmpty bool
claudeResponse.Type = "content_block_delta"
if len(chosenChoice.Delta.ToolCalls) > 0 {
toolCalls := chosenChoice.Delta.ToolCalls
if state.LastMessagesType != convmeta.LastMessageTypeTools {
stopOpenBlocksAndAdvance()
state.ToolCallBaseIndex = state.Index
state.ToolCallMaxIndexOffset = 0
state.ToolCalls = nil
state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
}
state.LastMessagesType = convmeta.LastMessageTypeTools
if state.ToolCallByIndex == nil {
state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
}
if state.ToolCallByID == nil {
state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
}
for i, toolCall := range toolCalls {
toolIndex := i
if toolCall.Index != nil {
toolIndex = *toolCall.Index
}
incomingID := strings.TrimSpace(toolCall.ID)
var tool *convmeta.ClaudeStreamToolCall
if incomingID != "" {
tool = state.ToolCallByID[incomingID]
}
if tool == nil {
tool = state.ToolCallByIndex[toolIndex]
}
if tool != nil && incomingID != "" && tool.ID != "" && tool.ID != incomingID {
tool = nil
}
if tool == nil {
tool = &convmeta.ClaudeStreamToolCall{
BlockIndex: state.ToolCallBaseIndex + len(state.ToolCalls),
}
state.ToolCalls = append(state.ToolCalls, tool)
}
state.ToolCallByIndex[toolIndex] = tool
if tool.ID == "" && incomingID != "" {
tool.ID = incomingID
state.ToolCallByID[incomingID] = tool
}
if tool.Name == "" && strings.TrimSpace(toolCall.Function.Name) != "" {
tool.Name = strings.TrimSpace(toolCall.Function.Name)
}
if !tool.Started {
tool.PendingArguments += toolCall.Function.Arguments
}
idx := tool.BlockIndex
if !tool.Started && tool.ID != "" && tool.Name != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: tool.ID,
Type: "tool_use",
Name: tool.Name,
Input: map[string]any{},
},
})
tool.Started = true
if tool.PendingArguments != "" {
arguments := tool.PendingArguments
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &arguments,
},
})
tool.PendingArguments = ""
}
continue
}
if tool.Started && toolCall.Function.Arguments != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &toolCall.Function.Arguments,
},
})
}
}
state.ToolCallMaxIndexOffset = len(state.ToolCalls) - 1
if len(state.ToolCalls) > 0 {
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls) - 1
}
} else {
reasoning := chosenChoice.Delta.GetReasoningContent()
textContent := chosenChoice.Delta.GetContentString()
if reasoning != "" || textContent != "" {
if reasoning != "" {
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeThinking
claudeResponse.Delta = &dto.ClaudeMediaMessage{
Type: "thinking_delta",
Thinking: &reasoning,
}
} else {
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeText
claudeResponse.Delta = &dto.ClaudeMediaMessage{
Type: "text_delta",
Text: kitutil.GetPointer[string](textContent),
}
}
} else {
isEmpty = true
}
}
claudeResponse.Index = kitutil.GetPointer[int](state.Index)
if !isEmpty && claudeResponse.Delta != nil {
claudeResponses = append(claudeResponses, &claudeResponse)
}
appendCitationDeltas(chosenChoice.Delta.Annotations)
if doneChunk || state.Done {
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
if oaiUsage == nil {
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Keep content blocks open until usage is available so the terminal message_delta
// can carry both usage and the final stop reason.
return claudeResponses
}
appendStopOpenBlocks()
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: oaiUsage,
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
state.Done = true
return claudeResponses
}
}
return claudeResponses
}
func FinalizeStreamResponseOpenAI2Claude(info convmeta.Meta) []*dto.ClaudeResponse {
if info == nil {
info = &convmeta.Values{}
}
state := info.EnsureClaudeConvertInfo()
if state.Done {
return nil
}
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
responses := startPendingToolBlocks(state)
responses = append(responses, stopOpenBlocks(state)...)
responses = append(responses,
&dto.ClaudeResponse{
Type: "message_delta",
Usage: clientVisibleClaudeStreamUsage(state, nil),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReason),
},
},
&dto.ClaudeResponse{Type: "message_stop"},
)
state.Done = true
return responses
}
func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.ClaudeResponse {
var stopReason string
contents := make([]dto.ClaudeMediaMessage, 0)
claudeResponse := &dto.ClaudeResponse{
Id: openAIResponse.Id,
Type: "message",
Role: "assistant",
Model: openAIResponse.Model,
}
for _, choice := range openAIResponse.Choices {
stopReason = stopReasonOpenAI2Claude(choice.FinishReason)
reasoningContent := choice.Message.GetReasoningContent()
textContent := choice.Message.StringContent()
toolCalls := choice.Message.ParseToolCalls()
if reasoningContent != "" {
claudeContent := dto.ClaudeMediaMessage{Type: "thinking"}
claudeContent.Thinking = kitutil.GetPointer(reasoningContent)
contents = append(contents, claudeContent)
}
if textContent != "" || (reasoningContent == "" && len(toolCalls) == 0) {
claudeContent := dto.ClaudeMediaMessage{}
claudeContent.Type = "text"
claudeContent.SetText(textContent)
citations := chatAnnotationsToClaude(choice.Message.Annotations, textContent)
if len(citations) > 0 {
claudeContent.Citations, _ = kitutil.Marshal(citations)
}
contents = append(contents, claudeContent)
}
for _, toolUse := range toolCalls {
claudeContent := dto.ClaudeMediaMessage{}
claudeContent.Type = "tool_use"
claudeContent.Id = toolUse.ID
claudeContent.Name = toolUse.Function.Name
mapParams := map[string]any{}
if strings.TrimSpace(toolUse.Function.Arguments) != "" {
var parsed map[string]any
if err := kitutil.Unmarshal([]byte(toolUse.Function.Arguments), &parsed); err == nil && parsed != nil {
mapParams = parsed
}
}
claudeContent.Input = mapParams
contents = append(contents, claudeContent)
}
}
claudeResponse.Content = contents
claudeResponse.StopReason = stopReason
claudeResponse.Usage = buildClaudeUsageFromOpenAIUsage(&openAIResponse.Usage)
return claudeResponse
}
func stopReasonOpenAI2Claude(reason string) string {
return reasonmap.OpenAIFinishReasonToClaudeStopReason(reason)
}
@@ -0,0 +1,424 @@
package oaichat
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponseOpenAI2ClaudeToolUseInputIsObject(t *testing.T) {
tests := []struct {
name string
args string
want map[string]any
}{
{name: "object", args: `{"q":"x"}`, want: map[string]any{"q": "x"}},
{name: "empty", args: "", want: map[string]any{}},
{name: "invalid", args: "{", want: map[string]any{}},
{name: "null", args: "null", want: map[string]any{}},
{name: "array", args: `["x"]`, want: map[string]any{}},
{name: "string", args: `"x"`, want: map[string]any{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := dto.Message{Role: "assistant"}
msg.SetToolCalls([]dto.ToolCallRequest{
{
ID: "call_1",
Type: "function",
Function: dto.FunctionRequest{
Name: "lookup",
Arguments: tt.args,
},
},
})
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: msg, FinishReason: "tool_calls"},
},
}, nil)
require.Len(t, resp.Content, 1)
assert.Equal(t, "tool_use", resp.Content[0].Type)
assert.Equal(t, tt.want, resp.Content[0].Input)
})
}
}
func TestResponseOpenAI2ClaudeUsageCarriesOpenAIBillingUsage(t *testing.T) {
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: dto.Message{Role: "assistant", Content: "hello"}, FinishReason: "stop"},
},
Usage: dto.Usage{
PromptTokens: 11,
CompletionTokens: 5,
TotalTokens: 16,
},
}, nil)
require.NotNil(t, resp.Usage)
assert.Equal(t, 11, resp.Usage.InputTokens)
assert.Equal(t, 5, resp.Usage.OutputTokens)
require.NotNil(t, resp.Usage.BillingUsage)
require.NotNil(t, resp.Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSourceOAIChat, resp.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticOpenAI, resp.Usage.BillingUsage.Semantic)
assert.Equal(t, 11, resp.Usage.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 5, resp.Usage.BillingUsage.OpenAIUsage.CompletionTokens)
assert.Equal(t, 16, resp.Usage.BillingUsage.OpenAIUsage.TotalTokens)
assert.Nil(t, resp.Usage.BillingUsage.OpenAIUsage.BillingUsage)
}
func TestResponseOpenAI2ClaudePreservesReasoningBeforeText(t *testing.T) {
message := dto.Message{Role: "assistant", Content: "final answer"}
message.ReasoningContent = ptr("considering the request")
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: message, FinishReason: "stop"},
},
}, nil)
require.Len(t, resp.Content, 2)
assert.Equal(t, "thinking", resp.Content[0].Type)
require.NotNil(t, resp.Content[0].Thinking)
assert.Equal(t, "considering the request", *resp.Content[0].Thinking)
assert.Equal(t, "text", resp.Content[1].Type)
assert.Equal(t, "final answer", resp.Content[1].GetText())
}
func TestBuildClaudeUsageFromOpenAICacheWriteUsage(t *testing.T) {
usage := buildClaudeUsageFromOpenAIUsage(&dto.Usage{
PromptTokens: 3619,
CompletionTokens: 36,
TotalTokens: 3655,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 2921,
CacheWriteTokens: 3616,
},
})
require.NotNil(t, usage)
// Claude semantics reports input_tokens excluding cache read/write; the
// overlapping unadjusted prefixes drive the remainder negative, clamp to 0.
assert.Equal(t, 0, usage.InputTokens)
assert.Equal(t, 2921, usage.CacheReadInputTokens)
assert.Equal(t, 3616, usage.CacheCreationInputTokens)
assert.Equal(t, 36, usage.OutputTokens)
require.NotNil(t, usage.BillingUsage)
require.NotNil(t, usage.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSemanticOpenAI, usage.BillingUsage.Semantic)
assert.Equal(t, 3616, usage.BillingUsage.OpenAIUsage.PromptTokensDetails.CacheWriteTokens)
}
func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T) {
info := &convmeta.Values{
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{
LastMessagesType: convmeta.LastMessageTypeNone,
},
}
info.SendResponseCount = 1
textResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
Content: ptr("hello"),
},
},
},
}, info)
require.Len(t, textResponses, 3)
assert.Equal(t, "message_start", textResponses[0].Type)
assert.Equal(t, "content_block_start", textResponses[1].Type)
assert.Equal(t, 0, textResponses[1].GetIndex())
assert.Equal(t, "content_block_delta", textResponses[2].Type)
info.SendResponseCount = 2
thinkingResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ReasoningContent: ptr("thinking"),
},
},
},
}, info)
require.Len(t, thinkingResponses, 3)
assert.Equal(t, "content_block_stop", thinkingResponses[0].Type)
assert.Equal(t, 0, thinkingResponses[0].GetIndex())
assert.Equal(t, "content_block_start", thinkingResponses[1].Type)
assert.Equal(t, 1, thinkingResponses[1].GetIndex())
assert.Equal(t, "thinking", thinkingResponses[1].ContentBlock.Type)
assert.Equal(t, "content_block_delta", thinkingResponses[2].Type)
info.SendResponseCount = 3
toolResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ToolCalls: []dto.ToolCallResponse{
{
Index: ptr(0),
ID: "call_1",
Type: "function",
Function: dto.FunctionResponse{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
},
},
},
},
}, info)
require.Len(t, toolResponses, 3)
assert.Equal(t, "content_block_stop", toolResponses[0].Type)
assert.Equal(t, 1, toolResponses[0].GetIndex())
assert.Equal(t, "content_block_start", toolResponses[1].Type)
assert.Equal(t, 2, toolResponses[1].GetIndex())
assert.Equal(t, "tool_use", toolResponses[1].ContentBlock.Type)
assert.Equal(t, "content_block_delta", toolResponses[2].Type)
info.SendResponseCount = 4
finishResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{FinishReason: ptr("tool_calls")},
},
Usage: &dto.Usage{
PromptTokens: 7,
CompletionTokens: 3,
TotalTokens: 10,
},
}, info)
require.Len(t, finishResponses, 3)
assert.Equal(t, "content_block_stop", finishResponses[0].Type)
assert.Equal(t, 2, finishResponses[0].GetIndex())
assert.Equal(t, "message_delta", finishResponses[1].Type)
assert.Equal(t, "tool_use", *finishResponses[1].Delta.StopReason)
require.NotNil(t, finishResponses[1].Usage)
require.NotNil(t, finishResponses[1].Usage.BillingUsage)
require.NotNil(t, finishResponses[1].Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, 7, finishResponses[1].Usage.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 3, finishResponses[1].Usage.BillingUsage.OpenAIUsage.CompletionTokens)
assert.Equal(t, "message_stop", finishResponses[2].Type)
}
func TestStreamResponseOpenAI2ClaudeFirstFrameUsesUpstreamUsageWhenPresent(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 32,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
responses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 0, TotalTokens: 29},
}, info)
require.NotEmpty(t, responses)
require.Equal(t, "message_start", responses[0].Type)
require.NotNil(t, responses[0].Message)
require.NotNil(t, responses[0].Message.Usage)
assert.Equal(t, 29, responses[0].Message.Usage.InputTokens)
}
func TestStreamResponseOpenAI2ClaudeMessageDeltaCorrectsEstimatedFirstFrame(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 32,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
}, info)
require.NotEmpty(t, first)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 32, first[0].Message.Usage.InputTokens)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 29, delta.Usage.InputTokens)
assert.Equal(t, 4, delta.Usage.OutputTokens)
}
func TestStreamResponseOpenAI2ClaudeMessageDeltaDoesNotZeroFirstFrameCache(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 8,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: &dto.Usage{
PromptTokens: 40,
CompletionTokens: 0,
TotalTokens: 40,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 20,
CachedCreationTokens: 10,
},
},
}, info)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 20, first[0].Message.Usage.CacheReadInputTokens)
assert.Equal(t, 10, first[0].Message.Usage.CacheCreationInputTokens)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 29, delta.Usage.InputTokens)
assert.Equal(t, 20, delta.Usage.CacheReadInputTokens)
assert.Equal(t, 10, delta.Usage.CacheCreationInputTokens)
}
func TestStreamResponseOpenAI2ClaudeGeminiBillingUsageOnStartAndDelta(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 4994,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
firstUsage := &dto.Usage{
PromptTokens: 3868,
CompletionTokens: 0,
TotalTokens: 3868,
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
PromptTokenCount: 3868,
TotalTokenCount: 3868,
}),
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: firstUsage,
}, info)
require.NotEmpty(t, first)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 3868, first[0].Message.Usage.InputTokens)
require.NotNil(t, first[0].Message.Usage.BillingUsage)
assert.Equal(t, dto.BillingUsageSourceGeminiChat, first[0].Message.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticGemini, first[0].Message.Usage.BillingUsage.Semantic)
require.NotNil(t, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, 3868, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{
PromptTokens: 3868,
CompletionTokens: 12,
TotalTokens: 3880,
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
PromptTokenCount: 3868,
CandidatesTokenCount: 12,
TotalTokenCount: 3880,
}),
},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 3868, delta.Usage.InputTokens)
assert.Equal(t, 12, delta.Usage.OutputTokens)
require.NotNil(t, delta.Usage.BillingUsage)
assert.Equal(t, dto.BillingUsageSourceGeminiChat, delta.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticGemini, delta.Usage.BillingUsage.Semantic)
require.NotNil(t, delta.Usage.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, 3868, delta.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
assert.Equal(t, 12, delta.Usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
}
func TestNormalizeCacheCreationSplit(t *testing.T) {
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
assert.Equal(t, 8, cache5m)
assert.Equal(t, 2, cache1h)
cache5m, cache1h = NormalizeCacheCreationSplit(3, 5, 1)
assert.Equal(t, 5, cache5m)
assert.Equal(t, 1, cache1h)
}
func ptr[T any](value T) *T {
return &value
}
@@ -0,0 +1,379 @@
package oaichat
import (
"errors"
"fmt"
"math"
"strings"
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto.GeneralOpenAIRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
opts := convmeta.OptionsOf(info)
geminiRequest := dto.GeminiChatRequest{
Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)),
GenerationConfig: dto.GeminiChatGenerationConfig{
Temperature: textRequest.Temperature,
},
}
if textRequest.TopP != nil {
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*textRequest.TopP)
}
if textRequest.MaxCompletionTokens != nil {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*textRequest.MaxCompletionTokens)
} else if textRequest.MaxTokens != nil {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*textRequest.MaxTokens)
}
if textRequest.Seed != nil {
geminiRequest.GenerationConfig.Seed = kitutil.GetPointer(int64(*textRequest.Seed))
}
upstreamModelName := textRequest.Model
if modelName := convmeta.UpstreamModelName(info); modelName != "" {
upstreamModelName = modelName
}
if opts.Gemini.SupportsImagineModel(upstreamModelName) {
geminiRequest.GenerationConfig.ResponseModalities = []string{
"TEXT",
"IMAGE",
}
}
if stopSequences := sharedgemini.ParseStopSequences(textRequest.Stop); len(stopSequences) > 0 {
if len(stopSequences) > 5 {
stopSequences = stopSequences[:5]
}
geminiRequest.GenerationConfig.StopSequences = stopSequences
}
if len(textRequest.ExtraBody) > 0 {
var extraBody map[string]any
if err := kitutil.Unmarshal(textRequest.ExtraBody, &extraBody); err != nil {
return nil, fmt.Errorf("invalid extra body: %w", err)
}
if googleBody, ok := extraBody["google"].(map[string]any); ok {
if _, hasErrorParam := googleBody["thinkingConfig"]; hasErrorParam {
return nil, errors.New("extra_body.google.thinkingConfig is not supported, use extra_body.google.thinking_config instead")
}
if thinkingConfig, ok := googleBody["thinking_config"].(map[string]any); ok {
if _, hasErrorParam := thinkingConfig["thinkingBudget"]; hasErrorParam {
return nil, errors.New("extra_body.google.thinking_config.thinkingBudget is not supported, use extra_body.google.thinking_config.thinking_budget instead")
}
var hasThinkingConfig bool
var tempThinkingConfig dto.GeminiThinkingConfig
if thinkingBudget, exists := thinkingConfig["thinking_budget"]; exists {
v, ok := thinkingBudget.(float64)
maxInt := int(^uint(0) >> 1)
if !ok || math.IsNaN(v) || math.IsInf(v, 0) || math.Trunc(v) != v || v > float64(maxInt) || v < float64(-maxInt-1) {
return nil, errors.New("extra_body.google.thinking_config.thinking_budget must be an integer")
}
budgetInt := int(v)
tempThinkingConfig.ThinkingBudget = kitutil.GetPointer(budgetInt)
hasThinkingConfig = true
}
if includeThoughts, exists := thinkingConfig["include_thoughts"]; exists {
if v, ok := includeThoughts.(bool); ok {
tempThinkingConfig.IncludeThoughts = kitutil.GetPointer(v)
hasThinkingConfig = true
} else {
return nil, errors.New("extra_body.google.thinking_config.include_thoughts must be a boolean")
}
}
if thinkingLevel, exists := thinkingConfig["thinking_level"]; exists {
if v, ok := thinkingLevel.(string); ok {
tempThinkingConfig.ThinkingLevel = v
hasThinkingConfig = true
} else {
return nil, errors.New("extra_body.google.thinking_config.thinking_level must be a string")
}
}
if hasThinkingConfig {
geminiRequest.GenerationConfig.ThinkingConfig = &tempThinkingConfig
}
}
if _, hasErrorParam := googleBody["imageConfig"]; hasErrorParam {
return nil, errors.New("extra_body.google.imageConfig is not supported, use extra_body.google.image_config instead")
}
if imageConfig, ok := googleBody["image_config"].(map[string]any); ok {
if _, hasErrorParam := imageConfig["aspectRatio"]; hasErrorParam {
return nil, errors.New("extra_body.google.image_config.aspectRatio is not supported, use extra_body.google.image_config.aspect_ratio instead")
}
if _, hasErrorParam := imageConfig["imageSize"]; hasErrorParam {
return nil, errors.New("extra_body.google.image_config.imageSize is not supported, use extra_body.google.image_config.image_size instead")
}
geminiImageConfig := make(map[string]any)
if aspectRatio, ok := imageConfig["aspect_ratio"]; ok {
geminiImageConfig["aspectRatio"] = aspectRatio
}
if imageSize, ok := imageConfig["image_size"]; ok {
geminiImageConfig["imageSize"] = imageSize
}
if len(geminiImageConfig) > 0 {
imageConfigBytes, err := kitutil.Marshal(geminiImageConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal image_config: %w", err)
}
geminiRequest.GenerationConfig.ImageConfig = imageConfigBytes
}
}
}
}
if err := sharedgemini.ApplyThinkingConfig(&geminiRequest, info, textRequest); err != nil {
return nil, reasoning.AsClientError(err)
}
var safetySettings []dto.GeminiChatSafetySettings
for _, category := range sharedgemini.SafetySettingCategories {
threshold := opts.Gemini.SafetySettingFor(category)
if threshold == "" {
continue
}
safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
Category: category,
Threshold: threshold,
})
}
if len(safetySettings) > 0 {
geminiRequest.SafetySettings = safetySettings
}
if textRequest.Tools != nil {
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
for _, tool := range textRequest.Tools {
if tool.Function.Parameters != nil {
if params, ok := tool.Function.Parameters.(map[string]any); ok {
if props, hasProps := params["properties"].(map[string]any); hasProps && len(props) == 0 {
tool.Function.Parameters = nil
}
}
}
tool.Function.Parameters = sharedgemini.CleanFunctionParameters(tool.Function.Parameters)
functions = append(functions, tool.Function)
}
geminiTools := geminiRequest.GetTools()
if len(functions) > 0 {
geminiTools = append(geminiTools, dto.GeminiChatTool{
FunctionDeclarations: functions,
})
}
geminiRequest.SetTools(geminiTools)
if textRequest.ToolChoice != nil {
geminiRequest.ToolConfig = sharedgemini.OpenAIToolChoiceToConfig(textRequest.ToolChoice)
}
}
if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") {
geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
if len(textRequest.ResponseFormat.JsonSchema) > 0 {
var jsonSchema dto.FormatJsonSchema
if err := kitutil.Unmarshal(textRequest.ResponseFormat.JsonSchema, &jsonSchema); err == nil {
cleanedSchema := sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema
}
}
}
toolCallIDs := make(map[string]string)
var systemContent []string
for _, message := range textRequest.Messages {
if message.Role == "system" || message.Role == "developer" {
systemContent = append(systemContent, message.StringContent())
continue
}
if message.Role == "tool" || message.Role == "function" {
if len(geminiRequest.Contents) == 0 || geminiRequest.Contents[len(geminiRequest.Contents)-1].Role == "model" {
geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{
Role: "user",
})
}
parts := &geminiRequest.Contents[len(geminiRequest.Contents)-1].Parts
name := ""
if message.Name != nil {
name = *message.Name
} else if val, exists := toolCallIDs[message.ToolCallId]; exists {
name = val
}
var contentMap map[string]any
contentStr := message.StringContent()
if err := kitutil.Unmarshal([]byte(contentStr), &contentMap); err != nil {
var contentSlice []any
if err := kitutil.Unmarshal([]byte(contentStr), &contentSlice); err == nil {
contentMap = map[string]any{"result": contentSlice}
} else {
contentMap = map[string]any{"content": contentStr}
}
}
functionResp := &dto.GeminiFunctionResponse{
Name: name,
Response: contentMap,
}
if message.ToolCallId != "" {
id, err := kitutil.Marshal(message.ToolCallId)
if err != nil {
return nil, fmt.Errorf("failed to marshal function response ID: %w", err)
}
functionResp.ID = id
}
*parts = append(*parts, dto.GeminiPart{
FunctionResponse: functionResp,
})
continue
}
var parts []dto.GeminiPart
content := dto.GeminiChatContent{
Role: message.Role,
}
shouldAttachThoughtSignature := (message.Role == "assistant" || message.Role == "model") && sharedgemini.ShouldAttachThoughtSignature(opts)
signatureAttached := false
if message.ToolCalls != nil {
for _, call := range message.ParseToolCalls() {
args := map[string]any{}
if call.Function.Arguments != "" {
if kitutil.Unmarshal([]byte(call.Function.Arguments), &args) != nil {
return nil, fmt.Errorf("invalid arguments for function %s, args: %s", call.Function.Name, call.Function.Arguments)
}
}
toolCall := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: call.ID,
FunctionName: call.Function.Name,
Arguments: args,
},
}
if shouldAttachThoughtSignature && !signatureAttached && sharedgemini.AttachFunctionCallThoughtSignature(opts, &toolCall) {
signatureAttached = true
}
parts = append(parts, toolCall)
toolCallIDs[call.ID] = call.Function.Name
}
}
openaiContent := message.ParseContent()
for _, part := range openaiContent {
if part.Type == dto.ContentTypeText {
if part.Text == "" {
continue
}
text := part.Text
hasMarkdownImage := false
for {
startIdx := strings.Index(text, "![")
if startIdx == -1 {
break
}
bracketIdx := strings.Index(text[startIdx:], "](data:")
if bracketIdx == -1 {
break
}
bracketIdx += startIdx
closeIdx := strings.Index(text[bracketIdx+2:], ")")
if closeIdx == -1 {
break
}
closeIdx += bracketIdx + 2
hasMarkdownImage = true
if startIdx > 0 {
textBefore := text[:startIdx]
if textBefore != "" {
parts = append(parts, dto.GeminiPart{
Text: textBefore,
})
}
}
dataURL := text[bracketIdx+2 : closeIdx]
format, base64String, err := relaymedia.DecodeBase64FileData(dataURL)
if err != nil {
return nil, fmt.Errorf("decode markdown base64 image data failed: %s", err.Error())
}
imgPart := dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: format,
Data: base64String,
},
}
if shouldAttachThoughtSignature {
sharedgemini.AttachThoughtSignatureBypass(opts, &imgPart)
}
parts = append(parts, imgPart)
text = text[closeIdx+1:]
}
if !hasMarkdownImage {
parts = append(parts, dto.GeminiPart{
Text: part.Text,
})
}
} else {
source := part.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image for Gemini")
if err != nil {
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
}
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
}
}
if shouldAttachThoughtSignature && !signatureAttached && len(parts) > 0 {
sharedgemini.AttachFirstTextThoughtSignature(opts, parts)
}
content.Parts = parts
if content.Role == "assistant" {
content.Role = "model"
}
if len(content.Parts) > 0 {
geminiRequest.Contents = append(geminiRequest.Contents, content)
}
}
if len(systemContent) > 0 {
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
Parts: []dto.GeminiPart{
{
Text: strings.Join(systemContent, "\n"),
},
},
}
}
return &geminiRequest, nil
}
@@ -0,0 +1,558 @@
package oaichat
import (
"fmt"
"sort"
"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"
)
type ChatToGeminiStreamState struct {
toolsByChoice map[int][]*chatToGeminiStreamTool
toolByIndex map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool
toolByID map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool
finishedChoices map[int]bool
seenChoices map[int]bool
usage *dto.Usage
usageEmitted bool
finalized bool
}
type chatToGeminiStreamToolKey struct {
ChoiceIndex int
ToolIndex int
}
type chatToGeminiStreamToolIDKey struct {
ChoiceIndex int
ID string
}
type chatToGeminiStreamTool struct {
ID string
Name string
Arguments strings.Builder
Emitted bool
}
func NewChatToGeminiStreamState() *ChatToGeminiStreamState {
return &ChatToGeminiStreamState{
toolsByChoice: make(map[int][]*chatToGeminiStreamTool),
toolByIndex: make(map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool),
toolByID: make(map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool),
finishedChoices: make(map[int]bool),
seenChoices: make(map[int]bool),
}
}
// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.GeminiChatResponse {
totalTokens := openAIResponse.TotalTokens
if totalTokens == 0 {
totalTokens = openAIResponse.PromptTokens + openAIResponse.CompletionTokens
}
geminiResponse := &dto.GeminiChatResponse{
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
HasUsageMetadata: true,
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: openAIResponse.PromptTokens,
CandidatesTokenCount: openAIResponse.CompletionTokens,
TotalTokenCount: totalTokens,
BillingUsage: openAIBillingUsageFromUsage(&openAIResponse.Usage),
},
}
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(&openAIResponse.Usage); ok {
geminiResponse.UsageMetadata = metadata
}
for _, choice := range openAIResponse.Choices {
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
}
// 设置结束原因
var finishReason string
switch choice.FinishReason {
case "stop":
finishReason = "STOP"
case "length":
finishReason = "MAX_TOKENS"
case "content_filter":
finishReason = "SAFETY"
case "tool_calls":
finishReason = "STOP"
default:
finishReason = "STOP"
}
candidate.FinishReason = &finishReason
// 转换消息内容
content := dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
}
textContent := choice.Message.StringContent()
if textContent != "" {
part := dto.GeminiPart{
Text: textContent,
}
content.Parts = append(content.Parts, part)
}
toolCalls := choice.Message.ParseToolCalls()
for _, toolCall := range toolCalls {
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: toolCall.ID,
FunctionName: toolCall.Function.Name,
Arguments: geminiFunctionArguments(toolCall.Function.Arguments),
},
}
content.Parts = append(content.Parts, part)
}
candidate.Content = content
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
}
return geminiResponse
}
// StreamResponseOpenAI2Gemini 将 OpenAI 流式响应转换为 Gemini 格式
func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) *dto.GeminiChatResponse {
// 检查是否有实际内容或结束标志
hasContent := false
hasFinishReason := false
for _, choice := range openAIResponse.Choices {
if len(choice.Delta.GetContentString()) > 0 || (choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0) {
hasContent = true
}
if choice.FinishReason != nil {
hasFinishReason = true
}
}
// 如果没有实际内容且没有结束标志,跳过。主要针对 openai 流响应开头的空数据
if !hasContent && !hasFinishReason {
return nil
}
estimatePromptTokens := 0
if info != nil {
estimatePromptTokens = info.GetEstimatePromptTokens()
}
geminiResponse := &dto.GeminiChatResponse{
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
HasUsageMetadata: true,
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: estimatePromptTokens,
CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息
TotalTokenCount: estimatePromptTokens,
},
}
if openAIResponse.Usage != nil {
geminiResponse.UsageMetadata.PromptTokenCount = openAIResponse.Usage.PromptTokens
geminiResponse.UsageMetadata.CandidatesTokenCount = openAIResponse.Usage.CompletionTokens
geminiResponse.UsageMetadata.TotalTokenCount = openAIResponse.Usage.TotalTokens
geminiResponse.UsageMetadata.BillingUsage = openAIBillingUsageFromUsage(openAIResponse.Usage)
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(openAIResponse.Usage); ok {
geminiResponse.UsageMetadata = metadata
}
}
for _, choice := range openAIResponse.Choices {
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
}
// 设置结束原因
if choice.FinishReason != nil {
var finishReason string
switch *choice.FinishReason {
case "stop":
finishReason = "STOP"
case "length":
finishReason = "MAX_TOKENS"
case "content_filter":
finishReason = "SAFETY"
case "tool_calls":
finishReason = "STOP"
default:
finishReason = "STOP"
}
candidate.FinishReason = &finishReason
}
// 转换消息内容
content := dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
}
// 处理工具调用
if choice.Delta.ToolCalls != nil {
for _, toolCall := range choice.Delta.ToolCalls {
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: toolCall.ID,
FunctionName: toolCall.Function.Name,
Arguments: geminiFunctionArguments(toolCall.Function.Arguments),
},
}
content.Parts = append(content.Parts, part)
}
} else {
// 处理文本内容
textContent := choice.Delta.GetContentString()
if textContent != "" {
part := dto.GeminiPart{
Text: textContent,
}
content.Parts = append(content.Parts, part)
}
}
candidate.Content = content
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
}
return geminiResponse
}
// ConvertChunk accumulates OpenAI tool-call deltas until their choice ends.
// Gemini functionCall parts are atomic, so emitting each OpenAI arguments
// fragment as a separate part would create duplicate calls with invalid input.
func (s *ChatToGeminiStreamState) ConvertChunk(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) ([]*dto.GeminiChatResponse, error) {
if openAIResponse == nil {
return nil, nil
}
if s == nil {
return nil, fmt.Errorf("OpenAI chat to Gemini stream state is required")
}
if s.finalized {
return nil, fmt.Errorf("OpenAI chat to Gemini stream received data after finalization")
}
if s.toolsByChoice == nil {
s.toolsByChoice = make(map[int][]*chatToGeminiStreamTool)
}
if s.toolByIndex == nil {
s.toolByIndex = make(map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool)
}
if s.toolByID == nil {
s.toolByID = make(map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool)
}
if s.finishedChoices == nil {
s.finishedChoices = make(map[int]bool)
}
if s.seenChoices == nil {
s.seenChoices = make(map[int]bool)
}
if openAIResponse.Usage != nil {
s.usage = UsageFromChatUsage(openAIResponse.Usage)
}
candidates := make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices))
for _, choice := range openAIResponse.Choices {
s.seenChoices[choice.Index] = true
hasText := choice.Delta.GetContentString() != ""
hasToolDelta := len(choice.Delta.ToolCalls) > 0
hasFinish := choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != ""
if s.finishedChoices[choice.Index] {
if hasText || hasToolDelta {
return nil, fmt.Errorf("OpenAI chat choice %d received data after completion", choice.Index)
}
continue
}
for position, toolCall := range choice.Delta.ToolCalls {
if toolCall.Index == nil {
toolCall.SetIndex(position)
}
if err := s.appendToolCallDelta(choice.Index, toolCall); err != nil {
return nil, err
}
}
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
Content: dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
},
}
if hasText {
candidate.Content.Parts = append(candidate.Content.Parts, dto.GeminiPart{Text: choice.Delta.GetContentString()})
}
if hasFinish {
parts, err := s.finishChoice(choice.Index)
if err != nil {
return nil, err
}
candidate.Content.Parts = append(candidate.Content.Parts, parts...)
finishReason := geminiFinishReason(*choice.FinishReason)
candidate.FinishReason = &finishReason
s.finishedChoices[choice.Index] = true
}
if len(candidate.Content.Parts) > 0 || candidate.FinishReason != nil {
candidates = append(candidates, candidate)
}
}
if len(candidates) == 0 {
if openAIResponse.Usage != nil && len(s.finishedChoices) > 0 {
s.usageEmitted = true
return []*dto.GeminiChatResponse{newGeminiStreamResponse(nil, s.usage, info)}, nil
}
return nil, nil
}
if openAIResponse.Usage != nil {
s.usageEmitted = true
}
return []*dto.GeminiChatResponse{newGeminiStreamResponse(candidates, openAIResponse.Usage, info)}, nil
}
// Finalize emits any calls left pending when an upstream closes without a
// finish-reason chunk. Calling Finalize more than once is safe.
func (s *ChatToGeminiStreamState) Finalize(info convmeta.Meta) ([]*dto.GeminiChatResponse, error) {
if s == nil || s.finalized {
return nil, nil
}
choiceIndexes := make(map[int]struct{})
for choiceIndex, tools := range s.toolsByChoice {
for _, tool := range tools {
if !tool.Emitted {
choiceIndexes[choiceIndex] = struct{}{}
break
}
}
}
for choiceIndex := range s.seenChoices {
if !s.finishedChoices[choiceIndex] {
choiceIndexes[choiceIndex] = struct{}{}
}
}
orderedChoices := make([]int, 0, len(choiceIndexes))
for choiceIndex := range choiceIndexes {
orderedChoices = append(orderedChoices, choiceIndex)
}
sort.Ints(orderedChoices)
candidates := make([]dto.GeminiChatCandidate, 0, len(orderedChoices))
for _, choiceIndex := range orderedChoices {
parts, err := s.finishChoice(choiceIndex)
if err != nil {
return nil, err
}
finishReason := "STOP"
candidates = append(candidates, dto.GeminiChatCandidate{
Index: int64(choiceIndex),
FinishReason: &finishReason,
SafetyRatings: []dto.GeminiChatSafetyRating{},
Content: dto.GeminiChatContent{
Role: "model",
Parts: parts,
},
})
}
if len(candidates) == 0 {
s.finalized = true
if s.usage == nil || s.usageEmitted {
return nil, nil
}
s.usageEmitted = true
return []*dto.GeminiChatResponse{newGeminiStreamResponse(nil, s.usage, info)}, nil
}
s.finalized = true
s.usageEmitted = s.usage != nil
return []*dto.GeminiChatResponse{newGeminiStreamResponse(candidates, s.usage, info)}, nil
}
func (s *ChatToGeminiStreamState) Usage() *dto.Usage {
if s == nil || s.usage == nil {
return nil
}
return UsageFromChatUsage(s.usage)
}
func (s *ChatToGeminiStreamState) SetUsage(usage *dto.Usage) {
if s == nil || usage == nil {
return
}
s.usage = UsageFromChatUsage(usage)
}
func (s *ChatToGeminiStreamState) StreamUsage() *dto.Usage {
return s.Usage()
}
func (s *ChatToGeminiStreamState) SetStreamUsage(usage *dto.Usage) {
s.SetUsage(usage)
}
func (s *ChatToGeminiStreamState) appendToolCallDelta(choiceIndex int, toolCall dto.ToolCallResponse) error {
toolIndex := 0
if toolCall.Index != nil {
toolIndex = *toolCall.Index
}
if toolIndex < 0 {
return fmt.Errorf("OpenAI chat choice %d has negative tool-call index %d", choiceIndex, toolIndex)
}
key := chatToGeminiStreamToolKey{ChoiceIndex: choiceIndex, ToolIndex: toolIndex}
incomingID := strings.TrimSpace(toolCall.ID)
var tool *chatToGeminiStreamTool
if incomingID != "" {
tool = s.toolByID[chatToGeminiStreamToolIDKey{ChoiceIndex: choiceIndex, ID: incomingID}]
}
if tool == nil {
tool = s.toolByIndex[key]
}
if tool != nil && incomingID != "" && tool.ID != "" && tool.ID != incomingID {
tool = nil
}
if tool == nil {
tool = &chatToGeminiStreamTool{}
s.toolsByChoice[choiceIndex] = append(s.toolsByChoice[choiceIndex], tool)
}
s.toolByIndex[key] = tool
// Compatibility gateways may reset a source index for the next occurrence.
// Once identity changes, keep the new occurrence active for later metadata-free deltas.
if tool.Emitted {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d received data after completion", choiceIndex, toolIndex)
}
if incomingID != "" {
if tool.ID != "" && tool.ID != incomingID {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d changed id from %q to %q", choiceIndex, toolIndex, tool.ID, incomingID)
}
tool.ID = incomingID
s.toolByID[chatToGeminiStreamToolIDKey{ChoiceIndex: choiceIndex, ID: incomingID}] = tool
}
incomingName := strings.TrimSpace(toolCall.Function.Name)
if incomingName != "" {
if tool.Name != "" && tool.Name != incomingName {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d changed name from %q to %q", choiceIndex, toolIndex, tool.Name, incomingName)
}
tool.Name = incomingName
}
tool.Arguments.WriteString(toolCall.Function.Arguments)
return nil
}
func (s *ChatToGeminiStreamState) finishChoice(choiceIndex int) ([]dto.GeminiPart, error) {
tools := s.toolsByChoice[choiceIndex]
pending := make([]*chatToGeminiStreamTool, 0, len(tools))
for _, tool := range tools {
if !tool.Emitted {
pending = append(pending, tool)
}
}
parts := make([]dto.GeminiPart, 0, len(pending))
for _, tool := range pending {
if tool.Name == "" {
return nil, fmt.Errorf("OpenAI chat choice %d has a tool call without a function name", choiceIndex)
}
parts = append(parts, dto.GeminiPart{FunctionCall: &dto.FunctionCall{
ID: tool.ID,
FunctionName: tool.Name,
Arguments: geminiFunctionArguments(tool.Arguments.String()),
}})
}
for _, tool := range pending {
tool.Emitted = true
}
return parts, nil
}
func newGeminiStreamResponse(candidates []dto.GeminiChatCandidate, usage *dto.Usage, info convmeta.Meta) *dto.GeminiChatResponse {
if candidates == nil {
candidates = make([]dto.GeminiChatCandidate, 0)
}
estimatePromptTokens := 0
if info != nil {
estimatePromptTokens = info.GetEstimatePromptTokens()
}
response := &dto.GeminiChatResponse{
Candidates: candidates,
HasUsageMetadata: true,
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: estimatePromptTokens,
TotalTokenCount: estimatePromptTokens,
},
}
if usage == nil {
return response
}
response.UsageMetadata.PromptTokenCount = usage.PromptTokens
response.UsageMetadata.CandidatesTokenCount = usage.CompletionTokens
response.UsageMetadata.TotalTokenCount = usage.TotalTokens
response.UsageMetadata.BillingUsage = openAIBillingUsageFromUsage(usage)
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(usage); ok {
response.UsageMetadata = metadata
}
return response
}
func geminiFunctionArguments(raw string) map[string]any {
if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "null" {
return map[string]any{}
}
var args map[string]any
if err := kitutil.Unmarshal([]byte(raw), &args); err == nil && args != nil {
return args
}
// Preserve historically accepted malformed/non-object input without
// emitting a non-object Gemini args value.
return map[string]any{"arguments": raw}
}
func geminiFinishReason(finishReason string) string {
switch strings.TrimSpace(finishReason) {
case "length":
return "MAX_TOKENS"
case "content_filter":
return "SAFETY"
default:
return "STOP"
}
}
func geminiBillingMetadataFromOpenAIUsage(usage *dto.Usage) (dto.GeminiUsageMetadata, bool) {
if usage == nil || usage.BillingUsage == nil || usage.BillingUsage.GeminiUsageMetadata == nil {
return dto.GeminiUsageMetadata{}, false
}
if usage.BillingUsage.Source != dto.BillingUsageSourceGeminiChat && usage.BillingUsage.Semantic != dto.BillingUsageSemanticGemini {
return dto.GeminiUsageMetadata{}, false
}
billingUsage := dto.CloneBillingUsage(usage.BillingUsage)
if billingUsage == nil || billingUsage.GeminiUsageMetadata == nil {
return dto.GeminiUsageMetadata{}, false
}
metadata := *billingUsage.GeminiUsageMetadata
// Restore the sidecar marker on the restored native payload so the next
// hop keeps settling on the original dialect (including Estimated).
metadata.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
return metadata, true
}
func openAIBillingUsageFromUsage(usage *dto.Usage) *dto.BillingUsage {
if usage == nil {
return nil
}
// An existing sidecar snapshots the original provider usage; carry it
// across this bridge unchanged regardless of its dialect. Only synthesize
// an OpenAI snapshot when no sidecar exists yet.
if existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage); existingBillingUsage != nil {
return existingBillingUsage
}
return dto.NewOpenAIChatBillingUsage(usage)
}
@@ -0,0 +1,112 @@
package oaichat
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponseOpenAI2GeminiMapsTextToolFinishReasonAndUsage(t *testing.T) {
msg := dto.Message{
Role: "assistant",
Content: "hello",
}
msg.SetToolCalls([]dto.ToolCallRequest{
{
ID: "call_1",
Type: "function",
Function: dto.FunctionRequest{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
})
resp := ResponseOpenAI2Gemini(&dto.OpenAITextResponse{
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{
Index: 2,
Message: msg,
FinishReason: "length",
},
},
Usage: dto.Usage{
PromptTokens: 11,
CompletionTokens: 5,
TotalTokens: 16,
},
}, nil)
assert.Equal(t, 11, resp.UsageMetadata.PromptTokenCount)
assert.Equal(t, 5, resp.UsageMetadata.CandidatesTokenCount)
assert.Equal(t, 16, resp.UsageMetadata.TotalTokenCount)
require.NotNil(t, resp.UsageMetadata.BillingUsage)
require.NotNil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSourceOAIChat, resp.UsageMetadata.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticOpenAI, resp.UsageMetadata.BillingUsage.Semantic)
assert.Equal(t, 11, resp.UsageMetadata.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 5, resp.UsageMetadata.BillingUsage.OpenAIUsage.CompletionTokens)
assert.Equal(t, 16, resp.UsageMetadata.BillingUsage.OpenAIUsage.TotalTokens)
assert.Nil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage.BillingUsage)
require.Len(t, resp.Candidates, 1)
assert.Equal(t, int64(2), resp.Candidates[0].Index)
require.NotNil(t, resp.Candidates[0].FinishReason)
assert.Equal(t, "MAX_TOKENS", *resp.Candidates[0].FinishReason)
require.Len(t, resp.Candidates[0].Content.Parts, 2)
assert.Equal(t, "hello", resp.Candidates[0].Content.Parts[0].Text)
require.NotNil(t, resp.Candidates[0].Content.Parts[1].FunctionCall)
assert.Equal(t, "lookup", resp.Candidates[0].Content.Parts[1].FunctionCall.FunctionName)
assert.Equal(t, map[string]any{"q": "x"}, resp.Candidates[0].Content.Parts[1].FunctionCall.Arguments)
}
func TestStreamResponseOpenAI2GeminiMapsToolCallFinishReasonAndUsage(t *testing.T) {
resp := StreamResponseOpenAI2Gemini(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Index: 1,
FinishReason: geminiRespPtr("tool_calls"),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ToolCalls: []dto.ToolCallResponse{
{
Type: "function",
Function: dto.FunctionResponse{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
},
},
},
},
Usage: &dto.Usage{
PromptTokens: 13,
CompletionTokens: 8,
TotalTokens: 21,
},
}, &convmeta.Values{})
require.NotNil(t, resp)
assert.Equal(t, 13, resp.UsageMetadata.PromptTokenCount)
assert.Equal(t, 8, resp.UsageMetadata.CandidatesTokenCount)
assert.Equal(t, 21, resp.UsageMetadata.TotalTokenCount)
require.NotNil(t, resp.UsageMetadata.BillingUsage)
require.NotNil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage)
assert.Equal(t, 13, resp.UsageMetadata.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 8, resp.UsageMetadata.BillingUsage.OpenAIUsage.CompletionTokens)
require.Len(t, resp.Candidates, 1)
assert.Equal(t, int64(1), resp.Candidates[0].Index)
require.NotNil(t, resp.Candidates[0].FinishReason)
assert.Equal(t, "STOP", *resp.Candidates[0].FinishReason)
require.Len(t, resp.Candidates[0].Content.Parts, 1)
require.NotNil(t, resp.Candidates[0].Content.Parts[0].FunctionCall)
assert.Equal(t, "lookup", resp.Candidates[0].Content.Parts[0].FunctionCall.FunctionName)
assert.Equal(t, map[string]any{"q": "x"}, resp.Candidates[0].Content.Parts[0].FunctionCall.Arguments)
}
func geminiRespPtr[T any](value T) *T {
return &value
}
@@ -0,0 +1,424 @@
package oaichat
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/samber/lo"
)
func normalizeChatImageURLToString(v any) any {
switch vv := v.(type) {
case string:
return vv
case map[string]any:
if url := kitutil.Interface2String(vv["url"]); url != "" {
return url
}
return v
case dto.MessageImageUrl:
if vv.Url != "" {
return vv.Url
}
return v
case *dto.MessageImageUrl:
if vv != nil && vv.Url != "" {
return vv.Url
}
return v
default:
return v
}
}
func convertChatResponseFormatToResponsesText(reqFormat *dto.ResponseFormat) json.RawMessage {
if reqFormat == nil || strings.TrimSpace(reqFormat.Type) == "" {
return nil
}
format := map[string]any{
"type": reqFormat.Type,
}
if reqFormat.Type == "json_schema" && len(reqFormat.JsonSchema) > 0 {
var chatSchema map[string]any
if err := kitutil.Unmarshal(reqFormat.JsonSchema, &chatSchema); err == nil {
for key, value := range chatSchema {
if key == "type" {
continue
}
format[key] = value
}
if nested, ok := format["json_schema"].(map[string]any); ok {
for key, value := range nested {
if _, exists := format[key]; !exists {
format[key] = value
}
}
delete(format, "json_schema")
}
} else {
format["json_schema"] = reqFormat.JsonSchema
}
}
textRaw, _ := kitutil.Marshal(map[string]any{
"format": format,
})
return textRaw
}
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
if req == nil {
return nil, errors.New("request is nil")
}
if req.Model == "" {
return nil, errors.New("model is required")
}
if lo.FromPtrOr(req.N, 1) > 1 {
return nil, fmt.Errorf("n>1 is not supported in responses compatibility mode")
}
var instructionsParts []string
inputItems := make([]map[string]any, 0, len(req.Messages))
for _, msg := range req.Messages {
role := strings.TrimSpace(msg.Role)
if role == "" {
continue
}
if role == "tool" || role == "function" {
callID := strings.TrimSpace(msg.ToolCallId)
var output any
if msg.Content == nil {
output = ""
} else if msg.IsStringContent() {
output = msg.StringContent()
} else {
if b, err := kitutil.Marshal(msg.Content); err == nil {
output = string(b)
} else {
output = fmt.Sprintf("%v", msg.Content)
}
}
if callID == "" {
inputItems = append(inputItems, map[string]any{
"role": "user",
"content": fmt.Sprintf("[tool_output_missing_call_id] %v", output),
})
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call_output",
"call_id": callID,
"output": output,
})
continue
}
// Prefer mapping system/developer messages into `instructions`.
if role == "system" || role == "developer" {
if msg.Content == nil {
continue
}
if msg.IsStringContent() {
if s := strings.TrimSpace(msg.StringContent()); s != "" {
instructionsParts = append(instructionsParts, s)
}
continue
}
parts := msg.ParseContent()
var sb strings.Builder
for _, part := range parts {
if part.Type == dto.ContentTypeText && strings.TrimSpace(part.Text) != "" {
if sb.Len() > 0 {
sb.WriteString("\n")
}
sb.WriteString(part.Text)
}
}
if s := strings.TrimSpace(sb.String()); s != "" {
instructionsParts = append(instructionsParts, s)
}
continue
}
item := map[string]any{
"role": role,
}
if msg.Content == nil {
item["content"] = ""
inputItems = append(inputItems, item)
if role == "assistant" {
for _, tc := range msg.ParseToolCalls() {
if strings.TrimSpace(tc.ID) == "" {
continue
}
if tc.Type != "" && tc.Type != "function" {
continue
}
name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": name,
"arguments": tc.Function.Arguments,
})
}
}
continue
}
if msg.IsStringContent() {
item["content"] = msg.StringContent()
inputItems = append(inputItems, item)
if role == "assistant" {
for _, tc := range msg.ParseToolCalls() {
if strings.TrimSpace(tc.ID) == "" {
continue
}
if tc.Type != "" && tc.Type != "function" {
continue
}
name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": name,
"arguments": tc.Function.Arguments,
})
}
}
continue
}
parts := msg.ParseContent()
contentParts := make([]map[string]any, 0, len(parts))
for _, part := range parts {
switch part.Type {
case dto.ContentTypeText:
textType := "input_text"
if role == "assistant" {
textType = "output_text"
}
contentParts = append(contentParts, map[string]any{
"type": textType,
"text": part.Text,
})
case dto.ContentTypeImageURL:
contentParts = append(contentParts, map[string]any{
"type": "input_image",
"image_url": normalizeChatImageURLToString(part.ImageUrl),
})
case dto.ContentTypeInputAudio:
contentParts = append(contentParts, map[string]any{
"type": "input_audio",
"input_audio": part.InputAudio,
})
case dto.ContentTypeFile:
contentParts = append(contentParts, map[string]any{
"type": "input_file",
"file": part.File,
})
case dto.ContentTypeVideoUrl:
contentParts = append(contentParts, map[string]any{
"type": "input_video",
"video_url": part.VideoUrl,
})
default:
contentParts = append(contentParts, map[string]any{
"type": part.Type,
})
}
}
item["content"] = contentParts
inputItems = append(inputItems, item)
if role == "assistant" {
for _, tc := range msg.ParseToolCalls() {
if strings.TrimSpace(tc.ID) == "" {
continue
}
if tc.Type != "" && tc.Type != "function" {
continue
}
name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": name,
"arguments": tc.Function.Arguments,
})
}
}
}
inputRaw, err := kitutil.Marshal(inputItems)
if err != nil {
return nil, err
}
var instructionsRaw json.RawMessage
if len(instructionsParts) > 0 {
instructions := strings.Join(instructionsParts, "\n\n")
instructionsRaw, _ = kitutil.Marshal(instructions)
}
var toolsRaw json.RawMessage
if req.Tools != nil {
tools := make([]map[string]any, 0, len(req.Tools))
for _, tool := range req.Tools {
switch tool.Type {
case "function":
tools = append(tools, map[string]any{
"type": "function",
"name": tool.Function.Name,
"description": tool.Function.Description,
"parameters": tool.Function.Parameters,
})
default:
// Best-effort: keep original tool shape for unknown types.
var m map[string]any
if b, err := kitutil.Marshal(tool); err == nil {
_ = kitutil.Unmarshal(b, &m)
}
if len(m) == 0 {
m = map[string]any{"type": tool.Type}
}
tools = append(tools, m)
}
}
toolsRaw, _ = kitutil.Marshal(tools)
}
var toolChoiceRaw json.RawMessage
if req.ToolChoice != nil {
switch v := req.ToolChoice.(type) {
case string:
toolChoiceRaw, _ = kitutil.Marshal(v)
default:
var m map[string]any
if b, err := kitutil.Marshal(v); err == nil {
_ = kitutil.Unmarshal(b, &m)
}
if m == nil {
toolChoiceRaw, _ = kitutil.Marshal(v)
} else if t, _ := m["type"].(string); t == "function" {
// Chat: {"type":"function","function":{"name":"..."}}
// Responses: {"type":"function","name":"..."}
if name, ok := m["name"].(string); ok && name != "" {
toolChoiceRaw, _ = kitutil.Marshal(map[string]any{
"type": "function",
"name": name,
})
} else if fn, ok := m["function"].(map[string]any); ok {
if name, ok := fn["name"].(string); ok && name != "" {
toolChoiceRaw, _ = kitutil.Marshal(map[string]any{
"type": "function",
"name": name,
})
} else {
toolChoiceRaw, _ = kitutil.Marshal(v)
}
} else {
toolChoiceRaw, _ = kitutil.Marshal(v)
}
} else {
toolChoiceRaw, _ = kitutil.Marshal(v)
}
}
}
var parallelToolCallsRaw json.RawMessage
if req.ParallelTooCalls != nil {
parallelToolCallsRaw, _ = kitutil.Marshal(*req.ParallelTooCalls)
}
textRaw := convertChatResponseFormatToResponsesText(req.ResponseFormat)
maxOutputTokens := lo.FromPtrOr(req.MaxTokens, uint(0))
if req.MaxCompletionTokens != nil {
maxOutputTokens = *req.MaxCompletionTokens
}
// OpenAI Responses API rejects max_output_tokens < 16 when explicitly provided.
//if maxOutputTokens > 0 && maxOutputTokens < 16 {
// maxOutputTokens = 16
//}
var topP *float64
if req.TopP != nil {
topP = kitutil.GetPointer(lo.FromPtr(req.TopP))
}
var frequencyPenaltyRaw, presencePenaltyRaw json.RawMessage
if req.FrequencyPenalty != nil {
frequencyPenaltyRaw, _ = kitutil.Marshal(req.FrequencyPenalty)
}
if req.PresencePenalty != nil {
presencePenaltyRaw, _ = kitutil.Marshal(req.PresencePenalty)
}
var promptCacheKeyRaw json.RawMessage
if req.PromptCacheKey != "" {
promptCacheKeyRaw, err = kitutil.Marshal(req.PromptCacheKey)
if err != nil {
return nil, fmt.Errorf("marshal prompt_cache_key: %w", err)
}
}
out := &dto.OpenAIResponsesRequest{
Model: req.Model,
Input: inputRaw,
Instructions: instructionsRaw,
Stream: req.Stream,
Temperature: req.Temperature,
Text: textRaw,
ToolChoice: toolChoiceRaw,
Tools: toolsRaw,
TopP: topP,
FrequencyPenalty: frequencyPenaltyRaw,
PresencePenalty: presencePenaltyRaw,
User: req.User,
ParallelToolCalls: parallelToolCallsRaw,
Store: req.Store,
Metadata: req.Metadata,
PromptCacheKey: promptCacheKeyRaw,
EnableThinking: req.EnableThinking,
ThinkingBudget: req.ThinkingBudget,
}
if req.MaxTokens != nil || req.MaxCompletionTokens != nil {
out.MaxOutputTokens = lo.ToPtr(maxOutputTokens)
}
reasoningIntent, err := reasoning.FromOpenAIChat(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := reasoning.ApplyToOpenAIResponses(out, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
return out, nil
}
@@ -0,0 +1,177 @@
package oaichat
import (
"encoding/json"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing.T) {
req := &dto.GeneralOpenAIRequest{
Model: "gpt-test",
N: lo.ToPtr(1),
Messages: []dto.Message{
{Role: "system", Content: "system rules"},
{Role: "developer", Content: "developer rules"},
{Role: "user", Content: []any{
map[string]any{"type": "text", "text": "look"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.test/a.png"}},
}},
assistantMessageWithTool("partial text", "call_1", "lookup", `{"q":"x"}`),
{Role: "tool", ToolCallId: "call_1", Content: "tool result"},
},
}
got, err := ChatCompletionsRequestToResponsesRequest(req)
require.NoError(t, err)
assert.Equal(t, "gpt-test", got.Model)
assert.Equal(t, `"system rules\n\ndeveloper rules"`, string(got.Instructions))
assert.Equal(t, "input_image", gjson.GetBytes(got.Input, "0.content.1.type").String())
assert.Equal(t, "function_call", gjson.GetBytes(got.Input, "2.type").String())
assert.Equal(t, "call_1", gjson.GetBytes(got.Input, "2.call_id").String())
assert.Equal(t, "function_call_output", gjson.GetBytes(got.Input, "3.type").String())
}
func TestChatCompletionsRequestToResponsesRequestPreservesPromptCacheKey(t *testing.T) {
t.Run("present", func(t *testing.T) {
key := "session-\"quoted\"\\path\n世界"
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
PromptCacheKey: key,
})
require.NoError(t, err)
keyRaw, err := kitutil.Marshal(key)
require.NoError(t, err)
assert.Equal(t, keyRaw, []byte(got.PromptCacheKey))
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.Equal(t, key, gjson.GetBytes(encoded, "prompt_cache_key").String())
})
t.Run("absent", func(t *testing.T) {
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
})
require.NoError(t, err)
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.False(t, gjson.GetBytes(encoded, "prompt_cache_key").Exists())
})
}
func TestChatCompletionsRequestToResponsesRequestPreservesQwenThinkingBudget(t *testing.T) {
tests := []struct {
name string
budget json.RawMessage
want int64
}{
{name: "positive budget", budget: json.RawMessage(`128`), want: 128},
{name: "zero budget", budget: json.RawMessage(`0`), want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &dto.GeneralOpenAIRequest{
Model: "qwen-plus",
EnableThinking: json.RawMessage(`true`),
ThinkingBudget: tt.budget,
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
}
got, err := ChatCompletionsRequestToResponsesRequest(req)
require.NoError(t, err)
assert.Equal(t, tt.budget, got.ThinkingBudget)
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.True(t, gjson.GetBytes(encoded, "enable_thinking").Bool())
value := gjson.GetBytes(encoded, "thinking_budget")
assert.True(t, value.Exists())
assert.Equal(t, tt.want, value.Int())
})
}
}
func TestChatCompletionsRequestToResponsesRequestRejectsMultipleChoices(t *testing.T) {
_, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
N: lo.ToPtr(2),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "n>1")
}
func TestChatCompletionsRequestToResponsesRequestPreservesPenalties(t *testing.T) {
tests := []struct {
name string
frequency *float64
frequencyWant json.RawMessage
presence *float64
presenceWant json.RawMessage
}{
{
name: "positive values",
frequency: lo.ToPtr(0.5),
frequencyWant: json.RawMessage(`0.5`),
presence: lo.ToPtr(1.5),
presenceWant: json.RawMessage(`1.5`),
},
{
name: "explicit zero values",
frequency: lo.ToPtr(0.0),
frequencyWant: json.RawMessage(`0`),
presence: lo.ToPtr(0.0),
presenceWant: json.RawMessage(`0`),
},
{
name: "unset stays nil",
frequency: nil,
presence: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
FrequencyPenalty: tt.frequency,
PresencePenalty: tt.presence,
})
require.NoError(t, err)
assert.Equal(t, tt.frequencyWant, got.FrequencyPenalty)
assert.Equal(t, tt.presenceWant, got.PresencePenalty)
})
}
}
func assistantMessageWithTool(content string, id string, name string, args string) dto.Message {
msg := dto.Message{Role: "assistant", Content: content}
msg.SetToolCalls([]dto.ToolCallRequest{
{
ID: id,
Type: "function",
Function: dto.FunctionRequest{
Name: name,
Arguments: args,
},
},
})
return msg
}
@@ -0,0 +1,269 @@
package oaichat
import (
"errors"
"fmt"
"maps"
"strings"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const (
chatFinishReasonLength = "length"
chatFinishReasonContentFilter = "content_filter"
responsesEventCreated = "response.created"
responsesEventCompleted = "response.completed"
responsesEventIncomplete = "response.incomplete"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventOutputTextAnnotationAdded = "response.output_text.annotation.added"
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventOutputItemDone = "response.output_item.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
responsesOutputTypeFunctionCall = "function_call"
responsesOutputTypeMessage = "message"
responsesOutputTypeReasoning = "reasoning"
responsesIncompleteReasonContentFilter = "content_filter"
responsesIncompleteReasonMaxTokens = "max_output_tokens"
)
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
if resp == nil {
return nil, nil, errors.New("response is nil")
}
usage := UsageFromChatUsage(&resp.Usage)
out := &dto.OpenAIResponsesResponse{
ID: id,
Object: "response",
CreatedAt: chatCreatedAt(resp.Created),
Status: []byte(`"completed"`),
Model: resp.Model,
Output: make([]dto.ResponsesOutput, 0),
Usage: usage,
}
if len(resp.Choices) == 0 {
return out, usage, nil
}
choice := resp.Choices[0]
if status, details := ResponsesStatusFromChatFinishReason(choice.FinishReason); status != "" {
out.Status = []byte(fmt.Sprintf("%q", status))
out.IncompleteDetails = details
}
if reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
out.Output = append(out.Output, dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: fmt.Sprintf("%s_reasoning_0", id),
Status: responseOutputStatus(out),
Summary: []dto.ResponsesReasoningSummaryPart{
{
Type: "summary_text",
Text: reasoning,
},
},
})
}
if text := choice.Message.StringContent(); text != "" {
annotations, err := chatAnnotationsToResponses(choice.Message.Annotations)
if err != nil {
return nil, nil, err
}
out.Output = append(out.Output, dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: fmt.Sprintf("%s_msg_0", id),
Status: responseOutputStatus(out),
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{
Type: "output_text",
Text: text,
Annotations: annotations,
},
},
})
}
for i, toolCall := range choice.Message.ParseToolCalls() {
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out))
if err != nil {
return nil, nil, err
}
out.Output = append(out.Output, toolOutput)
}
return out, usage, nil
}
func chatAnnotationsToResponses(raw []byte) ([]any, error) {
if len(raw) == 0 {
return []any{}, nil
}
var annotations []map[string]any
if err := kitutil.Unmarshal(raw, &annotations); err != nil {
return nil, fmt.Errorf("invalid Chat annotations: %w", err)
}
converted := make([]any, 0, len(annotations))
for _, annotation := range annotations {
if strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
converted = append(converted, annotation)
continue
}
citation, ok := annotation["url_citation"].(map[string]any)
if !ok {
converted = append(converted, annotation)
continue
}
flattened := make(map[string]any, len(citation)+1)
flattened["type"] = "url_citation"
maps.Copy(flattened, citation)
converted = append(converted, flattened)
}
return converted, nil
}
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
switch strings.TrimSpace(finishReason) {
case chatFinishReasonLength:
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonMaxTokens}
case chatFinishReasonContentFilter:
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter}
default:
return "completed", nil
}
}
func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
usage := &dto.Usage{}
if src == nil {
return usage
}
usage.UsageSemantic = src.UsageSemantic
usage.UsageSource = src.UsageSource
usage.BillingUsage = dto.CloneBillingUsage(src.BillingUsage)
if usage.BillingUsage == nil {
usage.BillingUsage = dto.NewOpenAIChatBillingUsage(src)
}
usage.Cost = src.Cost
if src.PromptTokens != 0 {
usage.PromptTokens = src.PromptTokens
usage.InputTokens = src.PromptTokens
}
if src.CompletionTokens != 0 {
usage.CompletionTokens = src.CompletionTokens
usage.OutputTokens = src.CompletionTokens
}
if src.TotalTokens != 0 {
usage.TotalTokens = src.TotalTokens
} else {
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
}
if src.PromptTokensDetails.CachedTokens != 0 ||
src.PromptTokensDetails.ImageTokens != 0 ||
src.PromptTokensDetails.AudioTokens != 0 ||
src.PromptTokensDetails.CachedCreationTokens != 0 ||
src.PromptTokensDetails.CacheWriteTokens != 0 ||
src.PromptTokensDetails.TextTokens != 0 {
details := src.PromptTokensDetails
usage.InputTokensDetails = &details
}
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
src.CompletionTokenDetails.TextTokens != 0 ||
src.CompletionTokenDetails.AudioTokens != 0 ||
src.CompletionTokenDetails.ImageTokens != 0 {
usage.CompletionTokenDetails = src.CompletionTokenDetails
}
usage.ClaudeCacheCreation5mTokens = src.ClaudeCacheCreation5mTokens
usage.ClaudeCacheCreation1hTokens = src.ClaudeCacheCreation1hTokens
return usage
}
func responseOutputStatus(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || responseStatusString(resp) != "incomplete" {
return "completed"
}
return "incomplete"
}
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || len(resp.Status) == 0 {
return ""
}
var status string
_ = kitutil.Unmarshal(resp.Status, &status)
return strings.TrimSpace(status)
}
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) {
callID := strings.TrimSpace(toolCall.ID)
if callID == "" {
callID = fmt.Sprintf("%s_call_%d", responseID, index)
}
if toolCall.Type == "" || toolCall.Type == "function" {
return dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: callID,
Status: status,
CallId: callID,
Name: toolCall.Function.Name,
Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments),
}, nil
}
return dto.ResponsesOutput{
Type: toolCall.Type,
ID: callID,
Status: status,
CallId: callID,
Arguments: toolCall.Custom,
}, nil
}
func chatArgumentsRawMessage(arguments string) []byte {
raw, err := kitutil.Marshal(arguments)
if err != nil {
return []byte(`""`)
}
return raw
}
func chatCreatedAt(created any) int {
switch v := created.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case float32:
return int(v)
case string:
if parsed := kitutil.String2Int(v); parsed != 0 {
return parsed
}
}
return int(time.Now().Unix())
}
func responsesStreamEvent(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
payload.Type = eventType
return ChatToResponsesStreamEvent{
Type: eventType,
Payload: payload,
}
}
func intPtr(v int) *int {
return &v
}
func stringPtr(v string) *string {
return &v
}
@@ -0,0 +1,161 @@
package oaichat
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChatCompletionsResponseToResponsesPreservesTextToolCallsAndUsage(t *testing.T) {
chat := &dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Created: 456,
Choices: []dto.OpenAITextResponseChoice{
{
Message: assistantMessageWithTool("I will call.", "call_1", "lookup", `{"q":"x"}`),
FinishReason: "tool_calls",
},
},
Usage: dto.Usage{PromptTokens: 3, CompletionTokens: 5, TotalTokens: 8},
}
resp, usage, err := ChatCompletionsResponseToResponsesResponse(chat, "resp_1")
require.NoError(t, err)
require.NotNil(t, usage)
assert.Equal(t, "resp_1", resp.ID)
assert.Equal(t, "response", resp.Object)
assert.Equal(t, `"completed"`, string(resp.Status))
assert.Equal(t, 3, resp.Usage.InputTokens)
assert.Equal(t, 5, resp.Usage.OutputTokens)
require.Len(t, resp.Output, 2)
assert.Equal(t, responsesOutputTypeMessage, resp.Output[0].Type)
assert.Equal(t, "I will call.", resp.Output[0].Content[0].Text)
assert.Equal(t, responsesOutputTypeFunctionCall, resp.Output[1].Type)
assert.Equal(t, "call_1", resp.Output[1].CallId)
assert.Equal(t, "lookup", resp.Output[1].Name)
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments))
}
func TestChatCompletionsResponseToResponsesEmitsReasoningSummaryBeforeText(t *testing.T) {
message := dto.Message{Role: "assistant", Content: "final answer"}
message.ReasoningContent = lo.ToPtr("thinking summary")
resp, _, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: message, FinishReason: "stop"},
},
}, "resp_1")
require.NoError(t, err)
require.Len(t, resp.Output, 2)
assert.Equal(t, responsesOutputTypeReasoning, resp.Output[0].Type)
require.Len(t, resp.Output[0].Summary, 1)
assert.Equal(t, "thinking summary", resp.Output[0].Summary[0].Text)
assert.Empty(t, resp.Output[0].Content)
assert.Equal(t, responsesOutputTypeMessage, resp.Output[1].Type)
assert.Equal(t, "final answer", resp.Output[1].Content[0].Text)
}
func TestChatCompletionsResponseToResponsesMapsIncompleteFinishReasons(t *testing.T) {
tests := []struct {
name string
finishReason string
wantReason string
}{
{name: "length", finishReason: "length", wantReason: responsesIncompleteReasonMaxTokens},
{name: "content filter", finishReason: "content_filter", wantReason: responsesIncompleteReasonContentFilter},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, _, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{
Message: dto.Message{Role: "assistant", Content: "partial"},
FinishReason: tt.finishReason,
},
},
}, "resp_1")
require.NoError(t, err)
assert.Equal(t, `"incomplete"`, string(resp.Status))
require.NotNil(t, resp.IncompleteDetails)
assert.Equal(t, tt.wantReason, resp.IncompleteDetails.Reason)
require.Len(t, resp.Output, 1)
assert.Equal(t, "incomplete", resp.Output[0].Status)
})
}
}
func TestChatCompletionsStreamToResponsesEventsAggregatesUsageAndToolArgs(t *testing.T) {
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
state.Created = 123
toolIndex := 0
var events []ChatToResponsesStreamEvent
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Created: 123,
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("hello")}},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: &toolIndex, ID: "call_1", Type: "function", Function: dto.FunctionResponse{Name: "lookup"}},
}}},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: &toolIndex, Function: dto.FunctionResponse{Arguments: `{"q":"x"}`}},
}}},
},
})...)
finishReason := "tool_calls"
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, FinishReason: &finishReason},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 4, TotalTokens: 6},
})...)
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
require.Len(t, events, 10)
assert.Equal(t, responsesEventCreated, events[0].Type)
assert.Equal(t, responsesEventOutputTextDelta, events[2].Type)
assert.Equal(t, "hello", events[2].Payload.Delta)
assert.Equal(t, responsesEventFunctionArgsDelta, events[4].Type)
assert.Equal(t, `{"q":"x"}`, events[4].Payload.Delta)
assert.Equal(t, responsesEventCompleted, events[9].Type)
require.NotNil(t, events[9].Payload.Response)
assert.Equal(t, 6, events[9].Payload.Response.Usage.TotalTokens)
require.Len(t, events[9].Payload.Response.Output, 2)
assert.Equal(t, "hello", events[9].Payload.Response.Output[0].Content[0].Text)
assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments))
}
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
t.Helper()
events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
require.NoError(t, err)
return events
}
@@ -0,0 +1,862 @@
package oaichat
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type ChatToResponsesStreamEvent struct {
Type string
Payload dto.ResponsesStreamResponse
}
type ChatToResponsesStreamState struct {
ID string
Model string
Created int64
Usage *dto.Usage
// EmitSequenceNumber enables the required sequence_number field for current
// Responses API SSE consumers while preserving the legacy relaykit default.
EmitSequenceNumber bool
status string
incompleteDetails *dto.IncompleteDetails
sentCreated bool
textOutputIndex int
textStarted bool
textDone bool
reasoningIndex int
reasoningStarted bool
reasoningDone bool
finalized bool
nextSequenceNumber int
nextOutputIndex int
toolsByIndex map[int]*chatToResponsesStreamTool
hostedByID map[string]*chatToResponsesHostedTool
outputOrder []chatToResponsesOutputRef
text strings.Builder
annotations []any
reasoning strings.Builder
}
type chatToResponsesStreamTool struct {
ChatIndex int
OutputIndex int
ItemID string
CallID string
Name string
Arguments strings.Builder
Done bool
}
type chatToResponsesOutputRef struct {
Kind string
ToolIndex int
HostedID string
}
// HostedToolStreamStart describes a provider-hosted tool call that is already
// being executed upstream. It is intentionally separate from function calls:
// hosted calls have their own Responses lifecycle and result fields.
type HostedToolStreamStart struct {
Type string
ID string
Name string
Action []byte
Caller []byte
ServerLabel string
}
// HostedToolStreamResult completes a previously started hosted tool call.
type HostedToolStreamResult struct {
Type string
ID string
Result []byte
ErrorCode string
IsError bool
}
type chatToResponsesHostedTool struct {
OutputIndex int
Output dto.ResponsesOutput
Done bool
}
func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState {
return &ChatToResponsesStreamState{
ID: id,
Model: model,
Created: time.Now().Unix(),
Usage: &dto.Usage{},
status: "completed",
textOutputIndex: -1,
reasoningIndex: -1,
toolsByIndex: make(map[int]*chatToResponsesStreamTool),
hostedByID: make(map[string]*chatToResponsesHostedTool),
}
}
func (s *ChatToResponsesStreamState) StreamUsage() *dto.Usage {
if s == nil {
return nil
}
return s.Usage
}
func (s *ChatToResponsesStreamState) SetStreamUsage(usage *dto.Usage) {
if s != nil && usage != nil {
s.Usage = UsageFromChatUsage(usage)
}
}
func (s *ChatToResponsesStreamState) StartHostedTool(start HostedToolStreamStart) ([]ChatToResponsesStreamEvent, error) {
if s == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
start.ID = strings.TrimSpace(start.ID)
if start.ID == "" {
return nil, fmt.Errorf("hosted-tool stream call is missing an id")
}
if _, exists := s.hostedByID[start.ID]; exists {
return nil, fmt.Errorf("duplicate hosted-tool stream call id %q", start.ID)
}
if hostedEventPrefix(start.Type) == "" {
return nil, fmt.Errorf("unsupported Responses hosted-tool output type %q", start.Type)
}
caller := strings.TrimSpace(string(start.Caller))
if caller != "" && caller != "null" {
return nil, fmt.Errorf("Responses %s cannot preserve Claude hosted-tool caller provenance", start.Type)
}
tool := &chatToResponsesHostedTool{
Output: dto.ResponsesOutput{
Type: start.Type,
ID: start.ID,
Status: "in_progress",
},
}
switch start.Type {
case "web_search_call":
action, err := dto.NormalizeResponsesWebSearchAction(start.Action)
if err != nil {
return nil, err
}
tool.Output.Action = action
case "code_interpreter_call":
return nil, fmt.Errorf("cannot map provider code execution to Responses code_interpreter_call without a container_id")
case "mcp_call":
if strings.TrimSpace(start.Name) == "" || strings.TrimSpace(start.ServerLabel) == "" {
return nil, fmt.Errorf("Responses MCP call requires name and server_label")
}
arguments, err := hostedJSONString(start.Action)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP arguments: %w", err)
}
tool.Output.Name = start.Name
tool.Output.ServerLabel = start.ServerLabel
tool.Output.Arguments = arguments
}
outputIndex := s.nextHostedIndex(start.ID)
tool.OutputIndex = outputIndex
s.hostedByID[start.ID] = tool
events := s.ensureCreated()
addedItem := cloneHostedOutput(&tool.Output)
if start.Type == "mcp_call" {
addedItem.Arguments = json.RawMessage(`""`)
}
events = append(events,
s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Item: addedItem,
}),
s.event(hostedEventPrefix(start.Type)+".in_progress", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
}),
)
if start.Type == "web_search_call" {
events = append(events, s.event(hostedEventPrefix(start.Type)+".searching", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
}))
}
if start.Type == "mcp_call" {
arguments := dto.ResponsesArgumentsString(tool.Output.Arguments)
events = append(events,
s.event("response.mcp_call_arguments.delta", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Delta: arguments,
}),
s.event("response.mcp_call_arguments.done", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Arguments: kitutil.GetPointer(arguments),
}),
)
}
return events, nil
}
func (s *ChatToResponsesStreamState) CompleteHostedTool(result HostedToolStreamResult) ([]ChatToResponsesStreamEvent, error) {
if s == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
result.ID = strings.TrimSpace(result.ID)
tool := s.hostedByID[result.ID]
if tool == nil {
return nil, fmt.Errorf("hosted-tool result references unknown call %q", result.ID)
}
if tool.Done {
return nil, fmt.Errorf("duplicate hosted-tool result for call %q", result.ID)
}
if result.Type != "" && result.Type != tool.Output.Type {
return nil, fmt.Errorf("hosted-tool result type %q does not match call type %q", result.Type, tool.Output.Type)
}
failed := result.IsError || strings.TrimSpace(result.ErrorCode) != ""
tool.Output.Status = "completed"
switch tool.Output.Type {
case "web_search_call":
// Responses exposes only the action and lifecycle status on a
// web_search_call. Claude's opaque result payload cannot be emitted
// as a top-level `results` field.
case "code_interpreter_call":
return nil, fmt.Errorf("Responses code_interpreter_call is not supported without a container_id")
case "mcp_call":
output, err := hostedResultString(result.Result)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP output: %w", err)
}
tool.Output.Output = output
}
if failed {
tool.Output.Status = "failed"
errorValue := result.ErrorCode
if errorValue == "" {
errorValue = "hosted tool execution failed"
}
if tool.Output.Type == "mcp_call" {
encoded, err := kitutil.Marshal(errorValue)
if err != nil {
return nil, fmt.Errorf("marshal hosted-tool error: %w", err)
}
tool.Output.ItemError = encoded
tool.Output.Output = nil
}
}
tool.Done = true
events := make([]ChatToResponsesStreamEvent, 0, 2)
if eventType := hostedTerminalEvent(tool.Output.Type, failed); eventType != "" {
events = append(events, s.event(eventType, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: result.ID,
}))
}
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: result.ID,
Item: cloneHostedOutput(&tool.Output),
}))
return events, nil
}
// Fail emits a terminal Responses error using the same event allocator as the
// rest of the stream, so callers never have to append a JSON HTTP error to an
// already-started SSE response.
func (s *ChatToResponsesStreamState) Fail(code string, message string, param string) []ChatToResponsesStreamEvent {
if s == nil || s.finalized {
return nil
}
code = strings.TrimSpace(code)
if code == "" {
code = "server_error"
}
message = strings.TrimSpace(message)
if message == "" {
message = "upstream response stream failed"
}
s.status = "failed"
events := s.ensureCreated()
events = append(events, s.doneDeltaEvents()...)
s.finalized = true
events = append(events, s.event("error", dto.ResponsesStreamResponse{
Code: code,
Message: message,
Param: param,
}))
response := s.finalResponse()
response.Error = map[string]any{
"code": code,
"message": message,
}
events = append(events, s.event("response.failed", dto.ResponsesStreamResponse{
Response: response,
}))
return events
}
func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) {
if chunk == nil || state == nil {
return nil, nil
}
if state.ID == "" {
state.ID = chunk.Id
}
if state.Model == "" {
state.Model = chunk.Model
}
if state.Created == 0 {
state.Created = chunk.Created
}
if chunk.Usage != nil {
state.Usage = UsageFromChatUsage(chunk.Usage)
}
events := state.ensureCreated()
for _, choice := range chunk.Choices {
if choice.Delta.GetReasoningContent() != "" {
events = append(events, state.appendReasoningDelta(choice.Delta.GetReasoningContent())...)
}
if choice.Delta.GetContentString() != "" {
events = append(events, state.appendTextDelta(choice.Delta.GetContentString())...)
}
if len(choice.Delta.Annotations) > 0 {
annotationEvents, err := state.appendAnnotationDelta(choice.Delta.Annotations)
if err != nil {
return nil, err
}
events = append(events, annotationEvents...)
}
for _, toolCall := range choice.Delta.ToolCalls {
toolEvents, err := state.appendToolCallDelta(toolCall)
if err != nil {
return nil, err
}
events = append(events, toolEvents...)
}
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
state.applyFinishReason(*choice.FinishReason)
events = append(events, state.doneDeltaEvents()...)
}
}
return events, nil
}
func (s *ChatToResponsesStreamState) ensureCreated() []ChatToResponsesStreamEvent {
if s.sentCreated {
return nil
}
s.sentCreated = true
return []ChatToResponsesStreamEvent{s.event(responsesEventCreated, dto.ResponsesStreamResponse{
Type: responsesEventCreated,
Response: s.createdResponse(),
})}
}
func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent {
if state == nil || state.finalized {
return nil
}
events := state.doneDeltaEvents()
state.finalized = true
resp := state.finalResponse()
eventType := responsesEventCompleted
if state.status == "incomplete" {
eventType = responsesEventIncomplete
}
events = append(events, state.event(eventType, dto.ResponsesStreamResponse{
Type: eventType,
Response: resp,
}))
return events
}
func (s *ChatToResponsesStreamState) UsageText() string {
if s == nil {
return ""
}
return s.text.String()
}
func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent {
events := s.startText()
s.text.WriteString(delta)
events = append(events, s.event(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
Type: responsesEventOutputTextDelta,
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
Delta: delta,
ItemID: s.messageID(),
}))
return events
}
func (s *ChatToResponsesStreamState) startText() []ChatToResponsesStreamEvent {
if !s.textStarted {
s.textStarted = true
s.textOutputIndex = s.nextIndex("message", -1)
return []ChatToResponsesStreamEvent{s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.textOutputIndex),
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: s.messageID(),
Status: "in_progress",
Role: "assistant",
Content: []dto.ResponsesOutputContent{},
},
})}
}
return nil
}
func (s *ChatToResponsesStreamState) appendAnnotationDelta(raw []byte) ([]ChatToResponsesStreamEvent, error) {
annotations, err := chatAnnotationsToResponses(raw)
if err != nil {
return nil, err
}
events := s.startText()
for _, annotation := range annotations {
annotationJSON, err := kitutil.Marshal(annotation)
if err != nil {
return nil, fmt.Errorf("marshal Responses annotation: %w", err)
}
annotationIndex := len(s.annotations)
s.annotations = append(s.annotations, annotation)
events = append(events, s.event(responsesEventOutputTextAnnotationAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputTextAnnotationAdded,
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
AnnotationIndex: intPtr(annotationIndex),
Annotation: annotationJSON,
ItemID: s.messageID(),
}))
}
return events, nil
}
func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent {
events := make([]ChatToResponsesStreamEvent, 0, 2)
if !s.reasoningStarted {
s.reasoningStarted = true
s.reasoningIndex = s.nextIndex("reasoning", -1)
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.reasoningIndex),
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: "in_progress",
Summary: []dto.ResponsesReasoningSummaryPart{},
},
}))
}
s.reasoning.WriteString(delta)
events = append(events, s.event(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDelta,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
Delta: delta,
ItemID: s.reasoningID(),
}))
return events
}
func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallResponse) ([]ChatToResponsesStreamEvent, error) {
chatIndex := 0
if toolCall.Index != nil {
chatIndex = *toolCall.Index
}
incomingID := strings.TrimSpace(toolCall.ID)
tool := s.toolsByIndex[chatIndex]
events := make([]ChatToResponsesStreamEvent, 0, 2)
if tool == nil {
tool = &chatToResponsesStreamTool{
ChatIndex: chatIndex,
OutputIndex: s.nextIndex("tool", chatIndex),
CallID: incomingID,
Name: strings.TrimSpace(toolCall.Function.Name),
}
tool.ItemID = incomingID
if tool.ItemID == "" {
tool.ItemID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
}
s.toolsByIndex[chatIndex] = tool
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ItemID,
Status: "in_progress",
CallId: tool.callID(),
Name: tool.Name,
Arguments: []byte(`""`),
},
}))
}
if tool.Done {
return nil, fmt.Errorf("tool-call stream index %d received data after completion", chatIndex)
}
if incomingID != "" {
if tool.CallID != "" && tool.CallID != incomingID {
return nil, fmt.Errorf("tool-call stream index %d changed id from %q to %q", chatIndex, tool.CallID, incomingID)
}
tool.CallID = incomingID
}
incomingName := strings.TrimSpace(toolCall.Function.Name)
if incomingName != "" {
if tool.Name != "" && tool.Name != incomingName {
return nil, fmt.Errorf("tool-call stream index %d changed name from %q to %q", chatIndex, tool.Name, incomingName)
}
tool.Name = incomingName
}
if toolCall.Function.Arguments != "" {
tool.Arguments.WriteString(toolCall.Function.Arguments)
events = append(events, s.event(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
Delta: toolCall.Function.Arguments,
}))
}
return events, nil
}
func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEvent {
events := make([]ChatToResponsesStreamEvent, 0)
status := s.outputStatus()
if s.textStarted && !s.textDone {
s.textDone = true
textDone := dto.ResponsesStreamResponse{
Type: "response.output_text.done",
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
ItemID: s.messageID(),
}
if s.EmitSequenceNumber {
textDone.Text = kitutil.GetPointer(s.text.String())
}
events = append(events, s.event("response.output_text.done", textDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.textOutputIndex),
Item: s.messageOutput(status),
}))
}
if s.reasoningStarted && !s.reasoningDone {
s.reasoningDone = true
reasoningDone := dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDone,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
ItemID: s.reasoningID(),
Part: &dto.ResponsesReasoningSummaryPart{
Type: "summary_text",
Text: s.reasoning.String(),
},
}
if s.EmitSequenceNumber {
reasoningDone.Text = kitutil.GetPointer(s.reasoning.String())
reasoningDone.Part = nil
}
events = append(events, s.event(responsesEventReasoningSummaryDone, reasoningDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.reasoningIndex),
Item: s.reasoningOutput(status),
}))
}
for _, tool := range s.sortedTools() {
if tool.Done {
continue
}
tool.Done = true
argumentsDone := dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDone,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
}
if s.EmitSequenceNumber {
argumentsDone.Arguments = kitutil.GetPointer(tool.Arguments.String())
argumentsDone.Name = tool.Name
}
events = append(events, s.event(responsesEventFunctionArgsDone, argumentsDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(tool.OutputIndex),
Item: s.toolOutput(tool, status),
}))
}
for _, ref := range s.outputOrder {
if ref.Kind != "hosted" {
continue
}
tool := s.hostedByID[ref.HostedID]
if tool == nil || tool.Done {
continue
}
if s.status != "failed" {
s.status = "incomplete"
}
tool.Done = true
tool.Output.Status = "incomplete"
if s.status == "failed" {
tool.Output.Status = "failed"
errorValue, err := kitutil.Marshal("provider stream failed before hosted-tool result")
if err == nil && tool.Output.Type == "mcp_call" {
tool.Output.ItemError = errorValue
tool.Output.Output = nil
}
if eventType := hostedTerminalEvent(tool.Output.Type, true); eventType != "" {
events = append(events, s.event(eventType, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.Output.ID,
}))
}
}
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.Output.ID,
Item: cloneHostedOutput(&tool.Output),
}))
}
return events
}
func (s *ChatToResponsesStreamState) applyFinishReason(finishReason string) {
if status, details := ResponsesStatusFromChatFinishReason(finishReason); status != "" {
s.status = status
s.incompleteDetails = details
}
}
func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesResponse {
output := make([]dto.ResponsesOutput, 0, len(s.outputOrder))
status := s.outputStatus()
for _, ref := range s.outputOrder {
switch ref.Kind {
case "message":
output = append(output, *s.messageOutput(status))
case "reasoning":
output = append(output, *s.reasoningOutput(status))
case "tool":
if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil {
output = append(output, *s.toolOutput(tool, status))
}
case "hosted":
if tool := s.hostedByID[ref.HostedID]; tool != nil {
output = append(output, *cloneHostedOutput(&tool.Output))
}
}
}
return &dto.OpenAIResponsesResponse{
ID: s.ID,
Object: "response",
CreatedAt: int(s.Created),
Status: []byte(fmt.Sprintf("%q", s.status)),
IncompleteDetails: s.incompleteDetails,
Model: s.Model,
Output: output,
Usage: s.Usage,
}
}
func (s *ChatToResponsesStreamState) createdResponse() *dto.OpenAIResponsesResponse {
return &dto.OpenAIResponsesResponse{
ID: s.ID,
Object: "response",
CreatedAt: int(s.Created),
Status: []byte(`"in_progress"`),
Model: s.Model,
Output: []dto.ResponsesOutput{},
}
}
func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int {
index := s.nextOutputIndex
s.nextOutputIndex++
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: kind, ToolIndex: toolIndex})
return index
}
func (s *ChatToResponsesStreamState) nextHostedIndex(id string) int {
index := s.nextOutputIndex
s.nextOutputIndex++
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: "hosted", HostedID: id})
return index
}
func (s *ChatToResponsesStreamState) sortedTools() []*chatToResponsesStreamTool {
indexes := make([]int, 0, len(s.toolsByIndex))
for index := range s.toolsByIndex {
indexes = append(indexes, index)
}
sort.Ints(indexes)
tools := make([]*chatToResponsesStreamTool, 0, len(indexes))
for _, index := range indexes {
tools = append(tools, s.toolsByIndex[index])
}
return tools
}
func (s *ChatToResponsesStreamState) outputStatus() string {
if s.status == "incomplete" || s.status == "failed" {
return "incomplete"
}
return "completed"
}
func (s *ChatToResponsesStreamState) messageID() string {
return fmt.Sprintf("%s_msg_0", s.ID)
}
func (s *ChatToResponsesStreamState) reasoningID() string {
return fmt.Sprintf("%s_reasoning_0", s.ID)
}
func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput {
annotations := s.annotations
if annotations == nil {
annotations = []any{}
}
return &dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: s.messageID(),
Status: status,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{
Type: "output_text",
Text: s.text.String(),
Annotations: annotations,
},
},
}
}
func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.ResponsesOutput {
return &dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: status,
Summary: []dto.ResponsesReasoningSummaryPart{
{
Type: "summary_text",
Text: s.reasoning.String(),
},
},
}
}
func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput {
return &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ItemID,
Status: status,
CallId: tool.callID(),
Name: tool.Name,
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
}
}
func (t *chatToResponsesStreamTool) callID() string {
if t == nil {
return ""
}
if t.CallID == "" {
return t.ItemID
}
return t.CallID
}
func hostedEventPrefix(outputType string) string {
switch outputType {
case "web_search_call":
return "response.web_search_call"
case "mcp_call":
return "response.mcp_call"
default:
return ""
}
}
func hostedTerminalEvent(outputType string, failed bool) string {
prefix := hostedEventPrefix(outputType)
if prefix == "" {
return ""
}
if !failed {
return prefix + ".completed"
}
// OpenAI currently defines a dedicated failed lifecycle event for MCP.
// Web search and code interpreter surface failure on output_item.done.
if outputType == "mcp_call" {
return prefix + ".failed"
}
return ""
}
func hostedJSONString(value []byte) (json.RawMessage, error) {
if len(value) == 0 {
return json.RawMessage(`""`), nil
}
if !kitutil.Valid(value) {
return nil, fmt.Errorf("invalid JSON payload")
}
encoded, err := kitutil.Marshal(string(value))
if err != nil {
return nil, err
}
return encoded, nil
}
func hostedResultString(value []byte) (json.RawMessage, error) {
if len(value) == 0 {
return json.RawMessage(`""`), nil
}
if !kitutil.Valid(value) {
return nil, fmt.Errorf("invalid JSON payload")
}
if kitutil.GetJsonType(value) == "string" {
return append(json.RawMessage(nil), value...), nil
}
return hostedJSONString(value)
}
func cloneHostedOutput(output *dto.ResponsesOutput) *dto.ResponsesOutput {
if output == nil {
return nil
}
clone := *output
clone.Action = append([]byte(nil), output.Action...)
clone.Arguments = append([]byte(nil), output.Arguments...)
clone.Code = append([]byte(nil), output.Code...)
clone.Results = append([]byte(nil), output.Results...)
clone.Outputs = append([]byte(nil), output.Outputs...)
clone.Output = append([]byte(nil), output.Output...)
clone.ItemError = append([]byte(nil), output.ItemError...)
clone.Caller = append([]byte(nil), output.Caller...)
return &clone
}
func (s *ChatToResponsesStreamState) event(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
if s.EmitSequenceNumber {
sequenceNumber := s.nextSequenceNumber
s.nextSequenceNumber++
payload.SequenceNumber = &sequenceNumber
}
return responsesStreamEvent(eventType, payload)
}