feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
This commit is contained in:
commit
bb7968b612
2381 files changed
+551494
No files matched your search
@@ -0,0 +1,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)
|
||||
}
|
||||
Reference in new issue
Block a user