feat: enhance text protocol conversion and advanced custom routing (#5825)
* refactor: consolidate relay protocol converters * refactor relayconvert text converters * feat: refine relay converters and advanced custom routing * refactor: enhance logging and add thought signature handling for Gemini requests * refactor: enhance channel cache and pricing endpoint handling for advanced custom models * feat: preserve billing usage semantics * feat: add protocol-aware billing usage * Delete useless files * chore: update action versions in workflow files * chore: update Docker action versions in workflow files * fix: harden billing usage settlement and hot-path route matching - estimate Gemini completion tokens locally when billable usageMetadata is prompt-only but output content was received (e.g. client aborts the stream before the final chunk), and rebuild the attached billing_usage as estimated so settlement does not bill zero output tokens - guard NewClaudeMessagesBillingUsage against all-zero ClaudeUsage, matching the OpenAI/Gemini constructors, so a zero billing_usage cannot override a non-zero top-level usage during settlement - cache compiled advanced-custom route model regexes; they run on the request hot path and were recompiled per request - move the effectiveBillingUsage remap to PostTextConsumeQuota only, and document that calculateTextQuotaSummary expects remapped usage - document the updatePricingLock -> channelSyncLock lock ordering that InitChannelCache/CacheUpdateChannel rely on, and the aux-struct pitfall in GeminiChatResponse.UnmarshalJSON
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
usageBillingPathLocal = "local"
|
||||
usageBillingPathUpstream = "upstream"
|
||||
usageBillingPathOpenAI = "billing-usage-openai"
|
||||
usageBillingPathOpenAIEstimated = "billing-usage-openai-estimated"
|
||||
usageBillingPathAnthropic = "billing-usage-anthropic"
|
||||
usageBillingPathAnthropicEstimated = "billing-usage-anthropic-estimated"
|
||||
usageBillingPathGemini = "billing-usage-gemini"
|
||||
usageBillingPathGeminiEstimated = "billing-usage-gemini-estimated"
|
||||
)
|
||||
|
||||
func effectiveBillingUsage(usage *dto.Usage) *dto.Usage {
|
||||
if billingUsage, ok := usageFromBillingUsage(usage); ok {
|
||||
return billingUsage
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func usageBillingPathForLog(isLocalCountTokens bool, usage *dto.Usage) string {
|
||||
if isLocalCountTokens {
|
||||
return usageBillingPathLocal
|
||||
}
|
||||
if usage == nil || usage.BillingUsage == nil {
|
||||
return usageBillingPathUpstream
|
||||
}
|
||||
source := strings.TrimSpace(usage.BillingUsage.Source)
|
||||
semantic := strings.TrimSpace(usage.BillingUsage.Semantic)
|
||||
if strings.EqualFold(source, dto.BillingUsageSourceOAIChat) ||
|
||||
strings.EqualFold(source, dto.BillingUsageSourceOAIResponses) ||
|
||||
strings.EqualFold(semantic, dto.BillingUsageSemanticOpenAI) {
|
||||
if usage.BillingUsage.Estimated {
|
||||
return usageBillingPathOpenAIEstimated
|
||||
}
|
||||
return usageBillingPathOpenAI
|
||||
}
|
||||
if strings.EqualFold(source, dto.BillingUsageSourceClaudeMessages) ||
|
||||
strings.EqualFold(semantic, dto.BillingUsageSemanticAnthropic) {
|
||||
if usage.BillingUsage.Estimated {
|
||||
return usageBillingPathAnthropicEstimated
|
||||
}
|
||||
return usageBillingPathAnthropic
|
||||
}
|
||||
if strings.EqualFold(source, dto.BillingUsageSourceGeminiChat) ||
|
||||
strings.EqualFold(semantic, dto.BillingUsageSemanticGemini) {
|
||||
if usage.BillingUsage.Estimated {
|
||||
return usageBillingPathGeminiEstimated
|
||||
}
|
||||
return usageBillingPathGemini
|
||||
}
|
||||
return usageBillingPathUpstream
|
||||
}
|
||||
|
||||
func appendUsageBillingPathForLog(other map[string]interface{}, isLocalCountTokens bool, usage *dto.Usage) {
|
||||
if other == nil {
|
||||
return
|
||||
}
|
||||
adminInfo, ok := other["admin_info"].(map[string]interface{})
|
||||
if !ok || adminInfo == nil {
|
||||
adminInfo = make(map[string]interface{})
|
||||
other["admin_info"] = adminInfo
|
||||
}
|
||||
adminInfo["usage_billing_path"] = usageBillingPathForLog(isLocalCountTokens, usage)
|
||||
}
|
||||
|
||||
func usageFromBillingUsage(usage *dto.Usage) (*dto.Usage, bool) {
|
||||
if usage == nil || usage.BillingUsage == nil {
|
||||
return nil, false
|
||||
}
|
||||
billingUsage := usage.BillingUsage
|
||||
source := strings.TrimSpace(billingUsage.Source)
|
||||
semantic := strings.TrimSpace(billingUsage.Semantic)
|
||||
|
||||
if billingUsage.OpenAIUsage != nil &&
|
||||
(strings.EqualFold(source, dto.BillingUsageSourceOAIChat) ||
|
||||
strings.EqualFold(source, dto.BillingUsageSourceOAIResponses) ||
|
||||
strings.EqualFold(semantic, dto.BillingUsageSemanticOpenAI)) {
|
||||
return usageFromOpenAIBillingUsage(billingUsage), true
|
||||
}
|
||||
|
||||
if billingUsage.ClaudeUsage != nil &&
|
||||
(strings.EqualFold(source, dto.BillingUsageSourceClaudeMessages) ||
|
||||
strings.EqualFold(semantic, dto.BillingUsageSemanticAnthropic)) {
|
||||
return usageFromClaudeBillingUsage(billingUsage), true
|
||||
}
|
||||
|
||||
if billingUsage.GeminiUsageMetadata != nil &&
|
||||
(strings.EqualFold(source, dto.BillingUsageSourceGeminiChat) ||
|
||||
strings.EqualFold(semantic, dto.BillingUsageSemanticGemini)) {
|
||||
return usageFromGeminiBillingUsage(billingUsage), true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func usageFromOpenAIBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
|
||||
usage := *billingUsage.OpenAIUsage
|
||||
if usage.PromptTokens == 0 && usage.InputTokens > 0 {
|
||||
usage.PromptTokens = usage.InputTokens
|
||||
}
|
||||
if usage.CompletionTokens == 0 && usage.OutputTokens > 0 {
|
||||
usage.CompletionTokens = usage.OutputTokens
|
||||
}
|
||||
if usage.InputTokens == 0 && usage.PromptTokens > 0 {
|
||||
usage.InputTokens = usage.PromptTokens
|
||||
}
|
||||
if usage.OutputTokens == 0 && usage.CompletionTokens > 0 {
|
||||
usage.OutputTokens = usage.CompletionTokens
|
||||
}
|
||||
if usage.TotalTokens == 0 {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
usage.UsageSemantic = dto.BillingUsageSemanticOpenAI
|
||||
usage.UsageSource = billingUsage.Source
|
||||
usage.BillingUsage = dto.CloneBillingUsage(billingUsage)
|
||||
return &usage
|
||||
}
|
||||
|
||||
func usageFromClaudeBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
|
||||
claudeUsage := billingUsage.ClaudeUsage
|
||||
cacheCreation5m := claudeUsage.GetCacheCreation5mTokens()
|
||||
if cacheCreation5m == 0 {
|
||||
cacheCreation5m = claudeUsage.ClaudeCacheCreation5mTokens
|
||||
}
|
||||
cacheCreation1h := claudeUsage.GetCacheCreation1hTokens()
|
||||
if cacheCreation1h == 0 {
|
||||
cacheCreation1h = claudeUsage.ClaudeCacheCreation1hTokens
|
||||
}
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: claudeUsage.InputTokens,
|
||||
CompletionTokens: claudeUsage.OutputTokens,
|
||||
TotalTokens: claudeUsage.InputTokens + claudeUsage.OutputTokens,
|
||||
InputTokens: claudeUsage.InputTokens + claudeUsage.CacheReadInputTokens + claudeUsage.CacheCreationInputTokens,
|
||||
OutputTokens: claudeUsage.OutputTokens,
|
||||
UsageSemantic: dto.BillingUsageSemanticAnthropic,
|
||||
UsageSource: dto.BillingUsageSourceClaudeMessages,
|
||||
BillingUsage: dto.CloneBillingUsage(billingUsage),
|
||||
ClaudeCacheCreation5mTokens: cacheCreation5m,
|
||||
ClaudeCacheCreation1hTokens: cacheCreation1h,
|
||||
}
|
||||
usage.PromptTokensDetails.CachedTokens = claudeUsage.CacheReadInputTokens
|
||||
usage.PromptTokensDetails.CachedCreationTokens = claudeUsage.CacheCreationInputTokens
|
||||
return usage
|
||||
}
|
||||
|
||||
func usageFromGeminiBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
|
||||
metadata := *billingUsage.GeminiUsageMetadata
|
||||
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
|
||||
TotalTokens: metadata.TotalTokenCount,
|
||||
UsageSemantic: dto.BillingUsageSemanticGemini,
|
||||
UsageSource: dto.BillingUsageSourceGeminiChat,
|
||||
BillingUsage: dto.CloneBillingUsage(billingUsage),
|
||||
}
|
||||
usage.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
|
||||
usage.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
|
||||
|
||||
for _, detail := range metadata.PromptTokensDetails {
|
||||
addGeminiInputTokenDetail(&usage.PromptTokensDetails, detail)
|
||||
}
|
||||
for _, detail := range metadata.ToolUsePromptTokensDetails {
|
||||
addGeminiInputTokenDetail(&usage.PromptTokensDetails, detail)
|
||||
}
|
||||
for _, detail := range metadata.CandidatesTokensDetails {
|
||||
switch detail.Modality {
|
||||
case "IMAGE":
|
||||
usage.CompletionTokenDetails.ImageTokens += detail.TokenCount
|
||||
case "AUDIO":
|
||||
usage.CompletionTokenDetails.AudioTokens += detail.TokenCount
|
||||
case "TEXT":
|
||||
usage.CompletionTokenDetails.TextTokens += detail.TokenCount
|
||||
}
|
||||
}
|
||||
|
||||
if usage.TotalTokens == 0 {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
} else if usage.CompletionTokens <= 0 {
|
||||
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||
}
|
||||
if usage.PromptTokens > 0 && usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokensDetails.AudioTokens == 0 {
|
||||
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func addGeminiInputTokenDetail(details *dto.InputTokenDetails, detail dto.GeminiPromptTokensDetails) {
|
||||
switch detail.Modality {
|
||||
case "AUDIO":
|
||||
details.AudioTokens += detail.TokenCount
|
||||
case "IMAGE":
|
||||
details.ImageTokens += detail.TokenCount
|
||||
case "TEXT":
|
||||
details.TextTokens += detail.TokenCount
|
||||
}
|
||||
}
|
||||
+6
-984
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResponseConverterFacades(t *testing.T) {
|
||||
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
|
||||
assert.Equal(t, 8, cache5m)
|
||||
assert.Equal(t, 2, cache1h)
|
||||
|
||||
chatResp := &dto.OpenAITextResponse{
|
||||
Id: "chatcmpl_1",
|
||||
Model: "gpt-test",
|
||||
Choices: []dto.OpenAITextResponseChoice{
|
||||
{
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: "hello",
|
||||
},
|
||||
FinishReason: "stop",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
claudeResp := ResponseOpenAI2Claude(chatResp, &relaycommon.RelayInfo{})
|
||||
require.NotNil(t, claudeResp)
|
||||
assert.Equal(t, "message", claudeResp.Type)
|
||||
|
||||
geminiResp := ResponseOpenAI2Gemini(chatResp, &relaycommon.RelayInfo{})
|
||||
require.NotNil(t, geminiResp)
|
||||
require.Len(t, geminiResp.Candidates, 1)
|
||||
}
|
||||
|
||||
func TestStreamResponseConverterFacades(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
SendResponseCount: 1,
|
||||
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||
LastMessagesType: relaycommon.LastMessageTypeNone,
|
||||
},
|
||||
}
|
||||
streamResp := &dto.ChatCompletionsStreamResponse{
|
||||
Id: "chatcmpl_1",
|
||||
Model: "gpt-test",
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
Content: ptrValue("hello"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
claudeResponses := StreamResponseOpenAI2Claude(streamResp, info)
|
||||
require.NotEmpty(t, claudeResponses)
|
||||
|
||||
geminiResp := StreamResponseOpenAI2Gemini(streamResp, &relaycommon.RelayInfo{})
|
||||
require.NotNil(t, geminiResp)
|
||||
require.Len(t, geminiResp.Candidates, 1)
|
||||
}
|
||||
|
||||
func ptrValue[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package claudemessages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relaymeta "github.com/QuantumNous/new-api/service/relayconvert/internal/meta"
|
||||
)
|
||||
|
||||
const (
|
||||
webSearchMaxUsesLow = 1
|
||||
webSearchMaxUsesMedium = 5
|
||||
webSearchMaxUsesHigh = 10
|
||||
)
|
||||
|
||||
type openRouterRequestReasoning struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Effort string `json:"effort,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Exclude bool `json:"exclude,omitempty"`
|
||||
}
|
||||
|
||||
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||
openAIRequest := dto.GeneralOpenAIRequest{
|
||||
Model: claudeRequest.Model,
|
||||
Temperature: claudeRequest.Temperature,
|
||||
}
|
||||
if claudeRequest.MaxTokens != nil {
|
||||
openAIRequest.MaxTokens = common.GetPointer(*claudeRequest.MaxTokens)
|
||||
}
|
||||
if claudeRequest.TopP != nil {
|
||||
openAIRequest.TopP = common.GetPointer(*claudeRequest.TopP)
|
||||
}
|
||||
if claudeRequest.TopK != nil {
|
||||
openAIRequest.TopK = common.GetPointer(*claudeRequest.TopK)
|
||||
}
|
||||
if claudeRequest.Stream != nil {
|
||||
openAIRequest.Stream = common.GetPointer(*claudeRequest.Stream)
|
||||
}
|
||||
|
||||
isOpenRouter := relaymeta.RelayInfoChannelType(info) == constant.ChannelTypeOpenRouter
|
||||
if isOpenRouter {
|
||||
if effort := claudeRequest.GetEfforts(); effort != "" {
|
||||
effortBytes, _ := common.Marshal(effort)
|
||||
openAIRequest.Verbosity = effortBytes
|
||||
}
|
||||
if claudeRequest.Thinking != nil {
|
||||
var reasoningConfig openRouterRequestReasoning
|
||||
if claudeRequest.Thinking.Type == "enabled" {
|
||||
reasoningConfig = openRouterRequestReasoning{
|
||||
Enabled: true,
|
||||
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
|
||||
}
|
||||
} else if claudeRequest.Thinking.Type == "adaptive" {
|
||||
reasoningConfig = openRouterRequestReasoning{
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
reasoningJSON, err := common.Marshal(reasoningConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
|
||||
}
|
||||
openAIRequest.Reasoning = reasoningJSON
|
||||
}
|
||||
} else if info != nil {
|
||||
thinkingSuffix := "-thinking"
|
||||
if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
|
||||
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
|
||||
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
|
||||
}
|
||||
}
|
||||
|
||||
if len(claudeRequest.StopSequences) == 1 {
|
||||
openAIRequest.Stop = claudeRequest.StopSequences[0]
|
||||
} else if len(claudeRequest.StopSequences) > 1 {
|
||||
openAIRequest.Stop = claudeRequest.StopSequences
|
||||
}
|
||||
|
||||
tools, _ := common.Any2Type[[]dto.Tool](claudeRequest.Tools)
|
||||
openAITools := make([]dto.ToolCallRequest, 0)
|
||||
for _, claudeTool := range tools {
|
||||
openAITool := dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: claudeTool.Name,
|
||||
Description: claudeTool.Description,
|
||||
Parameters: claudeTool.InputSchema,
|
||||
},
|
||||
}
|
||||
openAITools = append(openAITools, openAITool)
|
||||
}
|
||||
openAIRequest.Tools = openAITools
|
||||
|
||||
openAIMessages := make([]dto.Message, 0)
|
||||
if claudeRequest.System != nil {
|
||||
if claudeRequest.IsStringSystem() && claudeRequest.GetStringSystem() != "" {
|
||||
openAIMessage := dto.Message{
|
||||
Role: "system",
|
||||
}
|
||||
openAIMessage.SetStringContent(claudeRequest.GetStringSystem())
|
||||
openAIMessages = append(openAIMessages, openAIMessage)
|
||||
} else {
|
||||
systems := claudeRequest.ParseSystem()
|
||||
if len(systems) > 0 {
|
||||
openAIMessage := dto.Message{
|
||||
Role: "system",
|
||||
}
|
||||
isOpenRouterClaude := isOpenRouter && strings.HasPrefix(relaymeta.RelayInfoUpstreamModelName(info), "anthropic/claude")
|
||||
if isOpenRouterClaude {
|
||||
systemMediaMessages := make([]dto.MediaContent, 0, len(systems))
|
||||
for _, system := range systems {
|
||||
message := dto.MediaContent{
|
||||
Type: "text",
|
||||
Text: system.GetText(),
|
||||
CacheControl: system.CacheControl,
|
||||
}
|
||||
systemMediaMessages = append(systemMediaMessages, message)
|
||||
}
|
||||
openAIMessage.SetMediaContent(systemMediaMessages)
|
||||
} else {
|
||||
systemStr := ""
|
||||
for _, system := range systems {
|
||||
if system.Text != nil {
|
||||
systemStr += *system.Text
|
||||
}
|
||||
}
|
||||
openAIMessage.SetStringContent(systemStr)
|
||||
}
|
||||
openAIMessages = append(openAIMessages, openAIMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, claudeMessage := range claudeRequest.Messages {
|
||||
openAIMessage := dto.Message{
|
||||
Role: claudeMessage.Role,
|
||||
}
|
||||
if claudeMessage.IsStringContent() {
|
||||
openAIMessage.SetStringContent(claudeMessage.GetStringContent())
|
||||
} else {
|
||||
content, err := claudeMessage.ParseContent()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var toolCalls []dto.ToolCallRequest
|
||||
mediaMessages := make([]dto.MediaContent, 0, len(content))
|
||||
|
||||
for _, mediaMsg := range content {
|
||||
switch mediaMsg.Type {
|
||||
case "text", "input_text":
|
||||
message := dto.MediaContent{
|
||||
Type: "text",
|
||||
Text: mediaMsg.GetText(),
|
||||
CacheControl: mediaMsg.CacheControl,
|
||||
}
|
||||
mediaMessages = append(mediaMessages, message)
|
||||
case "image":
|
||||
imageData := fmt.Sprintf("data:%s;base64,%s", mediaMsg.Source.MediaType, mediaMsg.Source.Data)
|
||||
mediaMessage := dto.MediaContent{
|
||||
Type: "image_url",
|
||||
ImageUrl: &dto.MessageImageUrl{Url: imageData},
|
||||
}
|
||||
mediaMessages = append(mediaMessages, mediaMessage)
|
||||
case "tool_use":
|
||||
toolCall := dto.ToolCallRequest{
|
||||
ID: mediaMsg.Id,
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: mediaMsg.Name,
|
||||
Arguments: requestToJSONString(mediaMsg.Input),
|
||||
},
|
||||
}
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
case "tool_result":
|
||||
toolName := mediaMsg.Name
|
||||
if toolName == "" {
|
||||
toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId)
|
||||
}
|
||||
oaiToolMessage := dto.Message{
|
||||
Role: "tool",
|
||||
Name: &toolName,
|
||||
ToolCallId: mediaMsg.ToolUseId,
|
||||
}
|
||||
if mediaMsg.IsStringContent() {
|
||||
oaiToolMessage.SetStringContent(mediaMsg.GetStringContent())
|
||||
} else {
|
||||
mediaContents := mediaMsg.ParseMediaContent()
|
||||
encodedJSON, _ := common.Marshal(mediaContents)
|
||||
oaiToolMessage.SetStringContent(string(encodedJSON))
|
||||
}
|
||||
openAIMessages = append(openAIMessages, oaiToolMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if len(toolCalls) > 0 {
|
||||
openAIMessage.SetToolCalls(toolCalls)
|
||||
}
|
||||
if len(mediaMessages) > 0 && len(toolCalls) == 0 {
|
||||
openAIMessage.SetMediaContent(mediaMessages)
|
||||
}
|
||||
}
|
||||
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 {
|
||||
openAIMessages = append(openAIMessages, openAIMessage)
|
||||
}
|
||||
}
|
||||
|
||||
openAIRequest.Messages = openAIMessages
|
||||
return &openAIRequest, nil
|
||||
}
|
||||
|
||||
func requestToJSONString(v interface{}) string {
|
||||
b, err := common.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package claudemessages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/relay/reasonmap"
|
||||
sharedclaude "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/claude"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
type ClaudeResponseInfo struct {
|
||||
ResponseId string
|
||||
Created int64
|
||||
Model string
|
||||
ResponseText strings.Builder
|
||||
Usage *dto.Usage
|
||||
Done bool
|
||||
}
|
||||
|
||||
func StopReasonClaudeToOpenAI(reason string) string {
|
||||
return reasonmap.ClaudeStopReasonToOpenAIFinishReason(reason)
|
||||
}
|
||||
|
||||
func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse {
|
||||
var response dto.ChatCompletionsStreamResponse
|
||||
response.Object = "chat.completion.chunk"
|
||||
response.Model = claudeResponse.Model
|
||||
response.Choices = make([]dto.ChatCompletionsStreamResponseChoice, 0)
|
||||
tools := make([]dto.ToolCallResponse, 0)
|
||||
fcIdx := 0
|
||||
if claudeResponse.Index != nil {
|
||||
fcIdx = *claudeResponse.Index
|
||||
}
|
||||
var choice dto.ChatCompletionsStreamResponseChoice
|
||||
if claudeResponse.Type == "message_start" {
|
||||
if claudeResponse.Message != nil {
|
||||
response.Id = claudeResponse.Message.Id
|
||||
response.Model = claudeResponse.Message.Model
|
||||
}
|
||||
choice.Delta.SetContentString("")
|
||||
choice.Delta.Role = "assistant"
|
||||
} else if claudeResponse.Type == "content_block_start" {
|
||||
if claudeResponse.ContentBlock != nil {
|
||||
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
|
||||
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
|
||||
}
|
||||
if claudeResponse.ContentBlock.Type == "tool_use" {
|
||||
tools = append(tools, dto.ToolCallResponse{
|
||||
Index: common.GetPointer(fcIdx),
|
||||
ID: claudeResponse.ContentBlock.Id,
|
||||
Type: "function",
|
||||
Function: dto.FunctionResponse{
|
||||
Name: claudeResponse.ContentBlock.Name,
|
||||
Arguments: "",
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
} else if claudeResponse.Type == "content_block_delta" {
|
||||
if claudeResponse.Delta != nil {
|
||||
choice.Delta.Content = claudeResponse.Delta.Text
|
||||
switch claudeResponse.Delta.Type {
|
||||
case "input_json_delta":
|
||||
tools = append(tools, dto.ToolCallResponse{
|
||||
Type: "function",
|
||||
Index: common.GetPointer(fcIdx),
|
||||
Function: dto.FunctionResponse{
|
||||
Arguments: *claudeResponse.Delta.PartialJson,
|
||||
},
|
||||
})
|
||||
case "signature_delta":
|
||||
signatureContent := "\n"
|
||||
choice.Delta.ReasoningContent = &signatureContent
|
||||
case "thinking_delta":
|
||||
choice.Delta.ReasoningContent = claudeResponse.Delta.Thinking
|
||||
}
|
||||
}
|
||||
} else if claudeResponse.Type == "message_delta" {
|
||||
if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {
|
||||
finishReason := StopReasonClaudeToOpenAI(*claudeResponse.Delta.StopReason)
|
||||
if finishReason != "null" {
|
||||
choice.FinishReason = &finishReason
|
||||
}
|
||||
}
|
||||
} else if claudeResponse.Type == "message_stop" {
|
||||
return nil
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
choice.Delta.Content = nil
|
||||
choice.Delta.ToolCalls = tools
|
||||
}
|
||||
response.Choices = append(response.Choices, choice)
|
||||
|
||||
return &response
|
||||
}
|
||||
|
||||
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
|
||||
choices := make([]dto.OpenAITextResponseChoice, 0)
|
||||
fullTextResponse := dto.OpenAITextResponse{
|
||||
Id: fmt.Sprintf("chatcmpl-%s", common.GetUUID()),
|
||||
Object: "chat.completion",
|
||||
Created: common.GetTimestamp(),
|
||||
}
|
||||
var responseText string
|
||||
var responseThinking string
|
||||
if len(claudeResponse.Content) > 0 {
|
||||
responseText = claudeResponse.Content[0].GetText()
|
||||
if claudeResponse.Content[0].Thinking != nil {
|
||||
responseThinking = *claudeResponse.Content[0].Thinking
|
||||
}
|
||||
}
|
||||
tools := make([]dto.ToolCallResponse, 0)
|
||||
thinkingContent := ""
|
||||
|
||||
fullTextResponse.Id = claudeResponse.Id
|
||||
for _, message := range claudeResponse.Content {
|
||||
switch message.Type {
|
||||
case "tool_use":
|
||||
args, _ := common.Marshal(message.Input)
|
||||
tools = append(tools, dto.ToolCallResponse{
|
||||
ID: message.Id,
|
||||
Type: "function",
|
||||
Function: dto.FunctionResponse{
|
||||
Name: message.Name,
|
||||
Arguments: string(args),
|
||||
},
|
||||
})
|
||||
case "thinking":
|
||||
if message.Thinking != nil {
|
||||
thinkingContent = *message.Thinking
|
||||
}
|
||||
case "text":
|
||||
responseText = message.GetText()
|
||||
}
|
||||
}
|
||||
choice := dto.OpenAITextResponseChoice{
|
||||
Index: 0,
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
},
|
||||
FinishReason: StopReasonClaudeToOpenAI(claudeResponse.StopReason),
|
||||
}
|
||||
choice.SetStringContent(responseText)
|
||||
if len(responseThinking) > 0 {
|
||||
choice.ReasoningContent = &responseThinking
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
choice.Message.SetToolCalls(tools)
|
||||
}
|
||||
if thinkingContent != "" {
|
||||
choice.Message.ReasoningContent = &thinkingContent
|
||||
}
|
||||
fullTextResponse.Model = claudeResponse.Model
|
||||
choices = append(choices, choice)
|
||||
fullTextResponse.Choices = choices
|
||||
return &fullTextResponse
|
||||
}
|
||||
|
||||
func UsageFromClaudeAPIUsage(usage *dto.ClaudeUsage) *dto.Usage {
|
||||
if usage == nil {
|
||||
return &dto.Usage{}
|
||||
}
|
||||
semanticUsage := &dto.Usage{
|
||||
PromptTokens: usage.InputTokens,
|
||||
CompletionTokens: usage.OutputTokens,
|
||||
UsageSemantic: "anthropic",
|
||||
UsageSource: "anthropic",
|
||||
BillingUsage: dto.CloneBillingUsage(usage.BillingUsage),
|
||||
}
|
||||
if semanticUsage.BillingUsage == nil {
|
||||
semanticUsage.BillingUsage = dto.NewClaudeMessagesBillingUsage(usage)
|
||||
}
|
||||
semanticUsage.PromptTokensDetails.CachedTokens = usage.CacheReadInputTokens
|
||||
semanticUsage.PromptTokensDetails.CachedCreationTokens = usage.CacheCreationInputTokens
|
||||
semanticUsage.ClaudeCacheCreation5mTokens = usage.GetCacheCreation5mTokens()
|
||||
semanticUsage.ClaudeCacheCreation1hTokens = usage.GetCacheCreation1hTokens()
|
||||
return UsageFromClaudeUsage(semanticUsage)
|
||||
}
|
||||
|
||||
func UsageFromClaudeUsage(usage *dto.Usage) *dto.Usage {
|
||||
mapped := buildOpenAIStyleUsageFromClaudeUsage(usage)
|
||||
return &mapped
|
||||
}
|
||||
|
||||
func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int {
|
||||
if usage == nil {
|
||||
return 0
|
||||
}
|
||||
splitCacheCreationTokens := usage.ClaudeCacheCreation5mTokens + usage.ClaudeCacheCreation1hTokens
|
||||
if splitCacheCreationTokens == 0 {
|
||||
return usage.PromptTokensDetails.CachedCreationTokens
|
||||
}
|
||||
if usage.PromptTokensDetails.CachedCreationTokens > splitCacheCreationTokens {
|
||||
return usage.PromptTokensDetails.CachedCreationTokens
|
||||
}
|
||||
return splitCacheCreationTokens
|
||||
}
|
||||
|
||||
func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
|
||||
if usage == nil {
|
||||
return dto.Usage{}
|
||||
}
|
||||
clone := *usage
|
||||
clone.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
|
||||
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = sharedclaude.NormalizeCacheCreationSplit(
|
||||
usage.PromptTokensDetails.CachedCreationTokens,
|
||||
usage.ClaudeCacheCreation5mTokens,
|
||||
usage.ClaudeCacheCreation1hTokens,
|
||||
)
|
||||
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
|
||||
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
|
||||
clone.PromptTokens = totalInputTokens
|
||||
clone.InputTokens = totalInputTokens
|
||||
clone.TotalTokens = totalInputTokens + usage.CompletionTokens
|
||||
clone.UsageSemantic = "openai"
|
||||
clone.UsageSource = "anthropic"
|
||||
return clone
|
||||
}
|
||||
|
||||
func BuildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage {
|
||||
usage := &dto.ClaudeUsage{}
|
||||
if claudeResponse != nil && claudeResponse.Usage != nil {
|
||||
*usage = *claudeResponse.Usage
|
||||
}
|
||||
|
||||
if claudeInfo == nil || claudeInfo.Usage == nil {
|
||||
return usage
|
||||
}
|
||||
|
||||
if usage.InputTokens == 0 && claudeInfo.Usage.PromptTokens > 0 {
|
||||
usage.InputTokens = claudeInfo.Usage.PromptTokens
|
||||
}
|
||||
if usage.CacheReadInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedTokens > 0 {
|
||||
usage.CacheReadInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedTokens
|
||||
}
|
||||
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
|
||||
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
|
||||
}
|
||||
cacheCreation5m := 0
|
||||
cacheCreation1h := 0
|
||||
if usage.CacheCreation != nil {
|
||||
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
|
||||
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
|
||||
} else {
|
||||
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
|
||||
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
|
||||
}
|
||||
cacheCreation5m, cacheCreation1h = sharedclaude.NormalizeCacheCreationSplit(
|
||||
usage.CacheCreationInputTokens,
|
||||
cacheCreation5m,
|
||||
cacheCreation1h,
|
||||
)
|
||||
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
|
||||
}
|
||||
if usage.CacheCreation != nil {
|
||||
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
|
||||
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func claudeBillingUsageFromSemanticUsage(usage *dto.Usage) *dto.BillingUsage {
|
||||
if usage == nil {
|
||||
return nil
|
||||
}
|
||||
cacheCreation5m, cacheCreation1h := sharedclaude.NormalizeCacheCreationSplit(
|
||||
usage.PromptTokensDetails.CachedCreationTokens,
|
||||
usage.ClaudeCacheCreation5mTokens,
|
||||
usage.ClaudeCacheCreation1hTokens,
|
||||
)
|
||||
claudeUsage := &dto.ClaudeUsage{
|
||||
InputTokens: usage.PromptTokens,
|
||||
CacheCreationInputTokens: usage.PromptTokensDetails.CachedCreationTokens,
|
||||
CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens,
|
||||
OutputTokens: usage.CompletionTokens,
|
||||
}
|
||||
if cacheCreation5m > 0 || cacheCreation1h > 0 {
|
||||
claudeUsage.CacheCreation = &dto.ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: cacheCreation5m,
|
||||
Ephemeral1hInputTokens: cacheCreation1h,
|
||||
}
|
||||
}
|
||||
return dto.NewClaudeMessagesBillingUsage(claudeUsage)
|
||||
}
|
||||
|
||||
func PatchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
|
||||
if data == "" || usage == nil {
|
||||
return data
|
||||
}
|
||||
|
||||
data = setMessageDeltaUsageInt(data, "usage.input_tokens", usage.InputTokens)
|
||||
data = setMessageDeltaUsageInt(data, "usage.cache_read_input_tokens", usage.CacheReadInputTokens)
|
||||
data = setMessageDeltaUsageInt(data, "usage.cache_creation_input_tokens", usage.CacheCreationInputTokens)
|
||||
|
||||
if usage.CacheCreation != nil {
|
||||
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_5m_input_tokens", usage.CacheCreation.Ephemeral5mInputTokens)
|
||||
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_1h_input_tokens", usage.CacheCreation.Ephemeral1hInputTokens)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func setMessageDeltaUsageInt(data string, path string, localValue int) string {
|
||||
if localValue <= 0 {
|
||||
return data
|
||||
}
|
||||
|
||||
upstreamValue := gjson.Get(data, path)
|
||||
if upstreamValue.Exists() && upstreamValue.Int() > 0 {
|
||||
return data
|
||||
}
|
||||
|
||||
patchedData, err := sjson.Set(data, path, localValue)
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
return patchedData
|
||||
}
|
||||
|
||||
func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
|
||||
if claudeInfo == nil {
|
||||
return false
|
||||
}
|
||||
if claudeInfo.Usage == nil {
|
||||
claudeInfo.Usage = &dto.Usage{}
|
||||
}
|
||||
if claudeResponse.Type == "message_start" {
|
||||
if claudeResponse.Message != nil {
|
||||
claudeInfo.ResponseId = claudeResponse.Message.Id
|
||||
claudeInfo.Model = claudeResponse.Message.Model
|
||||
}
|
||||
|
||||
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
|
||||
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
|
||||
claudeInfo.Usage.UsageSemantic = "anthropic"
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
|
||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
|
||||
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
|
||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
|
||||
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
|
||||
}
|
||||
} else if claudeResponse.Type == "content_block_delta" {
|
||||
if claudeResponse.Delta != nil {
|
||||
if claudeResponse.Delta.Text != nil {
|
||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
|
||||
}
|
||||
if claudeResponse.Delta.Thinking != nil {
|
||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
|
||||
}
|
||||
}
|
||||
} else if claudeResponse.Type == "message_delta" {
|
||||
if claudeResponse.Usage != nil {
|
||||
claudeInfo.Usage.UsageSemantic = "anthropic"
|
||||
if claudeResponse.Usage.InputTokens > 0 {
|
||||
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
|
||||
}
|
||||
if claudeResponse.Usage.CacheReadInputTokens > 0 {
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
|
||||
}
|
||||
if claudeResponse.Usage.CacheCreationInputTokens > 0 {
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
|
||||
}
|
||||
if cacheCreation5m := claudeResponse.Usage.GetCacheCreation5mTokens(); cacheCreation5m > 0 {
|
||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = cacheCreation5m
|
||||
}
|
||||
if cacheCreation1h := claudeResponse.Usage.GetCacheCreation1hTokens(); cacheCreation1h > 0 {
|
||||
claudeInfo.Usage.ClaudeCacheCreation1hTokens = cacheCreation1h
|
||||
}
|
||||
if claudeResponse.Usage.OutputTokens > 0 {
|
||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
|
||||
}
|
||||
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
|
||||
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
|
||||
}
|
||||
|
||||
claudeInfo.Done = true
|
||||
} else if claudeResponse.Type == "content_block_start" {
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
if oaiResponse != nil {
|
||||
oaiResponse.Id = claudeInfo.ResponseId
|
||||
oaiResponse.Created = claudeInfo.Created
|
||||
oaiResponse.Model = claudeInfo.Model
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package geminichat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service/relayconvert/internal/jsonutil"
|
||||
relaymeta "github.com/QuantumNous/new-api/service/relayconvert/internal/meta"
|
||||
)
|
||||
|
||||
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||
modelName := ""
|
||||
isStream := false
|
||||
if info != nil {
|
||||
isStream = info.IsStream
|
||||
}
|
||||
modelName = relaymeta.RelayInfoUpstreamModelName(info)
|
||||
openaiRequest := &dto.GeneralOpenAIRequest{
|
||||
Model: modelName,
|
||||
Stream: common.GetPointer(isStream),
|
||||
}
|
||||
|
||||
var messages []dto.Message
|
||||
for _, content := range geminiRequest.Contents {
|
||||
message := dto.Message{
|
||||
Role: convertGeminiRoleToOpenAI(content.Role),
|
||||
}
|
||||
|
||||
var mediaContents []dto.MediaContent
|
||||
var toolCalls []dto.ToolCallRequest
|
||||
for _, part := range content.Parts {
|
||||
if part.Text != "" {
|
||||
mediaContent := dto.MediaContent{
|
||||
Type: "text",
|
||||
Text: part.Text,
|
||||
}
|
||||
mediaContents = append(mediaContents, mediaContent)
|
||||
} else if part.InlineData != nil {
|
||||
mediaContent := dto.MediaContent{
|
||||
Type: "image_url",
|
||||
ImageUrl: &dto.MessageImageUrl{
|
||||
Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data),
|
||||
Detail: "auto",
|
||||
MimeType: part.InlineData.MimeType,
|
||||
},
|
||||
}
|
||||
mediaContents = append(mediaContents, mediaContent)
|
||||
} else if part.FileData != nil {
|
||||
mediaContent := dto.MediaContent{
|
||||
Type: "image_url",
|
||||
ImageUrl: &dto.MessageImageUrl{
|
||||
Url: part.FileData.FileUri,
|
||||
Detail: "auto",
|
||||
MimeType: part.FileData.MimeType,
|
||||
},
|
||||
}
|
||||
mediaContents = append(mediaContents, mediaContent)
|
||||
} else if part.FunctionCall != nil {
|
||||
toolCall := dto.ToolCallRequest{
|
||||
ID: fmt.Sprintf("call_%d", len(toolCalls)+1),
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: part.FunctionCall.FunctionName,
|
||||
Arguments: jsonutil.ToJSONString(part.FunctionCall.Arguments),
|
||||
},
|
||||
}
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
} else if part.FunctionResponse != nil {
|
||||
toolMessage := dto.Message{
|
||||
Role: "tool",
|
||||
ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)),
|
||||
}
|
||||
toolMessage.SetStringContent(jsonutil.ToJSONString(part.FunctionResponse.Response))
|
||||
messages = append(messages, toolMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if len(toolCalls) > 0 {
|
||||
message.SetToolCalls(toolCalls)
|
||||
} else if len(mediaContents) == 1 && mediaContents[0].Type == "text" {
|
||||
message.Content = mediaContents[0].Text
|
||||
} else if len(mediaContents) > 0 {
|
||||
message.SetMediaContent(mediaContents)
|
||||
}
|
||||
|
||||
if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 {
|
||||
messages = append(messages, message)
|
||||
}
|
||||
}
|
||||
|
||||
openaiRequest.Messages = messages
|
||||
|
||||
if geminiRequest.GenerationConfig.Temperature != nil {
|
||||
openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature
|
||||
}
|
||||
if geminiRequest.GenerationConfig.TopP != nil && *geminiRequest.GenerationConfig.TopP > 0 {
|
||||
openaiRequest.TopP = common.GetPointer(*geminiRequest.GenerationConfig.TopP)
|
||||
}
|
||||
if geminiRequest.GenerationConfig.TopK != nil && *geminiRequest.GenerationConfig.TopK > 0 {
|
||||
openaiRequest.TopK = common.GetPointer(int(*geminiRequest.GenerationConfig.TopK))
|
||||
}
|
||||
if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
|
||||
openaiRequest.MaxTokens = common.GetPointer(*geminiRequest.GenerationConfig.MaxOutputTokens)
|
||||
}
|
||||
if len(geminiRequest.GenerationConfig.StopSequences) > 0 {
|
||||
openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:min(len(geminiRequest.GenerationConfig.StopSequences), 4)]
|
||||
}
|
||||
if geminiRequest.GenerationConfig.CandidateCount != nil && *geminiRequest.GenerationConfig.CandidateCount > 0 {
|
||||
openaiRequest.N = common.GetPointer(*geminiRequest.GenerationConfig.CandidateCount)
|
||||
}
|
||||
|
||||
if len(geminiRequest.GetTools()) > 0 {
|
||||
var tools []dto.ToolCallRequest
|
||||
for _, tool := range geminiRequest.GetTools() {
|
||||
if tool.FunctionDeclarations == nil {
|
||||
continue
|
||||
}
|
||||
functionDeclarations, err := common.Any2Type[[]dto.FunctionRequest](tool.FunctionDeclarations)
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to parse gemini function declarations: %v (type=%T)", err, tool.FunctionDeclarations))
|
||||
continue
|
||||
}
|
||||
for _, function := range functionDeclarations {
|
||||
openAITool := dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: function.Name,
|
||||
Description: function.Description,
|
||||
Parameters: function.Parameters,
|
||||
},
|
||||
}
|
||||
tools = append(tools, openAITool)
|
||||
}
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
openaiRequest.Tools = tools
|
||||
}
|
||||
}
|
||||
|
||||
if geminiRequest.SystemInstructions != nil {
|
||||
systemMessage := dto.Message{
|
||||
Role: "system",
|
||||
Content: extractTextFromGeminiParts(geminiRequest.SystemInstructions.Parts),
|
||||
}
|
||||
openaiRequest.Messages = append([]dto.Message{systemMessage}, openaiRequest.Messages...)
|
||||
}
|
||||
|
||||
return openaiRequest, nil
|
||||
}
|
||||
|
||||
func convertGeminiRoleToOpenAI(geminiRole string) string {
|
||||
switch geminiRole {
|
||||
case "user":
|
||||
return "user"
|
||||
case "model":
|
||||
return "assistant"
|
||||
case "function":
|
||||
return "function"
|
||||
default:
|
||||
return "user"
|
||||
}
|
||||
}
|
||||
|
||||
func extractTextFromGeminiParts(parts []dto.GeminiPart) string {
|
||||
texts := make([]string, 0)
|
||||
for _, part := range parts {
|
||||
if part.Text != "" {
|
||||
texts = append(texts, part.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(texts, "\n")
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package geminichat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
func UsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) *dto.Usage {
|
||||
if metadata == nil {
|
||||
if fallbackPromptTokens <= 0 {
|
||||
return nil
|
||||
}
|
||||
usage := &dto.Usage{PromptTokens: fallbackPromptTokens}
|
||||
usage.PromptTokensDetails.TextTokens = fallbackPromptTokens
|
||||
return usage
|
||||
}
|
||||
|
||||
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
|
||||
if promptTokens <= 0 && fallbackPromptTokens > 0 {
|
||||
promptTokens = fallbackPromptTokens
|
||||
}
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
|
||||
TotalTokens: metadata.TotalTokenCount,
|
||||
BillingUsage: dto.CloneBillingUsage(metadata.BillingUsage),
|
||||
}
|
||||
if usage.BillingUsage == nil {
|
||||
usage.BillingUsage = dto.NewGeminiChatBillingUsage(metadata)
|
||||
}
|
||||
usage.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
|
||||
usage.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
|
||||
|
||||
for _, detail := range metadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
|
||||
} else if detail.Modality == "IMAGE" {
|
||||
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens += detail.TokenCount
|
||||
}
|
||||
}
|
||||
for _, detail := range metadata.ToolUsePromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
|
||||
} else if detail.Modality == "IMAGE" {
|
||||
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens += detail.TokenCount
|
||||
}
|
||||
}
|
||||
for _, detail := range metadata.CandidatesTokensDetails {
|
||||
switch detail.Modality {
|
||||
case "IMAGE":
|
||||
usage.CompletionTokenDetails.ImageTokens += detail.TokenCount
|
||||
case "AUDIO":
|
||||
usage.CompletionTokenDetails.AudioTokens += detail.TokenCount
|
||||
case "TEXT":
|
||||
usage.CompletionTokenDetails.TextTokens += detail.TokenCount
|
||||
}
|
||||
}
|
||||
|
||||
if usage.TotalTokens > 0 && usage.CompletionTokens <= 0 {
|
||||
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||
}
|
||||
|
||||
if usage.PromptTokens > 0 && usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokensDetails.AudioTokens == 0 {
|
||||
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
|
||||
}
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
|
||||
fullTextResponse := dto.OpenAITextResponse{
|
||||
Id: id,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
|
||||
}
|
||||
isToolCall := false
|
||||
for _, candidate := range response.Candidates {
|
||||
choice := dto.OpenAITextResponseChoice{
|
||||
Index: int(candidate.Index),
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
},
|
||||
FinishReason: constant.FinishReasonStop,
|
||||
}
|
||||
if len(candidate.Content.Parts) > 0 {
|
||||
var content strings.Builder
|
||||
var inlineGrow int
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
|
||||
}
|
||||
}
|
||||
if inlineGrow > 0 {
|
||||
content.Grow(inlineGrow)
|
||||
}
|
||||
appended := 0
|
||||
writeSep := func() {
|
||||
if appended > 0 {
|
||||
content.WriteByte('\n')
|
||||
}
|
||||
appended++
|
||||
}
|
||||
var toolCalls []dto.ToolCallResponse
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
if strings.HasPrefix(part.InlineData.MimeType, "image") {
|
||||
writeSep()
|
||||
content.WriteString("
|
||||
content.WriteString(part.InlineData.MimeType)
|
||||
content.WriteString(";base64,")
|
||||
content.WriteString(part.InlineData.Data)
|
||||
content.WriteByte(')')
|
||||
} else {
|
||||
writeSep()
|
||||
content.WriteString("[media](data:")
|
||||
content.WriteString(part.InlineData.MimeType)
|
||||
content.WriteString(";base64,")
|
||||
content.WriteString(part.InlineData.Data)
|
||||
content.WriteByte(')')
|
||||
}
|
||||
} else if part.FunctionCall != nil {
|
||||
choice.FinishReason = constant.FinishReasonToolCalls
|
||||
if call := geminiResponseToolCall(&part); call != nil {
|
||||
toolCalls = append(toolCalls, *call)
|
||||
}
|
||||
} else if part.Thought {
|
||||
choice.Message.ReasoningContent = &part.Text
|
||||
} else {
|
||||
if part.ExecutableCode != nil {
|
||||
writeSep()
|
||||
content.WriteString("```")
|
||||
content.WriteString(part.ExecutableCode.Language)
|
||||
content.WriteByte('\n')
|
||||
content.WriteString(part.ExecutableCode.Code)
|
||||
content.WriteString("\n```")
|
||||
} else if part.CodeExecutionResult != nil {
|
||||
writeSep()
|
||||
content.WriteString("```output\n")
|
||||
content.WriteString(part.CodeExecutionResult.Output)
|
||||
content.WriteString("\n```")
|
||||
} else if part.Text != "\n" {
|
||||
writeSep()
|
||||
content.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
choice.Message.SetToolCalls(toolCalls)
|
||||
isToolCall = true
|
||||
}
|
||||
choice.Message.SetStringContent(content.String())
|
||||
}
|
||||
if candidate.FinishReason != nil {
|
||||
switch *candidate.FinishReason {
|
||||
case "STOP":
|
||||
choice.FinishReason = constant.FinishReasonStop
|
||||
case "MAX_TOKENS":
|
||||
choice.FinishReason = constant.FinishReasonLength
|
||||
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
|
||||
choice.FinishReason = constant.FinishReasonContentFilter
|
||||
default:
|
||||
choice.FinishReason = constant.FinishReasonContentFilter
|
||||
}
|
||||
}
|
||||
if isToolCall {
|
||||
choice.FinishReason = constant.FinishReasonToolCalls
|
||||
}
|
||||
|
||||
fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
|
||||
}
|
||||
return &fullTextResponse
|
||||
}
|
||||
|
||||
func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
|
||||
choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates))
|
||||
isStop := false
|
||||
for _, candidate := range geminiResponse.Candidates {
|
||||
if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" {
|
||||
isStop = true
|
||||
candidate.FinishReason = nil
|
||||
}
|
||||
choice := dto.ChatCompletionsStreamResponseChoice{
|
||||
Index: int(candidate.Index),
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
|
||||
}
|
||||
var content strings.Builder
|
||||
var inlineGrow int
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
|
||||
}
|
||||
}
|
||||
if inlineGrow > 0 {
|
||||
content.Grow(inlineGrow)
|
||||
}
|
||||
appended := 0
|
||||
writeSep := func() {
|
||||
if appended > 0 {
|
||||
content.WriteByte('\n')
|
||||
}
|
||||
appended++
|
||||
}
|
||||
isTools := false
|
||||
isThought := false
|
||||
if candidate.FinishReason != nil {
|
||||
switch *candidate.FinishReason {
|
||||
case "STOP":
|
||||
choice.FinishReason = &constant.FinishReasonStop
|
||||
case "MAX_TOKENS":
|
||||
choice.FinishReason = &constant.FinishReasonLength
|
||||
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
|
||||
choice.FinishReason = &constant.FinishReasonContentFilter
|
||||
default:
|
||||
choice.FinishReason = &constant.FinishReasonContentFilter
|
||||
}
|
||||
}
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
if strings.HasPrefix(part.InlineData.MimeType, "image") {
|
||||
writeSep()
|
||||
content.WriteString("
|
||||
content.WriteString(part.InlineData.MimeType)
|
||||
content.WriteString(";base64,")
|
||||
content.WriteString(part.InlineData.Data)
|
||||
content.WriteByte(')')
|
||||
}
|
||||
} else if part.FunctionCall != nil {
|
||||
isTools = true
|
||||
if call := geminiResponseToolCall(&part); call != nil {
|
||||
call.SetIndex(len(choice.Delta.ToolCalls))
|
||||
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call)
|
||||
}
|
||||
} else if part.Thought {
|
||||
isThought = true
|
||||
writeSep()
|
||||
content.WriteString(part.Text)
|
||||
} else {
|
||||
if part.ExecutableCode != nil {
|
||||
writeSep()
|
||||
content.WriteString("```")
|
||||
content.WriteString(part.ExecutableCode.Language)
|
||||
content.WriteByte('\n')
|
||||
content.WriteString(part.ExecutableCode.Code)
|
||||
content.WriteString("\n```\n")
|
||||
} else if part.CodeExecutionResult != nil {
|
||||
writeSep()
|
||||
content.WriteString("```output\n")
|
||||
content.WriteString(part.CodeExecutionResult.Output)
|
||||
content.WriteString("\n```\n")
|
||||
} else if part.Text != "\n" {
|
||||
writeSep()
|
||||
content.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if isThought {
|
||||
choice.Delta.SetReasoningContent(content.String())
|
||||
} else {
|
||||
choice.Delta.SetContentString(content.String())
|
||||
}
|
||||
if isTools {
|
||||
choice.FinishReason = &constant.FinishReasonToolCalls
|
||||
}
|
||||
choices = append(choices, choice)
|
||||
}
|
||||
|
||||
response := dto.ChatCompletionsStreamResponse{
|
||||
Object: "chat.completion.chunk",
|
||||
Choices: choices,
|
||||
}
|
||||
return &response, isStop
|
||||
}
|
||||
|
||||
func geminiResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
|
||||
argsBytes, err := common.Marshal(item.FunctionCall.Arguments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.ToolCallResponse{
|
||||
ID: fmt.Sprintf("call_%s", common.GetUUID()),
|
||||
Type: "function",
|
||||
Function: dto.FunctionResponse{
|
||||
Arguments: string(argsBytes),
|
||||
Name: item.FunctionCall.FunctionName,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package jsonutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
func ToJSONString(v interface{}) string {
|
||||
bytes, err := common.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(bytes)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package relayconvert
|
||||
package matcher
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
var compiledRegexCache sync.Map // map[string]*regexp.Regexp
|
||||
|
||||
func matchAnyRegex(patterns []string, s string) bool {
|
||||
func MatchAnyRegex(patterns []string, s string) bool {
|
||||
if len(patterns) == 0 || s == "" {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type MediaResolver struct {
|
||||
GetBase64Data func(c *gin.Context, source types.FileSource, reason ...string) (string, string, error)
|
||||
DecodeBase64FileData func(base64String string) (string, string, error)
|
||||
}
|
||||
|
||||
var (
|
||||
mediaResolverMu sync.RWMutex
|
||||
mediaResolver MediaResolver
|
||||
)
|
||||
|
||||
func SetMediaResolver(resolver MediaResolver) {
|
||||
mediaResolverMu.Lock()
|
||||
defer mediaResolverMu.Unlock()
|
||||
|
||||
mediaResolver = resolver
|
||||
}
|
||||
|
||||
func ResolveBase64Data(c *gin.Context, source types.FileSource, reason ...string) (string, string, error) {
|
||||
mediaResolverMu.RLock()
|
||||
resolver := mediaResolver.GetBase64Data
|
||||
mediaResolverMu.RUnlock()
|
||||
if resolver == nil {
|
||||
return "", "", errors.New("relayconvert media resolver is not configured")
|
||||
}
|
||||
return resolver(c, source, reason...)
|
||||
}
|
||||
|
||||
func DecodeBase64FileData(base64String string) (string, string, error) {
|
||||
mediaResolverMu.RLock()
|
||||
resolver := mediaResolver.DecodeBase64FileData
|
||||
mediaResolverMu.RUnlock()
|
||||
if resolver == nil {
|
||||
return "", "", errors.New("relayconvert media resolver is not configured")
|
||||
}
|
||||
return resolver(base64String)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package meta
|
||||
|
||||
import relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
|
||||
func RelayInfoChannelType(info *relaycommon.RelayInfo) int {
|
||||
if info == nil || info.ChannelMeta == nil {
|
||||
return 0
|
||||
}
|
||||
return info.ChannelType
|
||||
}
|
||||
|
||||
func RelayInfoUpstreamModelName(info *relaycommon.RelayInfo) string {
|
||||
if info == nil || info.ChannelMeta == nil {
|
||||
return ""
|
||||
}
|
||||
return info.UpstreamModelName
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package oaichat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaymedia "github.com/QuantumNous/new-api/service/relayconvert/internal/media"
|
||||
sharedclaude "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/claude"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/setting/reasoning"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
webSearchMaxUsesLow = 1
|
||||
webSearchMaxUsesMedium = 5
|
||||
webSearchMaxUsesHigh = 10
|
||||
)
|
||||
|
||||
type openRouterRequestReasoning struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Effort string `json:"effort,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Exclude bool `json:"exclude,omitempty"`
|
||||
}
|
||||
|
||||
func OpenAIChatRequestToClaudeMessages(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
|
||||
claudeTools := make([]any, 0, len(textRequest.Tools))
|
||||
|
||||
for _, tool := range textRequest.Tools {
|
||||
if params, ok := tool.Function.Parameters.(map[string]any); ok {
|
||||
claudeTool := dto.Tool{
|
||||
Name: tool.Function.Name,
|
||||
Description: tool.Function.Description,
|
||||
}
|
||||
claudeTool.InputSchema = make(map[string]interface{})
|
||||
if params["type"] != nil {
|
||||
claudeTool.InputSchema["type"] = params["type"].(string)
|
||||
}
|
||||
claudeTool.InputSchema["properties"] = params["properties"]
|
||||
claudeTool.InputSchema["required"] = params["required"]
|
||||
for key, value := range params {
|
||||
if key == "type" || key == "properties" || key == "required" {
|
||||
continue
|
||||
}
|
||||
claudeTool.InputSchema[key] = value
|
||||
}
|
||||
claudeTools = append(claudeTools, &claudeTool)
|
||||
}
|
||||
}
|
||||
|
||||
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]interface{}
|
||||
if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
|
||||
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); 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
|
||||
}
|
||||
|
||||
switch textRequest.WebSearchOptions.SearchContextSize {
|
||||
case "low":
|
||||
webSearchTool.MaxUses = webSearchMaxUsesLow
|
||||
case "medium":
|
||||
webSearchTool.MaxUses = webSearchMaxUsesMedium
|
||||
case "high":
|
||||
webSearchTool.MaxUses = webSearchMaxUsesHigh
|
||||
}
|
||||
|
||||
claudeTools = append(claudeTools, &webSearchTool)
|
||||
}
|
||||
|
||||
claudeRequest := dto.ClaudeRequest{
|
||||
Model: textRequest.Model,
|
||||
StopSequences: nil,
|
||||
Temperature: textRequest.Temperature,
|
||||
Tools: claudeTools,
|
||||
}
|
||||
if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 {
|
||||
claudeRequest.MaxTokens = common.GetPointer(maxTokens)
|
||||
}
|
||||
if textRequest.TopP != nil {
|
||||
claudeRequest.TopP = common.GetPointer(*textRequest.TopP)
|
||||
}
|
||||
if textRequest.TopK != nil {
|
||||
claudeRequest.TopK = common.GetPointer(*textRequest.TopK)
|
||||
}
|
||||
if textRequest.IsStream(nil) {
|
||||
claudeRequest.Stream = common.GetPointer(true)
|
||||
}
|
||||
|
||||
if textRequest.ToolChoice != nil || textRequest.ParallelTooCalls != nil {
|
||||
claudeToolChoice := sharedclaude.MapOpenAIToolChoice(textRequest.ToolChoice, textRequest.ParallelTooCalls)
|
||||
if claudeToolChoice != nil {
|
||||
claudeRequest.ToolChoice = claudeToolChoice
|
||||
}
|
||||
}
|
||||
|
||||
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 {
|
||||
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model))
|
||||
claudeRequest.MaxTokens = &defaultMaxTokens
|
||||
}
|
||||
|
||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
|
||||
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") ||
|
||||
strings.HasPrefix(textRequest.Model, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) {
|
||||
claudeRequest.Model = baseModel
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "adaptive",
|
||||
}
|
||||
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
|
||||
claudeRequest.Thinking.Display = "summarized"
|
||||
claudeRequest.Temperature = nil
|
||||
claudeRequest.TopP = nil
|
||||
claudeRequest.TopK = nil
|
||||
} else {
|
||||
claudeRequest.TopP = nil
|
||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||
}
|
||||
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
|
||||
strings.HasSuffix(textRequest.Model, "-thinking") {
|
||||
|
||||
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(trimmedModel, "claude-opus-4-8") {
|
||||
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||
claudeRequest.Temperature = nil
|
||||
claudeRequest.TopP = nil
|
||||
claudeRequest.TopK = nil
|
||||
} else {
|
||||
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 {
|
||||
claudeRequest.MaxTokens = common.GetPointer[uint](1280)
|
||||
}
|
||||
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: common.GetPointer[int](int(float64(*claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
|
||||
}
|
||||
claudeRequest.TopP = nil
|
||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||
}
|
||||
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
|
||||
claudeRequest.Model = trimmedModel
|
||||
}
|
||||
}
|
||||
|
||||
if textRequest.ReasoningEffort != "" {
|
||||
switch textRequest.ReasoningEffort {
|
||||
case "low":
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: common.GetPointer[int](1280),
|
||||
}
|
||||
case "medium":
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: common.GetPointer[int](2048),
|
||||
}
|
||||
case "high":
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: common.GetPointer[int](4096),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if textRequest.Reasoning != nil {
|
||||
var reasoningConfig openRouterRequestReasoning
|
||||
if err := common.Unmarshal(textRequest.Reasoning, &reasoningConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
budgetTokens := reasoningConfig.MaxTokens
|
||||
if budgetTokens > 0 {
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: &budgetTokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if textRequest.Stop != nil {
|
||||
switch stop := textRequest.Stop.(type) {
|
||||
case string:
|
||||
claudeRequest.StopSequences = []string{stop}
|
||||
case []interface{}:
|
||||
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 i, message := range textRequest.Messages {
|
||||
if message.Role == "" {
|
||||
textRequest.Messages[i].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(strings.Trim(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
|
||||
|
||||
for _, message := range formatMessages {
|
||||
if message.Role == "system" {
|
||||
if message.IsStringContent() {
|
||||
if text := message.StringContent(); text != "" {
|
||||
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](text),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
for _, ctx := range message.ParseContent() {
|
||||
if ctx.Type == "text" && ctx.Text != "" {
|
||||
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](ctx.Text),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isFirstMessage {
|
||||
isFirstMessage = false
|
||||
if message.Role != "user" {
|
||||
claudeMessage := dto.ClaudeMessage{
|
||||
Role: "user",
|
||||
Content: []dto.ClaudeMediaMessage{
|
||||
{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string]("..."),
|
||||
},
|
||||
},
|
||||
}
|
||||
claudeMessages = append(claudeMessages, claudeMessage)
|
||||
}
|
||||
}
|
||||
|
||||
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: common.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: common.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 := common.Unmarshal([]byte(args), &inputObj); err != nil {
|
||||
common.SysLog("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(systemMessages) > 0 {
|
||||
claudeRequest.System = systemMessages
|
||||
}
|
||||
|
||||
claudeRequest.Prompt = ""
|
||||
claudeRequest.Messages = claudeMessages
|
||||
return &claudeRequest, nil
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
package oaichat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/reasonmap"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func generateStopBlock(index int) *dto.ClaudeResponse {
|
||||
return &dto.ClaudeResponse{
|
||||
Type: "content_block_stop",
|
||||
Index: common.GetPointer[int](index),
|
||||
}
|
||||
}
|
||||
|
||||
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
||||
if oaiUsage == nil {
|
||||
return nil
|
||||
}
|
||||
if billingUsage := dto.CloneBillingUsage(oaiUsage.BillingUsage); billingUsage != nil && billingUsage.ClaudeUsage != nil {
|
||||
if billingUsage.Source == dto.BillingUsageSourceClaudeMessages || billingUsage.Semantic == dto.BillingUsageSemanticAnthropic {
|
||||
return billingUsage.ClaudeUsage
|
||||
}
|
||||
}
|
||||
billingUsage := dto.NewOpenAIChatBillingUsage(oaiUsage)
|
||||
if existingBillingUsage := dto.CloneBillingUsage(oaiUsage.BillingUsage); existingBillingUsage != nil && existingBillingUsage.OpenAIUsage != nil {
|
||||
if existingBillingUsage.Source == dto.BillingUsageSourceOAIChat ||
|
||||
existingBillingUsage.Source == dto.BillingUsageSourceOAIResponses ||
|
||||
existingBillingUsage.Semantic == dto.BillingUsageSemanticOpenAI {
|
||||
billingUsage = existingBillingUsage
|
||||
}
|
||||
}
|
||||
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
|
||||
oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||
oaiUsage.ClaudeCacheCreation5mTokens,
|
||||
oaiUsage.ClaudeCacheCreation1hTokens,
|
||||
)
|
||||
usage := &dto.ClaudeUsage{
|
||||
InputTokens: oaiUsage.PromptTokens,
|
||||
OutputTokens: oaiUsage.CompletionTokens,
|
||||
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
|
||||
BillingUsage: billingUsage,
|
||||
}
|
||||
if cacheCreation5m > 0 || cacheCreation1h > 0 {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: cacheCreation5m,
|
||||
Ephemeral1hInputTokens: cacheCreation1h,
|
||||
}
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
||||
remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
|
||||
return tokens5m + remainder, tokens1h
|
||||
}
|
||||
|
||||
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
|
||||
if info == nil {
|
||||
info = &relaycommon.RelayInfo{}
|
||||
}
|
||||
if info.ClaudeConvertInfo == nil {
|
||||
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{
|
||||
LastMessagesType: relaycommon.LastMessageTypeNone,
|
||||
}
|
||||
}
|
||||
if info.ClaudeConvertInfo.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 info.ClaudeConvertInfo.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.
|
||||
stopOpenBlocks := func() {
|
||||
switch info.ClaudeConvertInfo.LastMessagesType {
|
||||
case relaycommon.LastMessageTypeText, relaycommon.LastMessageTypeThinking:
|
||||
claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
|
||||
case relaycommon.LastMessageTypeTools:
|
||||
base := info.ClaudeConvertInfo.ToolCallBaseIndex
|
||||
for offset := 0; offset <= info.ClaudeConvertInfo.ToolCallMaxIndexOffset; offset++ {
|
||||
claudeResponses = append(claudeResponses, generateStopBlock(base+offset))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeNone {
|
||||
return
|
||||
}
|
||||
stopOpenBlocks()
|
||||
switch info.ClaudeConvertInfo.LastMessagesType {
|
||||
case relaycommon.LastMessageTypeTools:
|
||||
info.ClaudeConvertInfo.Index = info.ClaudeConvertInfo.ToolCallBaseIndex + info.ClaudeConvertInfo.ToolCallMaxIndexOffset + 1
|
||||
info.ClaudeConvertInfo.ToolCallBaseIndex = 0
|
||||
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
|
||||
default:
|
||||
info.ClaudeConvertInfo.Index++
|
||||
}
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeNone
|
||||
}
|
||||
if info.SendResponseCount == 1 {
|
||||
msg := &dto.ClaudeMediaMessage{
|
||||
Id: openAIResponse.Id,
|
||||
Model: openAIResponse.Model,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Usage: &dto.ClaudeUsage{
|
||||
InputTokens: info.GetEstimatePromptTokens(),
|
||||
OutputTokens: 0,
|
||||
},
|
||||
}
|
||||
msg.SetContent(make([]any, 0))
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_start",
|
||||
Message: msg,
|
||||
})
|
||||
//claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
// Type: "ping",
|
||||
//})
|
||||
if openAIResponse.IsToolCall() {
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools
|
||||
info.ClaudeConvertInfo.ToolCallBaseIndex = 0
|
||||
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
|
||||
var toolCall dto.ToolCallResponse
|
||||
if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.ToolCalls) > 0 {
|
||||
toolCall = openAIResponse.Choices[0].Delta.ToolCalls[0]
|
||||
} else {
|
||||
first := openAIResponse.GetFirstToolCall()
|
||||
if first != nil {
|
||||
toolCall = *first
|
||||
} else {
|
||||
toolCall = dto.ToolCallResponse{}
|
||||
}
|
||||
}
|
||||
resp := &dto.ClaudeResponse{
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Id: toolCall.ID,
|
||||
Type: "tool_use",
|
||||
Name: toolCall.Function.Name,
|
||||
Input: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
resp.SetIndex(0)
|
||||
claudeResponses = append(claudeResponses, resp)
|
||||
// 首块包含工具 delta,则追加 input_json_delta
|
||||
if toolCall.Function.Arguments != "" {
|
||||
idx := 0
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_delta",
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "input_json_delta",
|
||||
PartialJson: &toolCall.Function.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
}
|
||||
// 判断首个响应是否存在内容(非标准的 OpenAI 响应)
|
||||
if len(openAIResponse.Choices) > 0 {
|
||||
reasoning := openAIResponse.Choices[0].Delta.GetReasoningContent()
|
||||
content := openAIResponse.Choices[0].Delta.GetContentString()
|
||||
|
||||
if reasoning != "" {
|
||||
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking {
|
||||
stopOpenBlocksAndAdvance()
|
||||
}
|
||||
idx := info.ClaudeConvertInfo.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "thinking",
|
||||
Thinking: common.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
idx2 := idx
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx2,
|
||||
Type: "content_block_delta",
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "thinking_delta",
|
||||
Thinking: &reasoning,
|
||||
},
|
||||
})
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking
|
||||
} else if content != "" {
|
||||
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText {
|
||||
stopOpenBlocksAndAdvance()
|
||||
}
|
||||
idx := info.ClaudeConvertInfo.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
idx2 := idx
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx2,
|
||||
Type: "content_block_delta",
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "text_delta",
|
||||
Text: common.GetPointer[string](content),
|
||||
},
|
||||
})
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText
|
||||
}
|
||||
}
|
||||
|
||||
// 如果首块就带 finish_reason,需要立即发送停止块
|
||||
if len(openAIResponse.Choices) > 0 && openAIResponse.Choices[0].FinishReason != nil && *openAIResponse.Choices[0].FinishReason != "" {
|
||||
info.FinishReason = *openAIResponse.Choices[0].FinishReason
|
||||
stopOpenBlocks()
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
}
|
||||
if oaiUsage != nil {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
|
||||
},
|
||||
})
|
||||
}
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_stop",
|
||||
})
|
||||
info.ClaudeConvertInfo.Done = true
|
||||
}
|
||||
return claudeResponses
|
||||
}
|
||||
|
||||
if len(openAIResponse.Choices) == 0 {
|
||||
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
}
|
||||
if oaiUsage != nil {
|
||||
stopOpenBlocks()
|
||||
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
|
||||
if stopReason == "" {
|
||||
stopReason = "end_turn"
|
||||
}
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReason),
|
||||
},
|
||||
})
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_stop",
|
||||
})
|
||||
info.ClaudeConvertInfo.Done = true
|
||||
}
|
||||
return claudeResponses
|
||||
} else {
|
||||
chosenChoice := openAIResponse.Choices[0]
|
||||
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
|
||||
if doneChunk {
|
||||
info.FinishReason = *chosenChoice.FinishReason
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
|
||||
// Defer closing until usage is available so the final message_delta carries it.
|
||||
return claudeResponses
|
||||
}
|
||||
}
|
||||
|
||||
var claudeResponse dto.ClaudeResponse
|
||||
var isEmpty bool
|
||||
claudeResponse.Type = "content_block_delta"
|
||||
if len(chosenChoice.Delta.ToolCalls) > 0 {
|
||||
toolCalls := chosenChoice.Delta.ToolCalls
|
||||
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeTools {
|
||||
stopOpenBlocksAndAdvance()
|
||||
info.ClaudeConvertInfo.ToolCallBaseIndex = info.ClaudeConvertInfo.Index
|
||||
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
|
||||
}
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools
|
||||
base := info.ClaudeConvertInfo.ToolCallBaseIndex
|
||||
maxOffset := info.ClaudeConvertInfo.ToolCallMaxIndexOffset
|
||||
|
||||
for i, toolCall := range toolCalls {
|
||||
offset := 0
|
||||
if toolCall.Index != nil {
|
||||
offset = *toolCall.Index
|
||||
} else {
|
||||
offset = i
|
||||
}
|
||||
if offset > maxOffset {
|
||||
maxOffset = offset
|
||||
}
|
||||
blockIndex := base + offset
|
||||
|
||||
idx := blockIndex
|
||||
if toolCall.Function.Name != "" {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Id: toolCall.ID,
|
||||
Type: "tool_use",
|
||||
Name: toolCall.Function.Name,
|
||||
Input: map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(toolCall.Function.Arguments) > 0 {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_delta",
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "input_json_delta",
|
||||
PartialJson: &toolCall.Function.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = maxOffset
|
||||
info.ClaudeConvertInfo.Index = base + maxOffset
|
||||
} else {
|
||||
reasoning := chosenChoice.Delta.GetReasoningContent()
|
||||
textContent := chosenChoice.Delta.GetContentString()
|
||||
if reasoning != "" || textContent != "" {
|
||||
if reasoning != "" {
|
||||
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking {
|
||||
stopOpenBlocksAndAdvance()
|
||||
idx := info.ClaudeConvertInfo.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "thinking",
|
||||
Thinking: common.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
}
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking
|
||||
claudeResponse.Delta = &dto.ClaudeMediaMessage{
|
||||
Type: "thinking_delta",
|
||||
Thinking: &reasoning,
|
||||
}
|
||||
} else {
|
||||
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText {
|
||||
stopOpenBlocksAndAdvance()
|
||||
idx := info.ClaudeConvertInfo.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
}
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText
|
||||
claudeResponse.Delta = &dto.ClaudeMediaMessage{
|
||||
Type: "text_delta",
|
||||
Text: common.GetPointer[string](textContent),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isEmpty = true
|
||||
}
|
||||
}
|
||||
|
||||
claudeResponse.Index = common.GetPointer[int](info.ClaudeConvertInfo.Index)
|
||||
if !isEmpty && claudeResponse.Delta != nil {
|
||||
claudeResponses = append(claudeResponses, &claudeResponse)
|
||||
}
|
||||
|
||||
if doneChunk || info.ClaudeConvertInfo.Done {
|
||||
stopOpenBlocks()
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
}
|
||||
if oaiUsage != nil {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
|
||||
},
|
||||
})
|
||||
}
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_stop",
|
||||
})
|
||||
info.ClaudeConvertInfo.Done = true
|
||||
return claudeResponses
|
||||
}
|
||||
}
|
||||
|
||||
return claudeResponses
|
||||
}
|
||||
|
||||
func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *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)
|
||||
textContent := choice.Message.StringContent()
|
||||
toolCalls := choice.Message.ParseToolCalls()
|
||||
if textContent != "" || len(toolCalls) == 0 {
|
||||
claudeContent := dto.ClaudeMediaMessage{}
|
||||
claudeContent.Type = "text"
|
||||
claudeContent.SetText(textContent)
|
||||
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]interface{}{}
|
||||
if strings.TrimSpace(toolUse.Function.Arguments) != "" {
|
||||
var parsed map[string]interface{}
|
||||
if err := common.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,195 @@
|
||||
package oaichat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResponseOpenAI2ClaudeToolUseInputIsObject(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{name: "object", args: `{"q":"x"}`, want: map[string]interface{}{"q": "x"}},
|
||||
{name: "empty", args: "", want: map[string]interface{}{}},
|
||||
{name: "invalid", args: "{", want: map[string]interface{}{}},
|
||||
{name: "null", args: "null", want: map[string]interface{}{}},
|
||||
{name: "array", args: `["x"]`, want: map[string]interface{}{}},
|
||||
{name: "string", args: `"x"`, want: map[string]interface{}{}},
|
||||
}
|
||||
|
||||
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 TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||
LastMessagesType: relaycommon.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 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,406 @@
|
||||
package oaichat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relaymedia "github.com/QuantumNous/new-api/service/relayconvert/internal/media"
|
||||
relaymeta "github.com/QuantumNous/new-api/service/relayconvert/internal/meta"
|
||||
sharedgemini "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/gemini"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func OpenAIChatRequestToGeminiGenerateContent(c *gin.Context, textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) {
|
||||
geminiRequest := dto.GeminiChatRequest{
|
||||
Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)),
|
||||
GenerationConfig: dto.GeminiChatGenerationConfig{
|
||||
Temperature: textRequest.Temperature,
|
||||
},
|
||||
}
|
||||
|
||||
if textRequest.TopP != nil && *textRequest.TopP > 0 {
|
||||
geminiRequest.GenerationConfig.TopP = common.GetPointer(*textRequest.TopP)
|
||||
}
|
||||
if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 {
|
||||
geminiRequest.GenerationConfig.MaxOutputTokens = common.GetPointer(maxTokens)
|
||||
}
|
||||
if textRequest.Seed != nil && *textRequest.Seed != 0 {
|
||||
geminiRequest.GenerationConfig.Seed = common.GetPointer(int64(*textRequest.Seed))
|
||||
}
|
||||
|
||||
upstreamModelName := textRequest.Model
|
||||
if modelName := relaymeta.RelayInfoUpstreamModelName(info); modelName != "" {
|
||||
upstreamModelName = modelName
|
||||
}
|
||||
|
||||
if model_setting.IsGeminiModelSupportImagine(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
|
||||
}
|
||||
|
||||
adaptorWithExtraBody := false
|
||||
if len(textRequest.ExtraBody) > 0 {
|
||||
var extraBody map[string]interface{}
|
||||
if err := common.Unmarshal(textRequest.ExtraBody, &extraBody); err != nil {
|
||||
return nil, fmt.Errorf("invalid extra body: %w", err)
|
||||
}
|
||||
|
||||
if googleBody, ok := extraBody["google"].(map[string]interface{}); ok {
|
||||
if !strings.HasSuffix(upstreamModelName, "-nothinking") {
|
||||
adaptorWithExtraBody = true
|
||||
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]interface{}); 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 {
|
||||
switch v := thinkingBudget.(type) {
|
||||
case float64:
|
||||
budgetInt := int(v)
|
||||
tempThinkingConfig.ThinkingBudget = common.GetPointer(budgetInt)
|
||||
tempThinkingConfig.IncludeThoughts = budgetInt > 0
|
||||
hasThinkingConfig = true
|
||||
default:
|
||||
return nil, errors.New("extra_body.google.thinking_config.thinking_budget must be an integer")
|
||||
}
|
||||
}
|
||||
|
||||
if includeThoughts, exists := thinkingConfig["include_thoughts"]; exists {
|
||||
if v, ok := includeThoughts.(bool); ok {
|
||||
tempThinkingConfig.IncludeThoughts = 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 {
|
||||
if geminiRequest.GenerationConfig.ThinkingConfig == nil {
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &tempThinkingConfig
|
||||
} else {
|
||||
if tempThinkingConfig.ThinkingBudget != nil {
|
||||
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = tempThinkingConfig.ThinkingBudget
|
||||
}
|
||||
geminiRequest.GenerationConfig.ThinkingConfig.IncludeThoughts = tempThinkingConfig.IncludeThoughts
|
||||
if tempThinkingConfig.ThinkingLevel != "" {
|
||||
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingLevel = tempThinkingConfig.ThinkingLevel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]interface{}); 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]interface{})
|
||||
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 := common.Marshal(geminiImageConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal image_config: %w", err)
|
||||
}
|
||||
geminiRequest.GenerationConfig.ImageConfig = imageConfigBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !adaptorWithExtraBody {
|
||||
sharedgemini.ApplyThinkingConfig(&geminiRequest, info, textRequest)
|
||||
}
|
||||
|
||||
safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(sharedgemini.SafetySettingCategories))
|
||||
for _, category := range sharedgemini.SafetySettingCategories {
|
||||
safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
|
||||
Category: category,
|
||||
Threshold: model_setting.GetGeminiSafetySetting(category),
|
||||
})
|
||||
}
|
||||
geminiRequest.SafetySettings = safetySettings
|
||||
|
||||
if textRequest.Tools != nil {
|
||||
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
|
||||
googleSearch := false
|
||||
codeExecution := false
|
||||
urlContext := false
|
||||
for _, tool := range textRequest.Tools {
|
||||
if tool.Function.Name == "googleSearch" {
|
||||
googleSearch = true
|
||||
continue
|
||||
}
|
||||
if tool.Function.Name == "codeExecution" {
|
||||
codeExecution = true
|
||||
continue
|
||||
}
|
||||
if tool.Function.Name == "urlContext" {
|
||||
urlContext = true
|
||||
continue
|
||||
}
|
||||
if tool.Function.Parameters != nil {
|
||||
if params, ok := tool.Function.Parameters.(map[string]interface{}); ok {
|
||||
if props, hasProps := params["properties"].(map[string]interface{}); 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 codeExecution {
|
||||
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
||||
CodeExecution: make(map[string]string),
|
||||
})
|
||||
}
|
||||
if googleSearch {
|
||||
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
||||
GoogleSearch: make(map[string]string),
|
||||
})
|
||||
}
|
||||
if urlContext {
|
||||
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
||||
URLContext: make(map[string]string),
|
||||
})
|
||||
}
|
||||
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 := common.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]interface{}
|
||||
contentStr := message.StringContent()
|
||||
|
||||
if err := common.Unmarshal([]byte(contentStr), &contentMap); err != nil {
|
||||
var contentSlice []interface{}
|
||||
if err := common.Unmarshal([]byte(contentStr), &contentSlice); err == nil {
|
||||
contentMap = map[string]interface{}{"result": contentSlice}
|
||||
} else {
|
||||
contentMap = map[string]interface{}{"content": contentStr}
|
||||
}
|
||||
}
|
||||
|
||||
functionResp := &dto.GeminiFunctionResponse{
|
||||
Name: name,
|
||||
Response: contentMap,
|
||||
}
|
||||
|
||||
*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()
|
||||
signatureAttached := false
|
||||
if message.ToolCalls != nil {
|
||||
for _, call := range message.ParseToolCalls() {
|
||||
args := map[string]interface{}{}
|
||||
if call.Function.Arguments != "" {
|
||||
if common.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{
|
||||
FunctionName: call.Function.Name,
|
||||
Arguments: args,
|
||||
},
|
||||
}
|
||||
if shouldAttachThoughtSignature && !signatureAttached && sharedgemini.AttachFunctionCallThoughtSignature(&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(&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(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,230 @@
|
||||
package oaichat
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
)
|
||||
|
||||
// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式
|
||||
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *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 {
|
||||
var args map[string]interface{}
|
||||
if toolCall.Function.Arguments != "" {
|
||||
if err := common.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
|
||||
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
|
||||
}
|
||||
} else {
|
||||
args = make(map[string]interface{})
|
||||
}
|
||||
|
||||
part := dto.GeminiPart{
|
||||
FunctionCall: &dto.FunctionCall{
|
||||
FunctionName: toolCall.Function.Name,
|
||||
Arguments: args,
|
||||
},
|
||||
}
|
||||
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 *relaycommon.RelayInfo) *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 {
|
||||
// 解析参数
|
||||
var args map[string]interface{}
|
||||
if toolCall.Function.Arguments != "" {
|
||||
if err := common.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
|
||||
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
|
||||
}
|
||||
} else {
|
||||
args = make(map[string]interface{})
|
||||
}
|
||||
|
||||
part := dto.GeminiPart{
|
||||
FunctionCall: &dto.FunctionCall{
|
||||
FunctionName: toolCall.Function.Name,
|
||||
Arguments: args,
|
||||
},
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return *billingUsage.GeminiUsageMetadata, true
|
||||
}
|
||||
|
||||
func openAIBillingUsageFromUsage(usage *dto.Usage) *dto.BillingUsage {
|
||||
if usage == nil {
|
||||
return nil
|
||||
}
|
||||
if existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage); existingBillingUsage != nil && existingBillingUsage.OpenAIUsage != nil {
|
||||
if existingBillingUsage.Source == dto.BillingUsageSourceOAIChat ||
|
||||
existingBillingUsage.Source == dto.BillingUsageSourceOAIResponses ||
|
||||
existingBillingUsage.Semantic == dto.BillingUsageSemanticOpenAI {
|
||||
return existingBillingUsage
|
||||
}
|
||||
}
|
||||
return dto.NewOpenAIChatBillingUsage(usage)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package oaichat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"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]interface{}{"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,
|
||||
},
|
||||
}, &relaycommon.RelayInfo{})
|
||||
|
||||
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]interface{}{"q": "x"}, resp.Candidates[0].Content.Parts[0].FunctionCall.Arguments)
|
||||
}
|
||||
|
||||
func geminiRespPtr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
+6
-3
@@ -1,12 +1,15 @@
|
||||
package relayconvert
|
||||
package oaichat
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/model_setting"
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/service/relayconvert/internal/matcher"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
)
|
||||
|
||||
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
|
||||
if !policy.IsChannelEnabled(channelID, channelType) {
|
||||
return false
|
||||
}
|
||||
return matchAnyRegex(policy.ModelPatterns, model)
|
||||
return matcher.MatchAnyRegex(policy.ModelPatterns, model)
|
||||
}
|
||||
|
||||
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package relayconvert
|
||||
package oaichat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -0,0 +1,62 @@
|
||||
package oaichat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"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 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 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,231 @@
|
||||
package oaichat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
chatFinishReasonLength = "length"
|
||||
chatFinishReasonContentFilter = "content_filter"
|
||||
|
||||
responsesEventCreated = "response.created"
|
||||
responsesEventCompleted = "response.completed"
|
||||
responsesEventIncomplete = "response.incomplete"
|
||||
responsesEventOutputTextDelta = "response.output_text.delta"
|
||||
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 text := choice.Message.StringContent(); text != "" {
|
||||
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: []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
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),
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{
|
||||
Type: "summary_text",
|
||||
Text: reasoning,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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 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.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
|
||||
_ = common.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 := common.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 := common.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
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package oaichat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/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 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
|
||||
}
|
||||
+1
-189
@@ -1,133 +1,14 @@
|
||||
package relayconvert
|
||||
package oaichat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
chatFinishReasonLength = "length"
|
||||
chatFinishReasonContentFilter = "content_filter"
|
||||
)
|
||||
|
||||
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 text := choice.Message.StringContent(); text != "" {
|
||||
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: []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
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),
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{
|
||||
Type: "summary_text",
|
||||
Text: reasoning,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
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.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
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
type ChatToResponsesStreamEvent struct {
|
||||
Type string
|
||||
Payload dto.ResponsesStreamResponse
|
||||
@@ -534,72 +415,3 @@ func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool,
|
||||
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
|
||||
}
|
||||
}
|
||||
|
||||
func responseOutputStatus(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || responseStatusString(resp) != "incomplete" {
|
||||
return "completed"
|
||||
}
|
||||
return "incomplete"
|
||||
}
|
||||
|
||||
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 := common.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 := common.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
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
)
|
||||
|
||||
func openAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
|
||||
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
|
||||
responsesRequest = &value
|
||||
}
|
||||
}
|
||||
if responsesRequest == nil {
|
||||
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
|
||||
}
|
||||
return responsesRequest, nil
|
||||
}
|
||||
|
||||
func OpenAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
|
||||
return openAIResponsesRequestFromAny(request)
|
||||
}
|
||||
|
||||
func responsesInputItems(raw []byte) ([]map[string]any, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch common.GetJsonType(raw) {
|
||||
case "string":
|
||||
input, err := responsesJSONString(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid input string: %w", err)
|
||||
}
|
||||
return []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": input,
|
||||
},
|
||||
}, nil
|
||||
case "array":
|
||||
var items []map[string]any
|
||||
if err := common.Unmarshal(raw, &items); err != nil {
|
||||
return nil, fmt.Errorf("invalid input array: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported responses input type %q", common.GetJsonType(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func InputItems(raw []byte) ([]map[string]any, error) {
|
||||
return responsesInputItems(raw)
|
||||
}
|
||||
|
||||
func responsesContentParts(content any) ([]map[string]any, error) {
|
||||
switch typed := content.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case string:
|
||||
return []map[string]any{{"type": "input_text", "text": typed}}, nil
|
||||
case []map[string]any:
|
||||
return typed, nil
|
||||
case []any:
|
||||
parts := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
switch part := item.(type) {
|
||||
case string:
|
||||
parts = append(parts, map[string]any{"type": "input_text", "text": part})
|
||||
case map[string]any:
|
||||
parts = append(parts, part)
|
||||
default:
|
||||
raw, err := common.Marshal(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, map[string]any{"type": "input_text", "text": string(raw)})
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
default:
|
||||
raw, err := common.Marshal(typed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []map[string]any{{"type": "input_text", "text": string(raw)}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func ContentParts(content any) ([]map[string]any, error) {
|
||||
return responsesContentParts(content)
|
||||
}
|
||||
|
||||
func responsesRequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var tools []map[string]any
|
||||
if err := common.Unmarshal(raw, &tools); err != nil {
|
||||
return nil, fmt.Errorf("invalid tools: %w", err)
|
||||
}
|
||||
|
||||
functions := make([]dto.FunctionRequest, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
if strings.TrimSpace(common.Interface2String(tool["type"])) != "function" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(common.Interface2String(tool["name"]))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
functions = append(functions, dto.FunctionRequest{
|
||||
Name: name,
|
||||
Description: common.Interface2String(tool["description"]),
|
||||
Parameters: tool["parameters"],
|
||||
})
|
||||
}
|
||||
return functions, nil
|
||||
}
|
||||
|
||||
func RequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
|
||||
return responsesRequestFunctionDeclarations(raw)
|
||||
}
|
||||
|
||||
func responsesReasoningEffort(req *dto.OpenAIResponsesRequest) string {
|
||||
if req == nil || req.Reasoning == nil {
|
||||
return ""
|
||||
}
|
||||
return req.Reasoning.Effort
|
||||
}
|
||||
|
||||
func ReasoningEffort(req *dto.OpenAIResponsesRequest) string {
|
||||
return responsesReasoningEffort(req)
|
||||
}
|
||||
|
||||
func responsesObjectValue(value any, fallbackKey string) map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return map[string]any{}
|
||||
case map[string]any:
|
||||
return typed
|
||||
case string:
|
||||
var object map[string]any
|
||||
if err := common.Unmarshal([]byte(typed), &object); err == nil {
|
||||
return object
|
||||
}
|
||||
var array []any
|
||||
if err := common.Unmarshal([]byte(typed), &array); err == nil {
|
||||
return map[string]any{fallbackKey: array}
|
||||
}
|
||||
return map[string]any{fallbackKey: typed}
|
||||
case []any:
|
||||
return map[string]any{fallbackKey: typed}
|
||||
default:
|
||||
return map[string]any{fallbackKey: typed}
|
||||
}
|
||||
}
|
||||
|
||||
func ObjectValue(value any, fallbackKey string) map[string]any {
|
||||
return responsesObjectValue(value, fallbackKey)
|
||||
}
|
||||
|
||||
func responsesGeminiResponseMap(value any) map[string]interface{} {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return map[string]interface{}{}
|
||||
case map[string]any:
|
||||
return typed
|
||||
case string:
|
||||
var object map[string]interface{}
|
||||
if err := common.Unmarshal([]byte(typed), &object); err == nil {
|
||||
return object
|
||||
}
|
||||
var array []interface{}
|
||||
if err := common.Unmarshal([]byte(typed), &array); err == nil {
|
||||
return map[string]interface{}{"result": array}
|
||||
}
|
||||
return map[string]interface{}{"content": typed}
|
||||
case []any:
|
||||
return map[string]interface{}{"result": typed}
|
||||
default:
|
||||
return map[string]interface{}{"content": typed}
|
||||
}
|
||||
}
|
||||
|
||||
func GeminiResponseMap(value any) map[string]interface{} {
|
||||
return responsesGeminiResponseMap(value)
|
||||
}
|
||||
|
||||
func responsesParallelToolCalls(raw []byte) *bool {
|
||||
if !rawJSONPresent(raw) || common.GetJsonType(raw) != "boolean" {
|
||||
return nil
|
||||
}
|
||||
var parallelToolCalls bool
|
||||
if err := common.Unmarshal(raw, ¶llelToolCalls); err != nil {
|
||||
return nil
|
||||
}
|
||||
return ¶llelToolCalls
|
||||
}
|
||||
|
||||
func ParallelToolCalls(raw []byte) *bool {
|
||||
return responsesParallelToolCalls(raw)
|
||||
}
|
||||
|
||||
func ContentPartToFileSource(part map[string]any) types.FileSource {
|
||||
partType := strings.TrimSpace(common.Interface2String(part["type"]))
|
||||
var data string
|
||||
var mimeType string
|
||||
|
||||
switch partType {
|
||||
case "input_image":
|
||||
data, mimeType = responsesPartDataAndMime(part, "image_url", "url")
|
||||
case "input_file":
|
||||
data, mimeType = responsesPartDataAndMime(part, "file", "file_data", "file_url", "url")
|
||||
case "input_audio":
|
||||
data, mimeType = responsesPartDataAndMime(part, "input_audio", "data", "url")
|
||||
if mimeType == "" {
|
||||
if payload, ok := part["input_audio"].(map[string]any); ok {
|
||||
if format := strings.TrimSpace(common.Interface2String(payload["format"])); format != "" {
|
||||
mimeType = "audio/" + format
|
||||
}
|
||||
}
|
||||
}
|
||||
case "input_video":
|
||||
data, mimeType = responsesPartDataAndMime(part, "video_url", "url")
|
||||
}
|
||||
if data == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(data, mimeType)
|
||||
}
|
||||
|
||||
func responsesPartDataAndMime(part map[string]any, keys ...string) (string, string) {
|
||||
mimeType := strings.TrimSpace(common.Interface2String(part["mime_type"]))
|
||||
for _, key := range keys {
|
||||
value, ok := part[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if typed != "" {
|
||||
return typed, mimeType
|
||||
}
|
||||
case map[string]any:
|
||||
if mimeType == "" {
|
||||
mimeType = strings.TrimSpace(common.Interface2String(typed["mime_type"]))
|
||||
}
|
||||
for _, nestedKey := range []string{"url", "file_data", "file_url", "data"} {
|
||||
if data := strings.TrimSpace(common.Interface2String(typed[nestedKey])); data != "" {
|
||||
return data, mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", mimeType
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relaymedia "github.com/QuantumNous/new-api/service/relayconvert/internal/media"
|
||||
sharedclaude "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/claude"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func convertOpenAIResponsesRequestToClaudeMessages(c *gin.Context, _ *relaycommon.RelayInfo, request any) (any, error) {
|
||||
responsesRequest, err := OpenAIResponsesRequestFromAny(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return OpenAIResponsesRequestToClaudeMessages(c, responsesRequest)
|
||||
}
|
||||
|
||||
func OpenAIResponsesRequestToClaudeMessages(c *gin.Context, req *dto.OpenAIResponsesRequest) (*dto.ClaudeRequest, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("request is nil")
|
||||
}
|
||||
if req.Model == "" {
|
||||
return nil, fmt.Errorf("model is required")
|
||||
}
|
||||
if err := ValidateRequestChatUnsupportedFields(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claudeRequest := &dto.ClaudeRequest{
|
||||
Model: req.Model,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
Stream: req.Stream,
|
||||
}
|
||||
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
|
||||
claudeRequest.MaxTokens = common.GetPointer(*req.MaxOutputTokens)
|
||||
}
|
||||
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 {
|
||||
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(req.Model))
|
||||
claudeRequest.MaxTokens = &defaultMaxTokens
|
||||
}
|
||||
|
||||
functions, err := RequestFunctionDeclarations(req.Tools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(functions) > 0 {
|
||||
claudeRequest.Tools = responsesFunctionDeclarationsToClaudeTools(functions)
|
||||
}
|
||||
|
||||
toolChoice, err := RequestToolChoiceToChat(req.ToolChoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if toolChoice != nil || RawJSONPresent(req.ParallelToolCalls) {
|
||||
claudeRequest.ToolChoice = sharedclaude.MapOpenAIToolChoice(toolChoice, ParallelToolCalls(req.ParallelToolCalls))
|
||||
}
|
||||
applyResponsesReasoningToClaude(req, claudeRequest)
|
||||
|
||||
systemMessages := make([]dto.ClaudeMediaMessage, 0)
|
||||
if RawJSONPresent(req.Instructions) {
|
||||
instructions, err := JSONString(req.Instructions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid instructions: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(instructions) != "" {
|
||||
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer(instructions),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
inputItems, err := InputItems(req.Input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range inputItems {
|
||||
itemType := strings.TrimSpace(common.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case ResponsesInputTypeFunctionCall:
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "arguments"))
|
||||
case ResponsesInputTypeCustomToolCall:
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "input"))
|
||||
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
|
||||
claudeRequest.Messages = appendClaudeToolResult(claudeRequest.Messages, responsesFunctionOutputItemToClaudeToolResult(item))
|
||||
default:
|
||||
role := responsesClaudeRole(item)
|
||||
parts, err := responsesInputContentToClaudeMediaMessages(c, item["content"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == "system" {
|
||||
systemMessages = append(systemMessages, parts...)
|
||||
continue
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
parts = []dto.ClaudeMediaMessage{
|
||||
{
|
||||
Type: "text",
|
||||
Text: common.GetPointer("..."),
|
||||
},
|
||||
}
|
||||
}
|
||||
claudeRequest.Messages = append(claudeRequest.Messages, dto.ClaudeMessage{
|
||||
Role: role,
|
||||
Content: parts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(systemMessages) > 0 {
|
||||
claudeRequest.System = systemMessages
|
||||
}
|
||||
claudeRequest.Messages = ensureClaudeMessagesStartWithUser(claudeRequest.Messages)
|
||||
return claudeRequest, nil
|
||||
}
|
||||
|
||||
func responsesFunctionDeclarationsToClaudeTools(functions []dto.FunctionRequest) []any {
|
||||
tools := make([]any, 0, len(functions))
|
||||
for _, function := range functions {
|
||||
tools = append(tools, &dto.Tool{
|
||||
Name: function.Name,
|
||||
Description: function.Description,
|
||||
InputSchema: responsesFunctionParametersToClaudeInputSchema(function.Parameters),
|
||||
})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func responsesFunctionParametersToClaudeInputSchema(parameters any) map[string]interface{} {
|
||||
if params, ok := parameters.(map[string]any); ok {
|
||||
schema := make(map[string]interface{}, len(params))
|
||||
for key, value := range params {
|
||||
schema[key] = value
|
||||
}
|
||||
if schema["type"] == nil {
|
||||
schema["type"] = "object"
|
||||
}
|
||||
if schema["properties"] == nil {
|
||||
schema["properties"] = map[string]interface{}{}
|
||||
}
|
||||
return schema
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func applyResponsesReasoningToClaude(req *dto.OpenAIResponsesRequest, claudeRequest *dto.ClaudeRequest) {
|
||||
effort := ReasoningEffort(req)
|
||||
switch effort {
|
||||
case "low":
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: common.GetPointer(1280),
|
||||
}
|
||||
case "medium":
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: common.GetPointer(2048),
|
||||
}
|
||||
case "high":
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: common.GetPointer(4096),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func responsesInputContentToClaudeMediaMessages(c *gin.Context, content any) ([]dto.ClaudeMediaMessage, error) {
|
||||
contentParts, err := ContentParts(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parts := make([]dto.ClaudeMediaMessage, 0, len(contentParts))
|
||||
for _, contentPart := range contentParts {
|
||||
partType := strings.TrimSpace(common.Interface2String(contentPart["type"]))
|
||||
switch partType {
|
||||
case "input_text", "output_text", "text":
|
||||
text := common.Interface2String(contentPart["text"])
|
||||
if text != "" {
|
||||
parts = append(parts, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer(text),
|
||||
})
|
||||
}
|
||||
case "input_image", "input_file", "input_audio", "input_video":
|
||||
source := ContentPartToFileSource(contentPart)
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input for Claude")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
||||
}
|
||||
claudePart := dto.ClaudeMediaMessage{
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
MediaType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "application/pdf") {
|
||||
claudePart.Type = "document"
|
||||
} else {
|
||||
claudePart.Type = "image"
|
||||
}
|
||||
parts = append(parts, claudePart)
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func responsesFunctionCallItemToClaudeToolUse(item map[string]any, inputKey string) dto.ClaudeMediaMessage {
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "tool_use",
|
||||
Id: CallID(item),
|
||||
Name: strings.TrimSpace(common.Interface2String(item["name"])),
|
||||
Input: ObjectValue(item[inputKey], inputKey),
|
||||
}
|
||||
}
|
||||
|
||||
func responsesFunctionOutputItemToClaudeToolResult(item map[string]any) dto.ClaudeMediaMessage {
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "tool_result",
|
||||
ToolUseId: CallID(item),
|
||||
Content: responsesToolOutputValue(item["output"]),
|
||||
}
|
||||
}
|
||||
|
||||
func responsesToolOutputValue(value any) any {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func appendClaudeToolUse(messages []dto.ClaudeMessage, toolUse dto.ClaudeMediaMessage) []dto.ClaudeMessage {
|
||||
if len(messages) > 0 && messages[len(messages)-1].Role == "assistant" {
|
||||
last := messages[len(messages)-1]
|
||||
parts := claudeMessageContentParts(last.Content)
|
||||
parts = append(parts, toolUse)
|
||||
last.Content = parts
|
||||
messages[len(messages)-1] = last
|
||||
return messages
|
||||
}
|
||||
return append(messages, dto.ClaudeMessage{
|
||||
Role: "assistant",
|
||||
Content: []dto.ClaudeMediaMessage{toolUse},
|
||||
})
|
||||
}
|
||||
|
||||
func appendClaudeToolResult(messages []dto.ClaudeMessage, toolResult dto.ClaudeMediaMessage) []dto.ClaudeMessage {
|
||||
if len(messages) > 0 && messages[len(messages)-1].Role == "user" {
|
||||
last := messages[len(messages)-1]
|
||||
parts := claudeMessageContentParts(last.Content)
|
||||
parts = append(parts, toolResult)
|
||||
last.Content = parts
|
||||
messages[len(messages)-1] = last
|
||||
return messages
|
||||
}
|
||||
return append(messages, dto.ClaudeMessage{
|
||||
Role: "user",
|
||||
Content: []dto.ClaudeMediaMessage{toolResult},
|
||||
})
|
||||
}
|
||||
|
||||
func claudeMessageContentParts(content any) []dto.ClaudeMediaMessage {
|
||||
switch typed := content.(type) {
|
||||
case []dto.ClaudeMediaMessage:
|
||||
return typed
|
||||
case string:
|
||||
if typed == "" {
|
||||
return nil
|
||||
}
|
||||
return []dto.ClaudeMediaMessage{
|
||||
{
|
||||
Type: "text",
|
||||
Text: common.GetPointer(typed),
|
||||
},
|
||||
}
|
||||
default:
|
||||
parts, _ := common.Any2Type[[]dto.ClaudeMediaMessage](content)
|
||||
return parts
|
||||
}
|
||||
}
|
||||
|
||||
func responsesClaudeRole(item map[string]any) string {
|
||||
switch strings.TrimSpace(common.Interface2String(item["role"])) {
|
||||
case "assistant":
|
||||
return "assistant"
|
||||
case "system", "developer":
|
||||
return "system"
|
||||
default:
|
||||
return "user"
|
||||
}
|
||||
}
|
||||
|
||||
func ensureClaudeMessagesStartWithUser(messages []dto.ClaudeMessage) []dto.ClaudeMessage {
|
||||
if len(messages) == 0 || messages[0].Role == "user" {
|
||||
return messages
|
||||
}
|
||||
return append([]dto.ClaudeMessage{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []dto.ClaudeMediaMessage{
|
||||
{
|
||||
Type: "text",
|
||||
Text: common.GetPointer("..."),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, messages...)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relaymedia "github.com/QuantumNous/new-api/service/relayconvert/internal/media"
|
||||
relaymeta "github.com/QuantumNous/new-api/service/relayconvert/internal/meta"
|
||||
sharedgemini "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/gemini"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func convertOpenAIResponsesRequestToGeminiChat(c *gin.Context, info *relaycommon.RelayInfo, request any) (any, error) {
|
||||
responsesRequest, err := OpenAIResponsesRequestFromAny(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prepared, err := PrepareOpenAIResponsesRequest(*responsesRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return OpenAIResponsesRequestToGeminiChat(c, &prepared, info)
|
||||
}
|
||||
|
||||
func OpenAIResponsesRequestToGeminiChat(c *gin.Context, req *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("request is nil")
|
||||
}
|
||||
if req.Model == "" {
|
||||
return nil, fmt.Errorf("model is required")
|
||||
}
|
||||
if err := ValidateRequestChatUnsupportedFields(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
geminiRequest := &dto.GeminiChatRequest{
|
||||
GenerationConfig: dto.GeminiChatGenerationConfig{
|
||||
Temperature: req.Temperature,
|
||||
},
|
||||
}
|
||||
if req.TopP != nil && *req.TopP > 0 {
|
||||
geminiRequest.GenerationConfig.TopP = common.GetPointer(*req.TopP)
|
||||
}
|
||||
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
|
||||
geminiRequest.GenerationConfig.MaxOutputTokens = common.GetPointer(*req.MaxOutputTokens)
|
||||
}
|
||||
|
||||
upstreamModelName := req.Model
|
||||
if modelName := relaymeta.RelayInfoUpstreamModelName(info); modelName != "" {
|
||||
upstreamModelName = modelName
|
||||
}
|
||||
if model_setting.IsGeminiModelSupportImagine(upstreamModelName) {
|
||||
geminiRequest.GenerationConfig.ResponseModalities = []string{"TEXT", "IMAGE"}
|
||||
}
|
||||
if err := applyResponsesTextToGemini(req.Text, geminiRequest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sharedgemini.ApplyThinkingConfig(geminiRequest, info, dto.GeneralOpenAIRequest{
|
||||
Model: req.Model,
|
||||
MaxCompletionTokens: req.MaxOutputTokens,
|
||||
ReasoningEffort: ReasoningEffort(req),
|
||||
})
|
||||
|
||||
safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(sharedgemini.SafetySettingCategories))
|
||||
for _, category := range sharedgemini.SafetySettingCategories {
|
||||
safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
|
||||
Category: category,
|
||||
Threshold: model_setting.GetGeminiSafetySetting(category),
|
||||
})
|
||||
}
|
||||
geminiRequest.SafetySettings = safetySettings
|
||||
|
||||
functions, err := RequestFunctionDeclarations(req.Tools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range functions {
|
||||
if params, ok := functions[i].Parameters.(map[string]interface{}); ok {
|
||||
if props, hasProps := params["properties"].(map[string]interface{}); hasProps && len(props) == 0 {
|
||||
functions[i].Parameters = nil
|
||||
continue
|
||||
}
|
||||
}
|
||||
functions[i].Parameters = sharedgemini.CleanFunctionParameters(functions[i].Parameters)
|
||||
}
|
||||
if len(functions) > 0 {
|
||||
geminiRequest.SetTools([]dto.GeminiChatTool{
|
||||
{FunctionDeclarations: functions},
|
||||
})
|
||||
}
|
||||
|
||||
toolChoice, err := RequestToolChoiceToChat(req.ToolChoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if toolChoice != nil {
|
||||
geminiRequest.ToolConfig = sharedgemini.OpenAIToolChoiceToConfig(toolChoice)
|
||||
}
|
||||
|
||||
systemTexts := make([]string, 0)
|
||||
if RawJSONPresent(req.Instructions) {
|
||||
instructions, err := JSONString(req.Instructions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid instructions: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(instructions) != "" {
|
||||
systemTexts = append(systemTexts, instructions)
|
||||
}
|
||||
}
|
||||
|
||||
inputItems, err := InputItems(req.Input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
callNames := make(map[string]string)
|
||||
for _, item := range inputItems {
|
||||
itemType := strings.TrimSpace(common.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case ResponsesInputTypeFunctionCall:
|
||||
part, callID, err := responsesFunctionCallItemToGeminiPart(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sharedgemini.AttachFunctionCallThoughtSignature(&part)
|
||||
if callID != "" {
|
||||
callNames[callID] = part.FunctionCall.FunctionName
|
||||
}
|
||||
appendGeminiContentPart(geminiRequest, "model", part)
|
||||
case ResponsesInputTypeFunctionCallOutput:
|
||||
part := responsesFunctionOutputItemToGeminiPart(item, callNames)
|
||||
appendGeminiContentPart(geminiRequest, "user", part)
|
||||
default:
|
||||
role := responsesGeminiRole(item)
|
||||
parts, err := responsesInputContentToGeminiParts(c, item["content"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == "system" {
|
||||
for _, part := range parts {
|
||||
if part.Text != "" {
|
||||
systemTexts = append(systemTexts, part.Text)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{
|
||||
Role: role,
|
||||
Parts: parts,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(systemTexts) > 0 {
|
||||
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
|
||||
Parts: []dto.GeminiPart{{Text: strings.Join(systemTexts, "\n")}},
|
||||
}
|
||||
}
|
||||
|
||||
return geminiRequest, nil
|
||||
}
|
||||
|
||||
func applyResponsesTextToGemini(raw []byte, geminiRequest *dto.GeminiChatRequest) error {
|
||||
responseFormat, err := RequestTextToChatResponseFormat(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if responseFormat == nil || (responseFormat.Type != "json_schema" && responseFormat.Type != "json_object") {
|
||||
return nil
|
||||
}
|
||||
|
||||
geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
|
||||
if len(responseFormat.JsonSchema) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var jsonSchema dto.FormatJsonSchema
|
||||
if err := common.Unmarshal(responseFormat.JsonSchema, &jsonSchema); err != nil {
|
||||
return nil
|
||||
}
|
||||
geminiRequest.GenerationConfig.ResponseSchema = sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func responsesInputContentToGeminiParts(c *gin.Context, content any) ([]dto.GeminiPart, error) {
|
||||
contentParts, err := ContentParts(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parts := make([]dto.GeminiPart, 0, len(contentParts))
|
||||
for _, contentPart := range contentParts {
|
||||
nextParts, err := responsesContentPartToGeminiParts(c, contentPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, nextParts...)
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func responsesContentPartToGeminiParts(c *gin.Context, part map[string]any) ([]dto.GeminiPart, error) {
|
||||
partType := strings.TrimSpace(common.Interface2String(part["type"]))
|
||||
switch partType {
|
||||
case "input_text", "output_text", "text":
|
||||
text := common.Interface2String(part["text"])
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []dto.GeminiPart{{Text: text}}, nil
|
||||
case "input_image", "input_file", "input_audio", "input_video":
|
||||
source := ContentPartToFileSource(part)
|
||||
if source == nil {
|
||||
return nil, nil
|
||||
}
|
||||
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input 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())
|
||||
}
|
||||
return []dto.GeminiPart{
|
||||
{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func responsesFunctionCallItemToGeminiPart(item map[string]any) (dto.GeminiPart, string, error) {
|
||||
name := strings.TrimSpace(common.Interface2String(item["name"]))
|
||||
if name == "" {
|
||||
return dto.GeminiPart{}, "", fmt.Errorf("function_call item is missing name")
|
||||
}
|
||||
callID := CallID(item)
|
||||
return dto.GeminiPart{
|
||||
FunctionCall: &dto.FunctionCall{
|
||||
FunctionName: name,
|
||||
Arguments: ObjectValue(item["arguments"], "arguments"),
|
||||
},
|
||||
}, callID, nil
|
||||
}
|
||||
|
||||
func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) dto.GeminiPart {
|
||||
callID := CallID(item)
|
||||
name := strings.TrimSpace(common.Interface2String(item["name"]))
|
||||
if name == "" {
|
||||
name = callNames[callID]
|
||||
}
|
||||
return dto.GeminiPart{
|
||||
FunctionResponse: &dto.GeminiFunctionResponse{
|
||||
Name: name,
|
||||
Response: GeminiResponseMap(item["output"]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appendGeminiContentPart(req *dto.GeminiChatRequest, role string, part dto.GeminiPart) {
|
||||
if len(req.Contents) > 0 && req.Contents[len(req.Contents)-1].Role == role {
|
||||
if role == "model" && part.FunctionCall != nil {
|
||||
parts := req.Contents[len(req.Contents)-1].Parts
|
||||
insertAt := 0
|
||||
for insertAt < len(parts) && parts[insertAt].FunctionCall != nil {
|
||||
insertAt++
|
||||
}
|
||||
parts = append(parts, dto.GeminiPart{})
|
||||
copy(parts[insertAt+1:], parts[insertAt:])
|
||||
parts[insertAt] = part
|
||||
req.Contents[len(req.Contents)-1].Parts = parts
|
||||
return
|
||||
}
|
||||
req.Contents[len(req.Contents)-1].Parts = append(req.Contents[len(req.Contents)-1].Parts, part)
|
||||
return
|
||||
}
|
||||
req.Contents = append(req.Contents, dto.GeminiChatContent{
|
||||
Role: role,
|
||||
Parts: []dto.GeminiPart{part},
|
||||
})
|
||||
}
|
||||
|
||||
func responsesGeminiRole(item map[string]any) string {
|
||||
switch strings.TrimSpace(common.Interface2String(item["role"])) {
|
||||
case "assistant":
|
||||
return "model"
|
||||
case "system", "developer":
|
||||
return "system"
|
||||
case "model":
|
||||
return "model"
|
||||
default:
|
||||
return "user"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
geminiResponsesInputTypeCustomToolCall = "custom_tool_call"
|
||||
geminiResponsesInputTypeCustomToolCallOutput = "custom_tool_call_output"
|
||||
geminiResponsesInputTypeFunctionCallOutput = "function_call_output"
|
||||
)
|
||||
|
||||
const (
|
||||
ResponsesInputTypeCustomToolCallOutput = geminiResponsesInputTypeCustomToolCallOutput
|
||||
)
|
||||
|
||||
func PrepareOpenAIResponsesRequest(request dto.OpenAIResponsesRequest) (dto.OpenAIResponsesRequest, error) {
|
||||
tools, err := filterGeminiResponsesTools(request.Tools)
|
||||
if err != nil {
|
||||
return request, err
|
||||
}
|
||||
request.Tools = tools
|
||||
|
||||
input, err := filterGeminiResponsesInput(request.Input)
|
||||
if err != nil {
|
||||
return request, err
|
||||
}
|
||||
request.Input = input
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func filterGeminiResponsesTools(raw []byte) ([]byte, error) {
|
||||
if !geminiRawJSONPresent(raw) || common.GetJsonType(raw) != "array" {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
var tools []map[string]any
|
||||
if err := common.Unmarshal(raw, &tools); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
if strings.TrimSpace(common.Interface2String(tool["type"])) != "function" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, tool)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return common.Marshal(filtered)
|
||||
}
|
||||
|
||||
func filterGeminiResponsesInput(raw []byte) ([]byte, error) {
|
||||
if !geminiRawJSONPresent(raw) || common.GetJsonType(raw) != "array" {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
var items []map[string]any
|
||||
if err := common.Unmarshal(raw, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
skippedCustomCallIDs := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(common.Interface2String(item["type"])) != geminiResponsesInputTypeCustomToolCall {
|
||||
continue
|
||||
}
|
||||
if callID := strings.TrimSpace(common.Interface2String(item["call_id"])); callID != "" {
|
||||
skippedCustomCallIDs[callID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
itemType := strings.TrimSpace(common.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case geminiResponsesInputTypeCustomToolCall, geminiResponsesInputTypeCustomToolCallOutput:
|
||||
continue
|
||||
case geminiResponsesInputTypeFunctionCallOutput:
|
||||
if _, ok := skippedCustomCallIDs[strings.TrimSpace(common.Interface2String(item["call_id"]))]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
|
||||
return common.Marshal(filtered)
|
||||
}
|
||||
|
||||
func geminiRawJSONPresent(raw []byte) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
return common.GetJsonType(raw) != "null"
|
||||
}
|
||||
+33
-1
@@ -1,4 +1,4 @@
|
||||
package relayconvert
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -14,6 +14,14 @@ const (
|
||||
responsesInputTypeFunctionCall = "function_call"
|
||||
responsesInputTypeFunctionCallOutput = "function_call_output"
|
||||
responsesInputTypeCustomToolCall = "custom_tool_call"
|
||||
responsesInputTypeCustomToolOutput = "custom_tool_call_output"
|
||||
)
|
||||
|
||||
const (
|
||||
ResponsesInputTypeFunctionCall = responsesInputTypeFunctionCall
|
||||
ResponsesInputTypeFunctionCallOutput = responsesInputTypeFunctionCallOutput
|
||||
ResponsesInputTypeCustomToolCall = responsesInputTypeCustomToolCall
|
||||
ResponsesInputTypeCustomToolOutput = responsesInputTypeCustomToolOutput
|
||||
)
|
||||
|
||||
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
|
||||
@@ -109,6 +117,10 @@ func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesReque
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
|
||||
return validateResponsesRequestChatUnsupportedFields(req)
|
||||
}
|
||||
|
||||
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
|
||||
messages := make([]dto.Message, 0)
|
||||
if rawJSONPresent(req.Instructions) {
|
||||
@@ -373,6 +385,10 @@ func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func RequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
||||
return responsesRequestToolChoiceToChat(raw)
|
||||
}
|
||||
|
||||
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
@@ -403,6 +419,10 @@ func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.Respons
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
||||
return responsesRequestTextToChatResponseFormat(raw)
|
||||
}
|
||||
|
||||
func responsesImagePartToChatImageURL(part map[string]any) any {
|
||||
if imageURL, ok := part["image_url"]; ok {
|
||||
return imageURL
|
||||
@@ -472,6 +492,10 @@ func responsesCallID(item map[string]any) string {
|
||||
return strings.TrimSpace(common.Interface2String(item["id"]))
|
||||
}
|
||||
|
||||
func CallID(item map[string]any) string {
|
||||
return responsesCallID(item)
|
||||
}
|
||||
|
||||
func responsesArgumentsString(value any) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
@@ -519,3 +543,11 @@ func rawJSONPresent(raw json.RawMessage) bool {
|
||||
}
|
||||
return common.GetJsonType(raw) != "null"
|
||||
}
|
||||
|
||||
func JSONString(raw json.RawMessage) (string, error) {
|
||||
return responsesJSONString(raw)
|
||||
}
|
||||
|
||||
func RawJSONPresent(raw json.RawMessage) bool {
|
||||
return rawJSONPresent(raw)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package relayconvert
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -0,0 +1,289 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
responsesEventCreated = "response.created"
|
||||
responsesEventCompleted = "response.completed"
|
||||
responsesEventDone = "response.done"
|
||||
responsesEventIncomplete = "response.incomplete"
|
||||
responsesEventFailed = "response.failed"
|
||||
responsesEventError = "response.error"
|
||||
responsesEventOutputTextDelta = "response.output_text.delta"
|
||||
responsesEventOutputItemAdded = "response.output_item.added"
|
||||
responsesEventOutputItemDone = "response.output_item.done"
|
||||
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
||||
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
|
||||
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
|
||||
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
|
||||
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
|
||||
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
|
||||
responsesEventReasoningTextDelta = "response.reasoning_text.delta"
|
||||
responsesEventReasoningTextDone = "response.reasoning_text.done"
|
||||
responsesOutputTypeFunctionCall = "function_call"
|
||||
responsesOutputTypeCustomToolCall = "custom_tool_call"
|
||||
responsesOutputTypeMessage = "message"
|
||||
responsesOutputTypeReasoning = "reasoning"
|
||||
responsesIncompleteReasonContentFilter = "content_filter"
|
||||
responsesIncompleteReasonMaxTokens = "max_output_tokens"
|
||||
)
|
||||
|
||||
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
|
||||
if resp == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
status := responseStatusString(resp)
|
||||
if status != "incomplete" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if resp.IncompleteDetails != nil {
|
||||
reason = strings.TrimSpace(resp.IncompleteDetails.Reason)
|
||||
}
|
||||
if reason == responsesIncompleteReasonContentFilter {
|
||||
return "content_filter", true
|
||||
}
|
||||
return "length", true
|
||||
}
|
||||
|
||||
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
||||
if resp == nil {
|
||||
return nil, nil, errors.New("response is nil")
|
||||
}
|
||||
|
||||
text := ExtractOutputTextFromResponses(resp)
|
||||
reasoning := ExtractReasoningTextFromResponses(resp)
|
||||
|
||||
usage := UsageFromResponsesUsage(resp.Usage)
|
||||
|
||||
created := resp.CreatedAt
|
||||
|
||||
var toolCalls []dto.ToolCallResponse
|
||||
if len(resp.Output) > 0 {
|
||||
for _, out := range resp.Output {
|
||||
if !isResponsesToolOutputType(out.Type) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(out.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
callId := strings.TrimSpace(out.CallId)
|
||||
if callId == "" {
|
||||
callId = strings.TrimSpace(out.ID)
|
||||
}
|
||||
toolCalls = append(toolCalls, dto.ToolCallResponse{
|
||||
ID: callId,
|
||||
Type: "function",
|
||||
Function: dto.FunctionResponse{
|
||||
Name: name,
|
||||
Arguments: out.ArgumentsString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
if mappedReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
|
||||
finishReason = mappedReason
|
||||
} else if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
|
||||
msg := dto.Message{
|
||||
Role: "assistant",
|
||||
Content: text,
|
||||
}
|
||||
if reasoning != "" {
|
||||
msg.ReasoningContent = &reasoning
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
msg.SetToolCalls(toolCalls)
|
||||
}
|
||||
|
||||
out := &dto.OpenAITextResponse{
|
||||
Id: id,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Model: resp.Model,
|
||||
Choices: []dto.OpenAITextResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
},
|
||||
Usage: *usage,
|
||||
}
|
||||
|
||||
return out, usage, nil
|
||||
}
|
||||
|
||||
func UsageFromResponsesUsage(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.NewOpenAIResponsesBillingUsage(src)
|
||||
}
|
||||
usage.Cost = src.Cost
|
||||
if src.InputTokens != 0 {
|
||||
usage.PromptTokens = src.InputTokens
|
||||
usage.InputTokens = src.InputTokens
|
||||
}
|
||||
if src.OutputTokens != 0 {
|
||||
usage.CompletionTokens = src.OutputTokens
|
||||
usage.OutputTokens = src.OutputTokens
|
||||
}
|
||||
if src.TotalTokens != 0 {
|
||||
usage.TotalTokens = src.TotalTokens
|
||||
} else {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
if src.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
|
||||
usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens
|
||||
usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens
|
||||
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
|
||||
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
|
||||
}
|
||||
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
|
||||
src.CompletionTokenDetails.TextTokens != 0 ||
|
||||
src.CompletionTokenDetails.AudioTokens != 0 ||
|
||||
src.CompletionTokenDetails.ImageTokens != 0 {
|
||||
usage.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens
|
||||
usage.CompletionTokenDetails.TextTokens = src.CompletionTokenDetails.TextTokens
|
||||
usage.CompletionTokenDetails.AudioTokens = src.CompletionTokenDetails.AudioTokens
|
||||
usage.CompletionTokenDetails.ImageTokens = src.CompletionTokenDetails.ImageTokens
|
||||
}
|
||||
usage.ClaudeCacheCreation5mTokens = src.ClaudeCacheCreation5mTokens
|
||||
usage.ClaudeCacheCreation1hTokens = src.ClaudeCacheCreation1hTokens
|
||||
return usage
|
||||
}
|
||||
|
||||
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Prefer assistant message outputs.
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != "message" {
|
||||
continue
|
||||
}
|
||||
if out.Role != "" && out.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
for _, c := range out.Content {
|
||||
if c.Type == "output_text" && c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sb.Len() > 0 {
|
||||
return sb.String()
|
||||
}
|
||||
for _, out := range resp.Output {
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != responsesOutputTypeReasoning {
|
||||
continue
|
||||
}
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Status) == 0 {
|
||||
return ""
|
||||
}
|
||||
var status string
|
||||
_ = common.Unmarshal(resp.Status, &status)
|
||||
return strings.TrimSpace(status)
|
||||
}
|
||||
|
||||
func ensureIncompleteResponse(resp *dto.OpenAIResponsesResponse) *dto.OpenAIResponsesResponse {
|
||||
if resp == nil {
|
||||
resp = &dto.OpenAIResponsesResponse{}
|
||||
}
|
||||
if len(resp.Status) == 0 {
|
||||
resp.Status = []byte(`"incomplete"`)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func isResponsesToolOutputType(outputType string) bool {
|
||||
return outputType == responsesOutputTypeFunctionCall || outputType == responsesOutputTypeCustomToolCall
|
||||
}
|
||||
|
||||
func responseStreamEventItemID(event *dto.ResponsesStreamResponse) string {
|
||||
if event == nil {
|
||||
return ""
|
||||
}
|
||||
if event.Item != nil {
|
||||
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
|
||||
return itemID
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(event.ItemID)
|
||||
}
|
||||
|
||||
func fallbackToolKey(itemID string, callID string, outputIndex *int) string {
|
||||
if outputIndex != nil {
|
||||
return fmt.Sprintf("output:%d", *outputIndex)
|
||||
}
|
||||
if strings.TrimSpace(itemID) != "" {
|
||||
return "item:" + strings.TrimSpace(itemID)
|
||||
}
|
||||
if strings.TrimSpace(callID) != "" {
|
||||
return "call:" + strings.TrimSpace(callID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fallbackCallID(event *dto.ResponsesStreamResponse) string {
|
||||
if event == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(event.ItemID) != "" {
|
||||
return strings.TrimSpace(event.ItemID)
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
return fmt.Sprintf("call_output_%d", *event.OutputIndex)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+1
-184
@@ -1,51 +1,13 @@
|
||||
package relayconvert
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"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 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 TestResponsesResponseToChatCompletionsPreservesTextAndToolCalls(t *testing.T) {
|
||||
resp := &dto.OpenAIResponsesResponse{
|
||||
ID: "resp_1",
|
||||
@@ -413,144 +375,6 @@ func TestResponsesBufferedAccumulatorDoesNotDuplicatePendingArgsWithOutputIndexA
|
||||
assert.Empty(t, acc.pendingByItemID)
|
||||
}
|
||||
|
||||
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 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 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
|
||||
}
|
||||
|
||||
func newTestResponsesStreamState() *ResponsesToChatStreamState {
|
||||
state := NewResponsesToChatStreamState("gpt-test", false)
|
||||
state.ID = "chatcmpl_test"
|
||||
@@ -564,10 +388,3 @@ func mustStreamChunks(t *testing.T, state *ResponsesToChatStreamState, event *dt
|
||||
require.NoError(t, err)
|
||||
return chunks
|
||||
}
|
||||
|
||||
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
|
||||
t.Helper()
|
||||
events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
|
||||
require.NoError(t, err)
|
||||
return events
|
||||
}
|
||||
+1
-264
@@ -1,7 +1,6 @@
|
||||
package relayconvert
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -11,207 +10,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
responsesEventCreated = "response.created"
|
||||
responsesEventCompleted = "response.completed"
|
||||
responsesEventDone = "response.done"
|
||||
responsesEventIncomplete = "response.incomplete"
|
||||
responsesEventFailed = "response.failed"
|
||||
responsesEventError = "response.error"
|
||||
responsesEventOutputTextDelta = "response.output_text.delta"
|
||||
responsesEventOutputItemAdded = "response.output_item.added"
|
||||
responsesEventOutputItemDone = "response.output_item.done"
|
||||
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
||||
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
|
||||
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
|
||||
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
|
||||
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
|
||||
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
|
||||
responsesEventReasoningTextDelta = "response.reasoning_text.delta"
|
||||
responsesEventReasoningTextDone = "response.reasoning_text.done"
|
||||
responsesOutputTypeFunctionCall = "function_call"
|
||||
responsesOutputTypeCustomToolCall = "custom_tool_call"
|
||||
responsesOutputTypeMessage = "message"
|
||||
responsesOutputTypeReasoning = "reasoning"
|
||||
responsesIncompleteReasonContentFilter = "content_filter"
|
||||
responsesIncompleteReasonMaxTokens = "max_output_tokens"
|
||||
)
|
||||
|
||||
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
|
||||
if resp == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
status := responseStatusString(resp)
|
||||
if status != "incomplete" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if resp.IncompleteDetails != nil {
|
||||
reason = strings.TrimSpace(resp.IncompleteDetails.Reason)
|
||||
}
|
||||
if reason == responsesIncompleteReasonContentFilter {
|
||||
return "content_filter", true
|
||||
}
|
||||
return "length", true
|
||||
}
|
||||
|
||||
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
||||
if resp == nil {
|
||||
return nil, nil, errors.New("response is nil")
|
||||
}
|
||||
|
||||
text := ExtractOutputTextFromResponses(resp)
|
||||
reasoning := ExtractReasoningTextFromResponses(resp)
|
||||
|
||||
usage := UsageFromResponsesUsage(resp.Usage)
|
||||
|
||||
created := resp.CreatedAt
|
||||
|
||||
var toolCalls []dto.ToolCallResponse
|
||||
if len(resp.Output) > 0 {
|
||||
for _, out := range resp.Output {
|
||||
if !isResponsesToolOutputType(out.Type) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(out.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
callId := strings.TrimSpace(out.CallId)
|
||||
if callId == "" {
|
||||
callId = strings.TrimSpace(out.ID)
|
||||
}
|
||||
toolCalls = append(toolCalls, dto.ToolCallResponse{
|
||||
ID: callId,
|
||||
Type: "function",
|
||||
Function: dto.FunctionResponse{
|
||||
Name: name,
|
||||
Arguments: out.ArgumentsString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
if mappedReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
|
||||
finishReason = mappedReason
|
||||
} else if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
|
||||
msg := dto.Message{
|
||||
Role: "assistant",
|
||||
Content: text,
|
||||
}
|
||||
if reasoning != "" {
|
||||
msg.ReasoningContent = &reasoning
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
msg.SetToolCalls(toolCalls)
|
||||
}
|
||||
|
||||
out := &dto.OpenAITextResponse{
|
||||
Id: id,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Model: resp.Model,
|
||||
Choices: []dto.OpenAITextResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
},
|
||||
Usage: *usage,
|
||||
}
|
||||
|
||||
return out, usage, nil
|
||||
}
|
||||
|
||||
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
|
||||
usage := &dto.Usage{}
|
||||
if src == nil {
|
||||
return usage
|
||||
}
|
||||
if src.InputTokens != 0 {
|
||||
usage.PromptTokens = src.InputTokens
|
||||
usage.InputTokens = src.InputTokens
|
||||
}
|
||||
if src.OutputTokens != 0 {
|
||||
usage.CompletionTokens = src.OutputTokens
|
||||
usage.OutputTokens = src.OutputTokens
|
||||
}
|
||||
if src.TotalTokens != 0 {
|
||||
usage.TotalTokens = src.TotalTokens
|
||||
} else {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
if src.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
|
||||
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
|
||||
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
|
||||
}
|
||||
if src.CompletionTokenDetails.ReasoningTokens != 0 {
|
||||
usage.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Prefer assistant message outputs.
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != "message" {
|
||||
continue
|
||||
}
|
||||
if out.Role != "" && out.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
for _, c := range out.Content {
|
||||
if c.Type == "output_text" && c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sb.Len() > 0 {
|
||||
return sb.String()
|
||||
}
|
||||
for _, out := range resp.Output {
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != responsesOutputTypeReasoning {
|
||||
continue
|
||||
}
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type ResponsesToChatStreamState struct {
|
||||
ID string
|
||||
Model string
|
||||
@@ -902,64 +700,3 @@ func (a *ResponsesBufferedAccumulator) findToolIndex(event *dto.ResponsesStreamR
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Status) == 0 {
|
||||
return ""
|
||||
}
|
||||
var status string
|
||||
_ = common.Unmarshal(resp.Status, &status)
|
||||
return strings.TrimSpace(status)
|
||||
}
|
||||
|
||||
func ensureIncompleteResponse(resp *dto.OpenAIResponsesResponse) *dto.OpenAIResponsesResponse {
|
||||
if resp == nil {
|
||||
resp = &dto.OpenAIResponsesResponse{}
|
||||
}
|
||||
if len(resp.Status) == 0 {
|
||||
resp.Status = []byte(`"incomplete"`)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func isResponsesToolOutputType(outputType string) bool {
|
||||
return outputType == responsesOutputTypeFunctionCall || outputType == responsesOutputTypeCustomToolCall
|
||||
}
|
||||
|
||||
func responseStreamEventItemID(event *dto.ResponsesStreamResponse) string {
|
||||
if event == nil {
|
||||
return ""
|
||||
}
|
||||
if event.Item != nil {
|
||||
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
|
||||
return itemID
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(event.ItemID)
|
||||
}
|
||||
|
||||
func fallbackToolKey(itemID string, callID string, outputIndex *int) string {
|
||||
if outputIndex != nil {
|
||||
return fmt.Sprintf("output:%d", *outputIndex)
|
||||
}
|
||||
if strings.TrimSpace(itemID) != "" {
|
||||
return "item:" + strings.TrimSpace(itemID)
|
||||
}
|
||||
if strings.TrimSpace(callID) != "" {
|
||||
return "call:" + strings.TrimSpace(callID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fallbackCallID(event *dto.ResponsesStreamResponse) string {
|
||||
if event == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(event.ItemID) != "" {
|
||||
return strings.TrimSpace(event.ItemID)
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
return fmt.Sprintf("call_output_%d", *event.OutputIndex)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package claude
|
||||
|
||||
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
||||
remainder := totalTokens - tokens5m - tokens1h
|
||||
if remainder < 0 {
|
||||
remainder = 0
|
||||
}
|
||||
return tokens5m + remainder, tokens1h
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package claude
|
||||
|
||||
import "github.com/QuantumNous/new-api/dto"
|
||||
|
||||
func MapOpenAIToolChoice(toolChoice any, parallelToolCalls *bool) *dto.ClaudeToolChoice {
|
||||
var claudeToolChoice *dto.ClaudeToolChoice
|
||||
|
||||
if toolChoiceStr, ok := toolChoice.(string); ok {
|
||||
switch toolChoiceStr {
|
||||
case "auto":
|
||||
claudeToolChoice = &dto.ClaudeToolChoice{
|
||||
Type: "auto",
|
||||
}
|
||||
case "required":
|
||||
claudeToolChoice = &dto.ClaudeToolChoice{
|
||||
Type: "any",
|
||||
}
|
||||
case "none":
|
||||
claudeToolChoice = &dto.ClaudeToolChoice{
|
||||
Type: "none",
|
||||
}
|
||||
}
|
||||
} else if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
|
||||
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
|
||||
if toolName, ok := function["name"].(string); ok {
|
||||
claudeToolChoice = &dto.ClaudeToolChoice{
|
||||
Type: "tool",
|
||||
Name: toolName,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if parallelToolCalls != nil {
|
||||
if claudeToolChoice == nil {
|
||||
claudeToolChoice = &dto.ClaudeToolChoice{
|
||||
Type: "auto",
|
||||
}
|
||||
}
|
||||
if claudeToolChoice.Type != "none" {
|
||||
claudeToolChoice.DisableParallelToolUse = !*parallelToolCalls
|
||||
}
|
||||
}
|
||||
|
||||
return claudeToolChoice
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relaymeta "github.com/QuantumNous/new-api/service/relayconvert/internal/meta"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/setting/reasoning"
|
||||
)
|
||||
|
||||
var SupportedMimeTypes = map[string]bool{
|
||||
"application/pdf": true,
|
||||
"audio/mpeg": true,
|
||||
"audio/mp3": true,
|
||||
"audio/wav": true,
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/jpg": true,
|
||||
"image/webp": true,
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
"text/plain": true,
|
||||
"video/mov": true,
|
||||
"video/mpeg": true,
|
||||
"video/mp4": true,
|
||||
"video/mpg": true,
|
||||
"video/avi": true,
|
||||
"video/wmv": true,
|
||||
"video/mpegps": true,
|
||||
"video/flv": true,
|
||||
}
|
||||
|
||||
var SafetySettingCategories = []string{
|
||||
"HARM_CATEGORY_HARASSMENT",
|
||||
"HARM_CATEGORY_HATE_SPEECH",
|
||||
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
"HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
}
|
||||
|
||||
const ThoughtSignatureBypassValue = "context_engineering_is_the_way_to_go"
|
||||
|
||||
const (
|
||||
pro25MinBudget = 128
|
||||
pro25MaxBudget = 32768
|
||||
flash25MaxBudget = 24576
|
||||
flash25LiteMinBudget = 512
|
||||
flash25LiteMaxBudget = 24576
|
||||
)
|
||||
|
||||
func ShouldAttachThoughtSignature() bool {
|
||||
return model_setting.GetGeminiSettings().FunctionCallThoughtSignatureEnabled
|
||||
}
|
||||
|
||||
func AttachThoughtSignatureBypass(part *dto.GeminiPart) bool {
|
||||
if part == nil || len(part.ThoughtSignature) > 0 || !ShouldAttachThoughtSignature() {
|
||||
return false
|
||||
}
|
||||
part.ThoughtSignature = []byte(strconv.Quote(ThoughtSignatureBypassValue))
|
||||
return true
|
||||
}
|
||||
|
||||
func AttachFunctionCallThoughtSignature(part *dto.GeminiPart) bool {
|
||||
if part == nil || !HasFunctionCallContent(part.FunctionCall) {
|
||||
return false
|
||||
}
|
||||
return AttachThoughtSignatureBypass(part)
|
||||
}
|
||||
|
||||
func AttachFirstTextThoughtSignature(parts []dto.GeminiPart) bool {
|
||||
if !ShouldAttachThoughtSignature() {
|
||||
return false
|
||||
}
|
||||
for i := range parts {
|
||||
if parts[i].Text != "" && len(parts[i].ThoughtSignature) == 0 {
|
||||
parts[i].ThoughtSignature = []byte(strconv.Quote(ThoughtSignatureBypassValue))
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) {
|
||||
if geminiRequest == nil || info == nil || !model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
modelName := relaymeta.RelayInfoUpstreamModelName(info)
|
||||
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
|
||||
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
|
||||
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
|
||||
|
||||
if strings.Contains(modelName, "-thinking-") {
|
||||
parts := strings.SplitN(modelName, "-thinking-", 2)
|
||||
if len(parts) == 2 && parts[1] != "" {
|
||||
if budgetTokens, err := strconv.Atoi(parts[1]); err == nil {
|
||||
clampedBudget := clampThinkingBudget(modelName, budgetTokens)
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
|
||||
ThinkingBudget: common.GetPointer(clampedBudget),
|
||||
IncludeThoughts: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if strings.HasSuffix(modelName, "-thinking") {
|
||||
unsupportedModels := []string{
|
||||
"gemini-2.5-pro-preview-05-06",
|
||||
"gemini-2.5-pro-preview-03-25",
|
||||
}
|
||||
isUnsupported := false
|
||||
for _, unsupportedModel := range unsupportedModels {
|
||||
if strings.HasPrefix(modelName, unsupportedModel) {
|
||||
isUnsupported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isUnsupported {
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
|
||||
IncludeThoughts: true,
|
||||
}
|
||||
} else {
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
|
||||
IncludeThoughts: true,
|
||||
}
|
||||
if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
|
||||
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(*geminiRequest.GenerationConfig.MaxOutputTokens)
|
||||
clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
|
||||
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampedBudget)
|
||||
} else if len(oaiRequest) > 0 {
|
||||
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampThinkingBudgetByEffort(modelName, oaiRequest[0].ReasoningEffort))
|
||||
}
|
||||
}
|
||||
} else if strings.HasSuffix(modelName, "-nothinking") {
|
||||
if !isNew25Pro {
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
|
||||
ThinkingBudget: common.GetPointer(0),
|
||||
}
|
||||
}
|
||||
} else if _, level, ok := reasoning.TrimEffortSuffix(modelName); ok && level != "" {
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
|
||||
IncludeThoughts: true,
|
||||
ThinkingLevel: level,
|
||||
}
|
||||
info.ReasoningEffort = level
|
||||
}
|
||||
}
|
||||
|
||||
func ParseStopSequences(stop any) []string {
|
||||
if stop == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := stop.(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
return []string{v}
|
||||
}
|
||||
case []string:
|
||||
return v
|
||||
case []interface{}:
|
||||
sequences := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if str, ok := item.(string); ok && str != "" {
|
||||
sequences = append(sequences, str)
|
||||
}
|
||||
}
|
||||
return sequences
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func HasFunctionCallContent(call *dto.FunctionCall) bool {
|
||||
if call == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(call.FunctionName) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
switch v := call.Arguments.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case string:
|
||||
return strings.TrimSpace(v) != ""
|
||||
case map[string]interface{}:
|
||||
return len(v) > 0
|
||||
case []interface{}:
|
||||
return len(v) > 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func SupportedMimeTypesList() []string {
|
||||
keys := make([]string, 0, len(SupportedMimeTypes))
|
||||
for key := range SupportedMimeTypes {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func isNew25ProModel(modelName string) bool {
|
||||
return strings.HasPrefix(modelName, "gemini-2.5-pro") &&
|
||||
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
|
||||
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
|
||||
}
|
||||
|
||||
func is25FlashLiteModel(modelName string) bool {
|
||||
return strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
|
||||
}
|
||||
|
||||
func clampThinkingBudget(modelName string, budget int) int {
|
||||
isNew25Pro := isNew25ProModel(modelName)
|
||||
is25FlashLite := is25FlashLiteModel(modelName)
|
||||
|
||||
if is25FlashLite {
|
||||
if budget < flash25LiteMinBudget {
|
||||
return flash25LiteMinBudget
|
||||
}
|
||||
if budget > flash25LiteMaxBudget {
|
||||
return flash25LiteMaxBudget
|
||||
}
|
||||
} else if isNew25Pro {
|
||||
if budget < pro25MinBudget {
|
||||
return pro25MinBudget
|
||||
}
|
||||
if budget > pro25MaxBudget {
|
||||
return pro25MaxBudget
|
||||
}
|
||||
} else {
|
||||
if budget < 0 {
|
||||
return 0
|
||||
}
|
||||
if budget > flash25MaxBudget {
|
||||
return flash25MaxBudget
|
||||
}
|
||||
}
|
||||
return budget
|
||||
}
|
||||
|
||||
func clampThinkingBudgetByEffort(modelName string, effort string) int {
|
||||
isNew25Pro := isNew25ProModel(modelName)
|
||||
is25FlashLite := is25FlashLiteModel(modelName)
|
||||
|
||||
maxBudget := 0
|
||||
if is25FlashLite {
|
||||
maxBudget = flash25LiteMaxBudget
|
||||
}
|
||||
if isNew25Pro {
|
||||
maxBudget = pro25MaxBudget
|
||||
} else {
|
||||
maxBudget = flash25MaxBudget
|
||||
}
|
||||
switch effort {
|
||||
case "high":
|
||||
maxBudget = maxBudget * 80 / 100
|
||||
case "medium":
|
||||
maxBudget = maxBudget * 50 / 100
|
||||
case "low":
|
||||
maxBudget = maxBudget * 20 / 100
|
||||
case "minimal":
|
||||
maxBudget = maxBudget * 5 / 100
|
||||
}
|
||||
return clampThinkingBudget(modelName, maxBudget)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
var geminiOpenAPISchemaAllowedFields = map[string]struct{}{
|
||||
"anyOf": {},
|
||||
"default": {},
|
||||
"description": {},
|
||||
"enum": {},
|
||||
"example": {},
|
||||
"format": {},
|
||||
"items": {},
|
||||
"maxItems": {},
|
||||
"maxLength": {},
|
||||
"maxProperties": {},
|
||||
"maximum": {},
|
||||
"minItems": {},
|
||||
"minLength": {},
|
||||
"minProperties": {},
|
||||
"minimum": {},
|
||||
"nullable": {},
|
||||
"pattern": {},
|
||||
"properties": {},
|
||||
"propertyOrdering": {},
|
||||
"required": {},
|
||||
"title": {},
|
||||
"type": {},
|
||||
}
|
||||
|
||||
const geminiFunctionSchemaMaxDepth = 64
|
||||
|
||||
func CleanFunctionParameters(params interface{}) interface{} {
|
||||
return cleanGeminiFunctionParametersWithDepth(params, 0)
|
||||
}
|
||||
|
||||
func cleanGeminiFunctionParametersWithDepth(params interface{}, depth int) interface{} {
|
||||
if params == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if depth >= geminiFunctionSchemaMaxDepth {
|
||||
return cleanGeminiFunctionParametersShallow(params)
|
||||
}
|
||||
|
||||
switch v := params.(type) {
|
||||
case map[string]interface{}:
|
||||
cleanedMap := make(map[string]interface{}, len(v))
|
||||
for key, val := range v {
|
||||
if _, ok := geminiOpenAPISchemaAllowedFields[key]; ok {
|
||||
cleanedMap[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
|
||||
|
||||
if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil {
|
||||
cleanedProps := make(map[string]interface{})
|
||||
for propName, propValue := range props {
|
||||
cleanedProps[propName] = cleanGeminiFunctionParametersWithDepth(propValue, depth+1)
|
||||
}
|
||||
cleanedMap["properties"] = cleanedProps
|
||||
}
|
||||
|
||||
if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil {
|
||||
cleanedMap["items"] = cleanGeminiFunctionParametersWithDepth(items, depth+1)
|
||||
}
|
||||
if itemsArray, ok := cleanedMap["items"].([]interface{}); ok && len(itemsArray) > 0 {
|
||||
cleanedMap["items"] = cleanGeminiFunctionParametersWithDepth(itemsArray[0], depth+1)
|
||||
}
|
||||
|
||||
if nested, ok := cleanedMap["anyOf"].([]interface{}); ok && nested != nil {
|
||||
cleanedNested := make([]interface{}, len(nested))
|
||||
for i, item := range nested {
|
||||
cleanedNested[i] = cleanGeminiFunctionParametersWithDepth(item, depth+1)
|
||||
}
|
||||
cleanedMap["anyOf"] = cleanedNested
|
||||
}
|
||||
|
||||
return cleanedMap
|
||||
case []interface{}:
|
||||
cleanedArray := make([]interface{}, len(v))
|
||||
for i, item := range v {
|
||||
cleanedArray[i] = cleanGeminiFunctionParametersWithDepth(item, depth+1)
|
||||
}
|
||||
return cleanedArray
|
||||
default:
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
func cleanGeminiFunctionParametersShallow(params interface{}) interface{} {
|
||||
switch v := params.(type) {
|
||||
case map[string]interface{}:
|
||||
cleanedMap := make(map[string]interface{}, len(v))
|
||||
for key, val := range v {
|
||||
if _, ok := geminiOpenAPISchemaAllowedFields[key]; ok {
|
||||
cleanedMap[key] = val
|
||||
}
|
||||
}
|
||||
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
|
||||
delete(cleanedMap, "properties")
|
||||
delete(cleanedMap, "items")
|
||||
delete(cleanedMap, "anyOf")
|
||||
return cleanedMap
|
||||
case []interface{}:
|
||||
return []interface{}{}
|
||||
default:
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) {
|
||||
rawType, ok := schema["type"]
|
||||
if !ok || rawType == nil {
|
||||
return
|
||||
}
|
||||
|
||||
normalize := func(t string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "object":
|
||||
return "OBJECT", false
|
||||
case "array":
|
||||
return "ARRAY", false
|
||||
case "string":
|
||||
return "STRING", false
|
||||
case "integer":
|
||||
return "INTEGER", false
|
||||
case "number":
|
||||
return "NUMBER", false
|
||||
case "boolean":
|
||||
return "BOOLEAN", false
|
||||
case "null":
|
||||
return "", true
|
||||
default:
|
||||
return t, false
|
||||
}
|
||||
}
|
||||
|
||||
switch typed := rawType.(type) {
|
||||
case string:
|
||||
normalized, isNull := normalize(typed)
|
||||
if isNull {
|
||||
schema["nullable"] = true
|
||||
delete(schema, "type")
|
||||
return
|
||||
}
|
||||
schema["type"] = normalized
|
||||
case []interface{}:
|
||||
nullable := false
|
||||
var chosen string
|
||||
for _, item := range typed {
|
||||
if value, ok := item.(string); ok {
|
||||
normalized, isNull := normalize(value)
|
||||
if isNull {
|
||||
nullable = true
|
||||
continue
|
||||
}
|
||||
if chosen == "" {
|
||||
chosen = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
if nullable {
|
||||
schema["nullable"] = true
|
||||
}
|
||||
if chosen != "" {
|
||||
schema["type"] = chosen
|
||||
} else {
|
||||
delete(schema, "type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveAdditionalProperties(schema interface{}, depth int) interface{} {
|
||||
if depth >= 5 {
|
||||
return schema
|
||||
}
|
||||
|
||||
value, ok := schema.(map[string]interface{})
|
||||
if !ok || len(value) == 0 {
|
||||
return schema
|
||||
}
|
||||
delete(value, "title")
|
||||
delete(value, "$schema")
|
||||
if typeVal, exists := value["type"]; !exists || (typeVal != "object" && typeVal != "array") {
|
||||
return schema
|
||||
}
|
||||
switch value["type"] {
|
||||
case "object":
|
||||
delete(value, "additionalProperties")
|
||||
if properties, ok := value["properties"].(map[string]interface{}); ok {
|
||||
for key, nested := range properties {
|
||||
properties[key] = RemoveAdditionalProperties(nested, depth+1)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"allOf", "anyOf", "oneOf"} {
|
||||
if nested, ok := value[field].([]interface{}); ok {
|
||||
for i, item := range nested {
|
||||
nested[i] = RemoveAdditionalProperties(item, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "array":
|
||||
if items, ok := value["items"].(map[string]interface{}); ok {
|
||||
value["items"] = RemoveAdditionalProperties(items, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func OpenAIToolChoiceToConfig(toolChoice any) *dto.ToolConfig {
|
||||
if toolChoice == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if toolChoiceStr, ok := toolChoice.(string); ok {
|
||||
config := &dto.ToolConfig{
|
||||
FunctionCallingConfig: &dto.FunctionCallingConfig{},
|
||||
}
|
||||
switch toolChoiceStr {
|
||||
case "auto":
|
||||
config.FunctionCallingConfig.Mode = "AUTO"
|
||||
case "none":
|
||||
config.FunctionCallingConfig.Mode = "NONE"
|
||||
case "required":
|
||||
config.FunctionCallingConfig.Mode = "ANY"
|
||||
default:
|
||||
config.FunctionCallingConfig.Mode = "AUTO"
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
|
||||
if toolChoiceMap["type"] == "function" {
|
||||
config := &dto.ToolConfig{
|
||||
FunctionCallingConfig: &dto.FunctionCallingConfig{
|
||||
Mode: "ANY",
|
||||
},
|
||||
}
|
||||
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
|
||||
if name, ok := function["name"].(string); ok && name != "" {
|
||||
config.FunctionCallingConfig.AllowedFunctionNames = []string{name}
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package relayconvert
|
||||
|
||||
import relaymedia "github.com/QuantumNous/new-api/service/relayconvert/internal/media"
|
||||
|
||||
type MediaResolver = relaymedia.MediaResolver
|
||||
|
||||
func SetMediaResolver(resolver MediaResolver) {
|
||||
relaymedia.SetMediaResolver(resolver)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
claudemessages "github.com/QuantumNous/new-api/service/relayconvert/internal/claude_messages"
|
||||
geminichat "github.com/QuantumNous/new-api/service/relayconvert/internal/gemini_chat"
|
||||
oaichat "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_chat"
|
||||
oairesponses "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_responses"
|
||||
sharedgemini "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/gemini"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||
return claudemessages.ClaudeMessagesRequestToOpenAIChat(claudeRequest, info)
|
||||
}
|
||||
|
||||
func OpenAIChatRequestToClaudeMessages(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
|
||||
return oaichat.OpenAIChatRequestToClaudeMessages(c, textRequest)
|
||||
}
|
||||
|
||||
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||
return geminichat.GeminiGenerateContentRequestToOpenAIChat(geminiRequest, info)
|
||||
}
|
||||
|
||||
func OpenAIChatRequestToGeminiGenerateContent(c *gin.Context, textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) {
|
||||
return oaichat.OpenAIChatRequestToGeminiGenerateContent(c, textRequest, info)
|
||||
}
|
||||
|
||||
func ApplyGeminiThinkingConfig(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) {
|
||||
sharedgemini.ApplyThinkingConfig(geminiRequest, info, oaiRequest...)
|
||||
}
|
||||
|
||||
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
|
||||
return oaichat.ChatCompletionsRequestToResponsesRequest(req)
|
||||
}
|
||||
|
||||
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
|
||||
return oairesponses.ResponsesRequestToChatCompletionsRequest(req)
|
||||
}
|
||||
|
||||
func OpenAIResponsesRequestToClaudeMessages(c *gin.Context, req *dto.OpenAIResponsesRequest) (*dto.ClaudeRequest, error) {
|
||||
return oairesponses.OpenAIResponsesRequestToClaudeMessages(c, req)
|
||||
}
|
||||
|
||||
func OpenAIResponsesRequestToGeminiChat(c *gin.Context, req *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) {
|
||||
return oairesponses.OpenAIResponsesRequestToGeminiChat(c, req, info)
|
||||
}
|
||||
|
||||
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
|
||||
return oaichat.ShouldChatCompletionsUseResponsesPolicy(policy, channelID, channelType, model)
|
||||
}
|
||||
|
||||
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
|
||||
return oaichat.ShouldChatCompletionsUseResponsesGlobal(channelID, channelType, model)
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
claudemessages "github.com/QuantumNous/new-api/service/relayconvert/internal/claude_messages"
|
||||
geminichat "github.com/QuantumNous/new-api/service/relayconvert/internal/gemini_chat"
|
||||
oaichat "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_chat"
|
||||
oairesponses "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_responses"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RequestConverterFunc func(c *gin.Context, info *relaycommon.RelayInfo, request any) (any, error)
|
||||
|
||||
type RequestConverterQuality string
|
||||
|
||||
const (
|
||||
RequestConverterQualityGood RequestConverterQuality = "good"
|
||||
RequestConverterQualityFair RequestConverterQuality = "fair"
|
||||
RequestConverterQualityDiscouraged RequestConverterQuality = "discouraged"
|
||||
)
|
||||
|
||||
type RequestStep struct {
|
||||
Converter string
|
||||
From types.RelayFormat
|
||||
To types.RelayFormat
|
||||
}
|
||||
|
||||
type RequestResult struct {
|
||||
Value any
|
||||
From types.RelayFormat
|
||||
To types.RelayFormat
|
||||
Converter string
|
||||
Quality RequestConverterQuality
|
||||
Steps []RequestStep
|
||||
}
|
||||
|
||||
type RequestConverterSpec struct {
|
||||
ID string
|
||||
From types.RelayFormat
|
||||
To types.RelayFormat
|
||||
Quality RequestConverterQuality
|
||||
Convert RequestConverterFunc
|
||||
StepConverters []string
|
||||
}
|
||||
|
||||
type requestConverterRoute struct {
|
||||
from types.RelayFormat
|
||||
to types.RelayFormat
|
||||
}
|
||||
|
||||
var (
|
||||
requestConverterMu sync.RWMutex
|
||||
requestConverters = make(map[string]RequestConverterSpec)
|
||||
requestConverterRoutes = make(map[requestConverterRoute]string)
|
||||
requestConverterDirectRoutes = make(map[requestConverterRoute]string)
|
||||
)
|
||||
|
||||
const (
|
||||
requestConverterClaudeToGemini = "claude_messages_to_gemini_generate_content"
|
||||
requestConverterClaudeToResponses = "claude_messages_to_openai_responses"
|
||||
requestConverterGeminiToClaude = "gemini_generate_content_to_claude_messages"
|
||||
requestConverterGeminiToResponses = "gemini_generate_content_to_openai_responses"
|
||||
requestConverterResponsesToClaude = "openai_responses_to_claude_messages"
|
||||
)
|
||||
|
||||
const (
|
||||
ConverterNone = "none"
|
||||
ConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
|
||||
ConverterOpenAIChatToClaudeMessages = "openai_chat_completions_to_anthropic_messages"
|
||||
ConverterOpenAIChatToOpenAIResponses = "openai_chat_completions_to_openai_responses"
|
||||
ConverterOpenAIResponsesToOpenAIChat = "openai_responses_to_openai_chat_completions"
|
||||
ConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
|
||||
ConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
|
||||
ConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
|
||||
)
|
||||
|
||||
func registerBuiltinRequestConverter(spec RequestConverterSpec) {
|
||||
spec.ID = strings.TrimSpace(spec.ID)
|
||||
if spec.ID == "" {
|
||||
panic("request converter ID is required")
|
||||
}
|
||||
if spec.From == "" || spec.To == "" {
|
||||
panic(fmt.Sprintf("request converter %q must declare from and to formats", spec.ID))
|
||||
}
|
||||
if spec.Quality == "" {
|
||||
panic(fmt.Sprintf("request converter %q must declare quality", spec.ID))
|
||||
}
|
||||
if spec.Convert == nil && len(spec.StepConverters) == 0 {
|
||||
panic(fmt.Sprintf("request converter %q must declare convert or step converters", spec.ID))
|
||||
}
|
||||
if spec.Convert != nil && len(spec.StepConverters) > 0 {
|
||||
panic(fmt.Sprintf("request converter %q cannot declare convert and step converters together", spec.ID))
|
||||
}
|
||||
if _, exists := requestConverters[spec.ID]; exists {
|
||||
panic(fmt.Sprintf("request converter %q is already registered", spec.ID))
|
||||
}
|
||||
route := requestConverterRoute{from: spec.From, to: spec.To}
|
||||
if existingID, exists := requestConverterRoutes[route]; exists {
|
||||
panic(fmt.Sprintf("request converter route from %s to %s is already registered by %q", spec.From, spec.To, existingID))
|
||||
}
|
||||
|
||||
if len(spec.StepConverters) > 0 {
|
||||
stepConverters := make([]string, 0, len(spec.StepConverters))
|
||||
current := spec.From
|
||||
for _, converterID := range spec.StepConverters {
|
||||
step, ok := requestConverters[converterID]
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("request converter %q references unknown step converter %q", spec.ID, converterID))
|
||||
}
|
||||
if step.Convert == nil || len(step.StepConverters) > 0 {
|
||||
panic(fmt.Sprintf("request converter %q step %q must be a direct converter", spec.ID, converterID))
|
||||
}
|
||||
if step.From != current {
|
||||
panic(fmt.Sprintf("request converter %q step %q expects %s after %s", spec.ID, converterID, step.From, current))
|
||||
}
|
||||
stepConverters = append(stepConverters, converterID)
|
||||
current = step.To
|
||||
}
|
||||
if current != spec.To {
|
||||
panic(fmt.Sprintf("request converter %q ends at %s, expected %s", spec.ID, current, spec.To))
|
||||
}
|
||||
spec.StepConverters = stepConverters
|
||||
}
|
||||
|
||||
requestConverters[spec.ID] = spec
|
||||
requestConverterRoutes[route] = spec.ID
|
||||
if len(spec.StepConverters) == 0 {
|
||||
requestConverterDirectRoutes[route] = spec.ID
|
||||
}
|
||||
}
|
||||
|
||||
func LookupRequestConverter(converter string) (RequestConverterSpec, bool) {
|
||||
requestConverterMu.RLock()
|
||||
defer requestConverterMu.RUnlock()
|
||||
|
||||
spec, ok := requestConverters[strings.TrimSpace(converter)]
|
||||
if !ok {
|
||||
return RequestConverterSpec{}, false
|
||||
}
|
||||
return cloneRequestConverterSpec(spec), true
|
||||
}
|
||||
|
||||
func ConvertRequest(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, request any) (*RequestResult, error) {
|
||||
from, err := inferRequestRelayFormat(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target == "" {
|
||||
return nil, errors.New("target relay format is required")
|
||||
}
|
||||
if from == target {
|
||||
return &RequestResult{
|
||||
Value: request,
|
||||
From: from,
|
||||
To: target,
|
||||
}, nil
|
||||
}
|
||||
|
||||
spec, ok := lookupRequestRoute(from, target)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("request converter from %s to %s is not registered", from, target)
|
||||
}
|
||||
return executeRequestSpec(c, info, from, target, request, spec)
|
||||
}
|
||||
|
||||
func ConvertRequestVia(c *gin.Context, info *relaycommon.RelayInfo, request any, path ...types.RelayFormat) (*RequestResult, error) {
|
||||
from, err := inferRequestRelayFormat(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return nil, errors.New("request conversion path is required")
|
||||
}
|
||||
|
||||
targets := make([]types.RelayFormat, 0, len(path))
|
||||
for _, format := range path {
|
||||
if format == "" {
|
||||
return nil, errors.New("request conversion path contains empty relay format")
|
||||
}
|
||||
targets = append(targets, format)
|
||||
}
|
||||
if targets[0] == from {
|
||||
targets = targets[1:]
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return &RequestResult{
|
||||
Value: request,
|
||||
From: from,
|
||||
To: from,
|
||||
}, nil
|
||||
}
|
||||
|
||||
steps := make([]RequestConverterSpec, 0, len(targets))
|
||||
current := from
|
||||
for _, target := range targets {
|
||||
spec, ok := lookupRequestDirectRoute(current, target)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("request converter from %s to %s is not registered", current, target)
|
||||
}
|
||||
steps = append(steps, spec)
|
||||
current = target
|
||||
}
|
||||
return executeRequestSteps(c, info, from, targets[len(targets)-1], request, "", "", steps)
|
||||
}
|
||||
|
||||
func ConvertRequestByID(c *gin.Context, info *relaycommon.RelayInfo, converter string, request any) (*RequestResult, error) {
|
||||
from, err := inferRequestRelayFormat(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec, ok := LookupRequestConverter(converter)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("request converter %q is not registered", strings.TrimSpace(converter))
|
||||
}
|
||||
if spec.From != "" && spec.From != from {
|
||||
return nil, fmt.Errorf("request converter %q expects %s request, got %s", spec.ID, spec.From, from)
|
||||
}
|
||||
return executeRequestSpec(c, info, from, spec.To, request, spec)
|
||||
}
|
||||
|
||||
func executeRequestSpec(c *gin.Context, info *relaycommon.RelayInfo, from types.RelayFormat, target types.RelayFormat, request any, spec RequestConverterSpec) (*RequestResult, error) {
|
||||
steps, err := expandRequestConverterSteps(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return executeRequestSteps(c, info, from, target, request, spec.ID, spec.Quality, steps)
|
||||
}
|
||||
|
||||
func executeRequestSteps(c *gin.Context, info *relaycommon.RelayInfo, from types.RelayFormat, target types.RelayFormat, request any, converter string, quality RequestConverterQuality, specs []RequestConverterSpec) (*RequestResult, error) {
|
||||
current := request
|
||||
steps := make([]RequestStep, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
var err error
|
||||
current, err = prepareRequestForStep(current, spec, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var step RequestStep
|
||||
current, step, err = executeRequestStep(c, info, spec, current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, step)
|
||||
}
|
||||
|
||||
converters := make([]string, 0, len(steps))
|
||||
for _, step := range steps {
|
||||
converters = append(converters, step.Converter)
|
||||
}
|
||||
if converter == "" {
|
||||
converter = strings.Join(converters, ",")
|
||||
}
|
||||
return &RequestResult{
|
||||
Value: current,
|
||||
From: from,
|
||||
To: target,
|
||||
Converter: converter,
|
||||
Quality: quality,
|
||||
Steps: steps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func expandRequestConverterSteps(spec RequestConverterSpec) ([]RequestConverterSpec, error) {
|
||||
if len(spec.StepConverters) == 0 {
|
||||
if spec.Convert == nil {
|
||||
return nil, fmt.Errorf("request converter %q has no registered implementation", spec.ID)
|
||||
}
|
||||
return []RequestConverterSpec{spec}, nil
|
||||
}
|
||||
if spec.Convert != nil {
|
||||
return nil, fmt.Errorf("request converter %q cannot mix direct and step conversion", spec.ID)
|
||||
}
|
||||
|
||||
steps := make([]RequestConverterSpec, 0, len(spec.StepConverters))
|
||||
current := spec.From
|
||||
for _, converterID := range spec.StepConverters {
|
||||
step, ok := LookupRequestConverter(converterID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("request converter %q references missing step converter %q", spec.ID, converterID)
|
||||
}
|
||||
if step.Convert == nil || len(step.StepConverters) > 0 {
|
||||
return nil, fmt.Errorf("request converter %q step %q is not a direct converter", spec.ID, converterID)
|
||||
}
|
||||
if step.From != current {
|
||||
return nil, fmt.Errorf("request converter %q step %q expects %s request, got %s", spec.ID, converterID, step.From, current)
|
||||
}
|
||||
steps = append(steps, step)
|
||||
current = step.To
|
||||
}
|
||||
if current != spec.To {
|
||||
return nil, fmt.Errorf("request converter %q ends at %s, expected %s", spec.ID, current, spec.To)
|
||||
}
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
func executeRequestStep(c *gin.Context, info *relaycommon.RelayInfo, spec RequestConverterSpec, request any) (any, RequestStep, error) {
|
||||
if spec.Convert == nil {
|
||||
return nil, RequestStep{}, fmt.Errorf("request converter %q has no registered implementation", spec.ID)
|
||||
}
|
||||
|
||||
value, err := spec.Convert(c, info, request)
|
||||
if err != nil {
|
||||
return nil, RequestStep{}, err
|
||||
}
|
||||
if info != nil {
|
||||
info.AppendRequestConversion(spec.To)
|
||||
}
|
||||
return value, RequestStep{
|
||||
Converter: spec.ID,
|
||||
From: spec.From,
|
||||
To: spec.To,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func prepareRequestForStep(request any, spec RequestConverterSpec, finalTarget types.RelayFormat) (any, error) {
|
||||
if spec.From != types.RelayFormatOpenAIResponses || finalTarget != types.RelayFormatGemini {
|
||||
return request, nil
|
||||
}
|
||||
|
||||
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
|
||||
responsesRequest = &value
|
||||
}
|
||||
}
|
||||
if responsesRequest == nil {
|
||||
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
|
||||
}
|
||||
|
||||
prepared, err := oairesponses.PrepareOpenAIResponsesRequest(*responsesRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &prepared, nil
|
||||
}
|
||||
|
||||
func lookupRequestRoute(from types.RelayFormat, to types.RelayFormat) (RequestConverterSpec, bool) {
|
||||
requestConverterMu.RLock()
|
||||
defer requestConverterMu.RUnlock()
|
||||
|
||||
converterID, ok := requestConverterRoutes[requestConverterRoute{from: from, to: to}]
|
||||
if !ok {
|
||||
return RequestConverterSpec{}, false
|
||||
}
|
||||
spec, ok := requestConverters[converterID]
|
||||
return cloneRequestConverterSpec(spec), ok
|
||||
}
|
||||
|
||||
func lookupRequestDirectRoute(from types.RelayFormat, to types.RelayFormat) (RequestConverterSpec, bool) {
|
||||
requestConverterMu.RLock()
|
||||
defer requestConverterMu.RUnlock()
|
||||
|
||||
converterID, ok := requestConverterDirectRoutes[requestConverterRoute{from: from, to: to}]
|
||||
if !ok {
|
||||
return RequestConverterSpec{}, false
|
||||
}
|
||||
spec, ok := requestConverters[converterID]
|
||||
return cloneRequestConverterSpec(spec), ok
|
||||
}
|
||||
|
||||
func cloneRequestConverterSpec(spec RequestConverterSpec) RequestConverterSpec {
|
||||
if len(spec.StepConverters) > 0 {
|
||||
spec.StepConverters = append([]string{}, spec.StepConverters...)
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
func inferRequestRelayFormat(request any) (types.RelayFormat, error) {
|
||||
if isNilRequest(request) {
|
||||
return "", errors.New("request is nil")
|
||||
}
|
||||
format, ok := relaycommon.GuessRelayFormatFromRequest(request)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unsupported request type %T", request)
|
||||
}
|
||||
return format, nil
|
||||
}
|
||||
|
||||
func isNilRequest(request any) bool {
|
||||
if request == nil {
|
||||
return true
|
||||
}
|
||||
value := reflect.ValueOf(request)
|
||||
switch value.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return value.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func convertChatRequestToResponses(_ *gin.Context, _ *relaycommon.RelayInfo, request any) (any, error) {
|
||||
chatRequest, ok := request.(*dto.GeneralOpenAIRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.GeneralOpenAIRequest); ok {
|
||||
chatRequest = &value
|
||||
}
|
||||
}
|
||||
if chatRequest == nil {
|
||||
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
|
||||
}
|
||||
return oaichat.ChatCompletionsRequestToResponsesRequest(chatRequest)
|
||||
}
|
||||
|
||||
func convertClaudeRequestToOpenAI(_ *gin.Context, info *relaycommon.RelayInfo, request any) (any, error) {
|
||||
claudeRequest, ok := request.(*dto.ClaudeRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.ClaudeRequest); ok {
|
||||
claudeRequest = &value
|
||||
}
|
||||
}
|
||||
if claudeRequest == nil {
|
||||
return nil, fmt.Errorf("expected Anthropic Messages request, got %T", request)
|
||||
}
|
||||
return claudemessages.ClaudeMessagesRequestToOpenAIChat(*claudeRequest, info)
|
||||
}
|
||||
|
||||
func convertOpenAIRequestToClaude(c *gin.Context, _ *relaycommon.RelayInfo, request any) (any, error) {
|
||||
openAIRequest, ok := request.(*dto.GeneralOpenAIRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.GeneralOpenAIRequest); ok {
|
||||
openAIRequest = &value
|
||||
}
|
||||
}
|
||||
if openAIRequest == nil {
|
||||
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
|
||||
}
|
||||
return oaichat.OpenAIChatRequestToClaudeMessages(c, *openAIRequest)
|
||||
}
|
||||
|
||||
func convertGeminiRequestToOpenAI(_ *gin.Context, info *relaycommon.RelayInfo, request any) (any, error) {
|
||||
geminiRequest, ok := request.(*dto.GeminiChatRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.GeminiChatRequest); ok {
|
||||
geminiRequest = &value
|
||||
}
|
||||
}
|
||||
if geminiRequest == nil {
|
||||
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", request)
|
||||
}
|
||||
return geminichat.GeminiGenerateContentRequestToOpenAIChat(geminiRequest, info)
|
||||
}
|
||||
|
||||
func convertOpenAIRequestToGemini(c *gin.Context, info *relaycommon.RelayInfo, request any) (any, error) {
|
||||
openAIRequest, ok := request.(*dto.GeneralOpenAIRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.GeneralOpenAIRequest); ok {
|
||||
openAIRequest = &value
|
||||
}
|
||||
}
|
||||
if openAIRequest == nil {
|
||||
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
|
||||
}
|
||||
return oaichat.OpenAIChatRequestToGeminiGenerateContent(c, *openAIRequest, info)
|
||||
}
|
||||
|
||||
func convertOpenAIResponsesRequestToClaudeMessages(c *gin.Context, _ *relaycommon.RelayInfo, request any) (any, error) {
|
||||
responsesRequest, err := oairesponses.OpenAIResponsesRequestFromAny(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return oairesponses.OpenAIResponsesRequestToClaudeMessages(c, responsesRequest)
|
||||
}
|
||||
|
||||
func convertOpenAIResponsesRequestToGeminiChat(c *gin.Context, info *relaycommon.RelayInfo, request any) (any, error) {
|
||||
responsesRequest, err := oairesponses.OpenAIResponsesRequestFromAny(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prepared, err := oairesponses.PrepareOpenAIResponsesRequest(*responsesRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return oairesponses.OpenAIResponsesRequestToGeminiChat(c, &prepared, info)
|
||||
}
|
||||
|
||||
func convertResponsesRequestToChat(_ *gin.Context, _ *relaycommon.RelayInfo, request any) (any, error) {
|
||||
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
|
||||
responsesRequest = &value
|
||||
}
|
||||
}
|
||||
if responsesRequest == nil {
|
||||
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
|
||||
}
|
||||
return oairesponses.ResponsesRequestToChatCompletionsRequest(responsesRequest)
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
sharedgemini "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/gemini"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRequestConverterRegistryListsSupportedTextConverters(t *testing.T) {
|
||||
tests := []struct {
|
||||
converter string
|
||||
from types.RelayFormat
|
||||
to types.RelayFormat
|
||||
quality RequestConverterQuality
|
||||
stepConverters []string
|
||||
advancedCustom bool
|
||||
}{
|
||||
{converter: ConverterClaudeMessagesToOpenAIChat, from: types.RelayFormatClaude, to: types.RelayFormatOpenAI, quality: RequestConverterQualityFair, advancedCustom: true},
|
||||
{converter: ConverterGeminiContentToOpenAIChat, from: types.RelayFormatGemini, to: types.RelayFormatOpenAI, quality: RequestConverterQualityFair, advancedCustom: true},
|
||||
{converter: ConverterOpenAIChatToClaudeMessages, from: types.RelayFormatOpenAI, to: types.RelayFormatClaude, quality: RequestConverterQualityFair, advancedCustom: true},
|
||||
{converter: ConverterOpenAIChatToGeminiContent, from: types.RelayFormatOpenAI, to: types.RelayFormatGemini, quality: RequestConverterQualityFair, advancedCustom: true},
|
||||
{converter: ConverterOpenAIChatToOpenAIResponses, from: types.RelayFormatOpenAI, to: types.RelayFormatOpenAIResponses, quality: RequestConverterQualityGood, advancedCustom: true},
|
||||
{converter: ConverterOpenAIResponsesToOpenAIChat, from: types.RelayFormatOpenAIResponses, to: types.RelayFormatOpenAI, quality: RequestConverterQualityGood, advancedCustom: true},
|
||||
{
|
||||
converter: requestConverterClaudeToGemini,
|
||||
from: types.RelayFormatClaude,
|
||||
to: types.RelayFormatGemini,
|
||||
quality: RequestConverterQualityDiscouraged,
|
||||
stepConverters: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToGeminiContent,
|
||||
},
|
||||
},
|
||||
{
|
||||
converter: requestConverterClaudeToResponses,
|
||||
from: types.RelayFormatClaude,
|
||||
to: types.RelayFormatOpenAIResponses,
|
||||
quality: RequestConverterQualityFair,
|
||||
stepConverters: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
},
|
||||
{
|
||||
converter: requestConverterGeminiToClaude,
|
||||
from: types.RelayFormatGemini,
|
||||
to: types.RelayFormatClaude,
|
||||
quality: RequestConverterQualityDiscouraged,
|
||||
stepConverters: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToClaudeMessages,
|
||||
},
|
||||
},
|
||||
{
|
||||
converter: requestConverterGeminiToResponses,
|
||||
from: types.RelayFormatGemini,
|
||||
to: types.RelayFormatOpenAIResponses,
|
||||
quality: RequestConverterQualityFair,
|
||||
stepConverters: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
},
|
||||
{
|
||||
converter: requestConverterResponsesToClaude,
|
||||
from: types.RelayFormatOpenAIResponses,
|
||||
to: types.RelayFormatClaude,
|
||||
quality: RequestConverterQualityFair,
|
||||
},
|
||||
{
|
||||
converter: ConverterOpenAIResponsesToGemini,
|
||||
from: types.RelayFormatOpenAIResponses,
|
||||
to: types.RelayFormatGemini,
|
||||
quality: RequestConverterQualityFair,
|
||||
advancedCustom: true,
|
||||
},
|
||||
}
|
||||
|
||||
require.Len(t, requestConverters, len(tests))
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.converter, func(t *testing.T) {
|
||||
spec, ok := LookupRequestConverter(tt.converter)
|
||||
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tt.converter, spec.ID)
|
||||
assert.Equal(t, tt.from, spec.From)
|
||||
assert.Equal(t, tt.to, spec.To)
|
||||
assert.Equal(t, tt.quality, spec.Quality)
|
||||
assert.Equal(t, tt.stepConverters, spec.StepConverters)
|
||||
if len(tt.stepConverters) == 0 {
|
||||
assert.NotNil(t, spec.Convert)
|
||||
} else {
|
||||
assert.Nil(t, spec.Convert)
|
||||
}
|
||||
assert.Equal(t, tt.advancedCustom, dto.IsAdvancedCustomConverterAllowed(tt.converter))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertRequestToTargetRecordsConversionChain(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI},
|
||||
}
|
||||
req := &dto.GeneralOpenAIRequest{
|
||||
Model: "gpt-test",
|
||||
Messages: []dto.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAIResponses, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
|
||||
assert.Equal(t, types.RelayFormatOpenAI, result.From)
|
||||
assert.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), result.To)
|
||||
assert.Equal(t, ConverterOpenAIChatToOpenAIResponses, result.Converter)
|
||||
assert.Equal(t, RequestConverterQualityGood, result.Quality)
|
||||
assert.Equal(t, []RequestStep{
|
||||
{
|
||||
Converter: ConverterOpenAIChatToOpenAIResponses,
|
||||
From: types.RelayFormatOpenAI,
|
||||
To: types.RelayFormatOpenAIResponses,
|
||||
},
|
||||
}, result.Steps)
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.RequestConversionChain)
|
||||
}
|
||||
|
||||
func TestConvertRequestPlansMultiHopPath(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatClaude,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatClaude},
|
||||
}
|
||||
req := &dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAIResponses, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
|
||||
assert.Equal(t, types.RelayFormat(types.RelayFormatClaude), result.From)
|
||||
assert.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), result.To)
|
||||
assert.Equal(t, requestConverterClaudeToResponses, result.Converter)
|
||||
assert.Equal(t, RequestConverterQualityFair, result.Quality)
|
||||
assert.Equal(t, []RequestStep{
|
||||
{
|
||||
Converter: ConverterClaudeMessagesToOpenAIChat,
|
||||
From: types.RelayFormatClaude,
|
||||
To: types.RelayFormatOpenAI,
|
||||
},
|
||||
{
|
||||
Converter: ConverterOpenAIChatToOpenAIResponses,
|
||||
From: types.RelayFormatOpenAI,
|
||||
To: types.RelayFormatOpenAIResponses,
|
||||
},
|
||||
}, result.Steps)
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.RequestConversionChain)
|
||||
}
|
||||
|
||||
func TestConvertRequestViaExecutesExplicitPath(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI},
|
||||
}
|
||||
req := &dto.GeneralOpenAIRequest{
|
||||
Model: "gpt-test",
|
||||
Messages: []dto.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequestVia(nil, info, req, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
|
||||
assert.Equal(t, []RequestStep{
|
||||
{
|
||||
Converter: ConverterOpenAIChatToOpenAIResponses,
|
||||
From: types.RelayFormatOpenAI,
|
||||
To: types.RelayFormatOpenAIResponses,
|
||||
},
|
||||
}, result.Steps)
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.RequestConversionChain)
|
||||
}
|
||||
|
||||
func TestConvertRequestResponsesToGeminiAppliesResponsesPreprocess(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-test",
|
||||
},
|
||||
}
|
||||
req := &dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "next turn",
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_custom",
|
||||
"name": "apply_patch",
|
||||
"input": "patch body",
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_custom",
|
||||
"output": "ok",
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_custom",
|
||||
"output": "legacy custom output",
|
||||
},
|
||||
}),
|
||||
Tools: mustRawMessage(t, []map[string]any{
|
||||
{"type": "custom", "name": "apply_patch"},
|
||||
}),
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, geminiReq.GetTools())
|
||||
require.Len(t, geminiReq.Contents, 1)
|
||||
assert.Equal(t, "user", geminiReq.Contents[0].Role)
|
||||
require.Len(t, geminiReq.Contents[0].Parts, 1)
|
||||
assert.Equal(t, "next turn", geminiReq.Contents[0].Parts[0].Text)
|
||||
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
|
||||
assert.Equal(t, RequestConverterQualityFair, result.Quality)
|
||||
assert.Equal(t, []RequestStep{
|
||||
{
|
||||
Converter: ConverterOpenAIResponsesToGemini,
|
||||
From: types.RelayFormatOpenAIResponses,
|
||||
To: types.RelayFormatGemini,
|
||||
},
|
||||
}, result.Steps)
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatGemini}, info.RequestConversionChain)
|
||||
}
|
||||
|
||||
func TestConvertRequestResponsesToGeminiUsesDirectConverter(t *testing.T) {
|
||||
geminiSettings := model_setting.GetGeminiSettings()
|
||||
originalThoughtSignatureEnabled := geminiSettings.FunctionCallThoughtSignatureEnabled
|
||||
geminiSettings.FunctionCallThoughtSignatureEnabled = true
|
||||
t.Cleanup(func() {
|
||||
geminiSettings.FunctionCallThoughtSignatureEnabled = originalThoughtSignatureEnabled
|
||||
})
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-test",
|
||||
},
|
||||
}
|
||||
maxOutputTokens := uint(256)
|
||||
req := &dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Instructions: mustRawMessage(t, "system rules"),
|
||||
MaxOutputTokens: &maxOutputTokens,
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": []map[string]any{
|
||||
{"type": "output_text", "text": "I will call."},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "lookup",
|
||||
"arguments": map[string]any{"q": "x"},
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": map[string]any{"ok": true},
|
||||
},
|
||||
}),
|
||||
Tools: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"propertyNames": map[string]any{"pattern": "^[a-z]+$"},
|
||||
"properties": map[string]any{
|
||||
"q": map[string]any{
|
||||
"type": "string",
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"filters": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
Text: mustRawMessage(t, map[string]any{
|
||||
"format": map[string]any{
|
||||
"type": "json_schema",
|
||||
"name": "answer",
|
||||
"schema": map[string]any{"type": "object"},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
|
||||
assert.Equal(t, []RequestStep{
|
||||
{
|
||||
Converter: ConverterOpenAIResponsesToGemini,
|
||||
From: types.RelayFormatOpenAIResponses,
|
||||
To: types.RelayFormatGemini,
|
||||
},
|
||||
}, result.Steps)
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatGemini}, info.RequestConversionChain)
|
||||
|
||||
require.NotNil(t, geminiReq.SystemInstructions)
|
||||
require.Len(t, geminiReq.SystemInstructions.Parts, 1)
|
||||
assert.Equal(t, "system rules", geminiReq.SystemInstructions.Parts[0].Text)
|
||||
assert.Equal(t, "application/json", geminiReq.GenerationConfig.ResponseMimeType)
|
||||
assert.Equal(t, maxOutputTokens, *geminiReq.GenerationConfig.MaxOutputTokens)
|
||||
|
||||
tools := geminiReq.GetTools()
|
||||
require.Len(t, tools, 1)
|
||||
functions, err := common.Any2Type[[]dto.FunctionRequest](tools[0].FunctionDeclarations)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, functions, 1)
|
||||
assert.Equal(t, "lookup", functions[0].Name)
|
||||
params, ok := functions[0].Parameters.(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "OBJECT", params["type"])
|
||||
assert.NotContains(t, params, "additionalProperties")
|
||||
assert.NotContains(t, params, "propertyNames")
|
||||
properties, ok := params["properties"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
queryParam, ok := properties["q"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "STRING", queryParam["type"])
|
||||
assert.NotContains(t, queryParam, "exclusiveMinimum")
|
||||
filterParam, ok := properties["filters"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
filterItems, ok := filterParam["items"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.NotContains(t, filterItems, "additionalProperties")
|
||||
|
||||
require.Len(t, geminiReq.Contents, 2)
|
||||
assert.Equal(t, "model", geminiReq.Contents[0].Role)
|
||||
require.Len(t, geminiReq.Contents[0].Parts, 2)
|
||||
functionCall := geminiReq.Contents[0].Parts[0].FunctionCall
|
||||
require.NotNil(t, functionCall)
|
||||
assert.Equal(t, "lookup", functionCall.FunctionName)
|
||||
assert.Equal(t, map[string]any{"q": "x"}, functionCall.Arguments)
|
||||
var thoughtSignature string
|
||||
require.NoError(t, common.Unmarshal(geminiReq.Contents[0].Parts[0].ThoughtSignature, &thoughtSignature))
|
||||
assert.Equal(t, sharedgemini.ThoughtSignatureBypassValue, thoughtSignature)
|
||||
assert.Equal(t, "I will call.", geminiReq.Contents[0].Parts[1].Text)
|
||||
|
||||
assert.Equal(t, "user", geminiReq.Contents[1].Role)
|
||||
require.Len(t, geminiReq.Contents[1].Parts, 1)
|
||||
functionResponse := geminiReq.Contents[1].Parts[0].FunctionResponse
|
||||
require.NotNil(t, functionResponse)
|
||||
assert.Equal(t, "lookup", functionResponse.Name)
|
||||
assert.Equal(t, true, functionResponse.Response["ok"])
|
||||
assert.Empty(t, geminiReq.Contents[1].Parts[0].ThoughtSignature)
|
||||
}
|
||||
|
||||
func TestConvertRequestResponsesToGeminiSkipsThoughtSignatureWhenDisabled(t *testing.T) {
|
||||
geminiSettings := model_setting.GetGeminiSettings()
|
||||
originalThoughtSignatureEnabled := geminiSettings.FunctionCallThoughtSignatureEnabled
|
||||
geminiSettings.FunctionCallThoughtSignatureEnabled = false
|
||||
t.Cleanup(func() {
|
||||
geminiSettings.FunctionCallThoughtSignatureEnabled = originalThoughtSignatureEnabled
|
||||
})
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-test",
|
||||
},
|
||||
}
|
||||
req := &dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "lookup",
|
||||
"arguments": map[string]any{"q": "x"},
|
||||
},
|
||||
}),
|
||||
Tools: mustRawMessage(t, []map[string]any{
|
||||
{"type": "function", "name": "lookup", "parameters": map[string]any{"type": "object"}},
|
||||
}),
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||
require.True(t, ok)
|
||||
require.Len(t, geminiReq.Contents, 1)
|
||||
require.Len(t, geminiReq.Contents[0].Parts, 1)
|
||||
require.NotNil(t, geminiReq.Contents[0].Parts[0].FunctionCall)
|
||||
assert.Empty(t, geminiReq.Contents[0].Parts[0].ThoughtSignature)
|
||||
}
|
||||
|
||||
func TestConvertRequestOpenAIChatToGeminiAddsThoughtSignatureForAdvancedCustom(t *testing.T) {
|
||||
geminiSettings := model_setting.GetGeminiSettings()
|
||||
originalThoughtSignatureEnabled := geminiSettings.FunctionCallThoughtSignatureEnabled
|
||||
geminiSettings.FunctionCallThoughtSignatureEnabled = true
|
||||
t.Cleanup(func() {
|
||||
geminiSettings.FunctionCallThoughtSignatureEnabled = originalThoughtSignatureEnabled
|
||||
})
|
||||
|
||||
assistantMessage := dto.Message{Role: "assistant", Content: ""}
|
||||
assistantMessage.SetToolCalls([]dto.ToolCallRequest{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: "lookup",
|
||||
Arguments: `{"q":"x"}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI},
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelType: constant.ChannelTypeAdvancedCustom,
|
||||
UpstreamModelName: "gemini-test",
|
||||
},
|
||||
}
|
||||
req := &dto.GeneralOpenAIRequest{
|
||||
Model: "gemini-test",
|
||||
Messages: []dto.Message{
|
||||
{Role: "user", Content: "hi"},
|
||||
assistantMessage,
|
||||
{Role: "tool", ToolCallId: "call_1", Content: `{"ok":true}`},
|
||||
},
|
||||
Tools: []dto.ToolCallRequest{
|
||||
{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: "lookup",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||
require.True(t, ok)
|
||||
require.Len(t, geminiReq.Contents, 3)
|
||||
assert.Equal(t, "model", geminiReq.Contents[1].Role)
|
||||
require.Len(t, geminiReq.Contents[1].Parts, 1)
|
||||
require.NotNil(t, geminiReq.Contents[1].Parts[0].FunctionCall)
|
||||
var thoughtSignature string
|
||||
require.NoError(t, common.Unmarshal(geminiReq.Contents[1].Parts[0].ThoughtSignature, &thoughtSignature))
|
||||
assert.Equal(t, sharedgemini.ThoughtSignatureBypassValue, thoughtSignature)
|
||||
}
|
||||
|
||||
func TestConvertRequestResponsesToClaudeUsesDirectConverter(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||
}
|
||||
stream := true
|
||||
parallelToolCalls := false
|
||||
maxOutputTokens := uint(512)
|
||||
req := &dto.OpenAIResponsesRequest{
|
||||
Model: "claude-test",
|
||||
Instructions: mustRawMessage(t, "system rules"),
|
||||
Stream: &stream,
|
||||
MaxOutputTokens: &maxOutputTokens,
|
||||
ParallelToolCalls: mustRawMessage(t, parallelToolCalls),
|
||||
Reasoning: &dto.Reasoning{Effort: "medium"},
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "question",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": []map[string]any{
|
||||
{"type": "output_text", "text": "I will call."},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "lookup",
|
||||
"arguments": map[string]any{"q": "x"},
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": map[string]any{"ok": true},
|
||||
},
|
||||
}),
|
||||
Tools: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"q": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatClaude, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
claudeReq, ok := result.Value.(*dto.ClaudeRequest)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, requestConverterResponsesToClaude, result.Converter)
|
||||
assert.Equal(t, []RequestStep{
|
||||
{
|
||||
Converter: requestConverterResponsesToClaude,
|
||||
From: types.RelayFormatOpenAIResponses,
|
||||
To: types.RelayFormatClaude,
|
||||
},
|
||||
}, result.Steps)
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatClaude}, info.RequestConversionChain)
|
||||
|
||||
system, err := common.Any2Type[[]dto.ClaudeMediaMessage](claudeReq.System)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, system, 1)
|
||||
assert.Equal(t, "system rules", system[0].GetText())
|
||||
require.NotNil(t, claudeReq.Stream)
|
||||
assert.True(t, *claudeReq.Stream)
|
||||
assert.Equal(t, maxOutputTokens, *claudeReq.MaxTokens)
|
||||
require.NotNil(t, claudeReq.Thinking)
|
||||
assert.Equal(t, "enabled", claudeReq.Thinking.Type)
|
||||
assert.Equal(t, 2048, claudeReq.Thinking.GetBudgetTokens())
|
||||
|
||||
tools, err := common.Any2Type[[]*dto.Tool](claudeReq.Tools)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tools, 1)
|
||||
assert.Equal(t, "lookup", tools[0].Name)
|
||||
|
||||
require.Len(t, claudeReq.Messages, 3)
|
||||
assert.Equal(t, "user", claudeReq.Messages[0].Role)
|
||||
userParts, err := claudeReq.Messages[0].ParseContent()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, userParts, 1)
|
||||
assert.Equal(t, "question", userParts[0].GetText())
|
||||
|
||||
assert.Equal(t, "assistant", claudeReq.Messages[1].Role)
|
||||
assistantParts, err := claudeReq.Messages[1].ParseContent()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assistantParts, 2)
|
||||
assert.Equal(t, "I will call.", assistantParts[0].GetText())
|
||||
assert.Equal(t, "tool_use", assistantParts[1].Type)
|
||||
assert.Equal(t, "call_1", assistantParts[1].Id)
|
||||
assert.Equal(t, "lookup", assistantParts[1].Name)
|
||||
assert.Equal(t, map[string]any{"q": "x"}, assistantParts[1].Input)
|
||||
|
||||
assert.Equal(t, "user", claudeReq.Messages[2].Role)
|
||||
toolResultParts, err := claudeReq.Messages[2].ParseContent()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, toolResultParts, 1)
|
||||
assert.Equal(t, "tool_result", toolResultParts[0].Type)
|
||||
assert.Equal(t, "call_1", toolResultParts[0].ToolUseId)
|
||||
assert.Equal(t, map[string]any{"ok": true}, toolResultParts[0].Content)
|
||||
}
|
||||
|
||||
func TestConvertRequestViaResponsesToGeminiStillUsesDirectSteps(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-test",
|
||||
},
|
||||
}
|
||||
req := &dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hello",
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
result, err := ConvertRequestVia(nil, info, req, types.RelayFormatOpenAI, types.RelayFormatGemini)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &dto.GeminiChatRequest{}, result.Value)
|
||||
assert.Equal(t, ConverterOpenAIResponsesToOpenAIChat+","+ConverterOpenAIChatToGeminiContent, result.Converter)
|
||||
assert.Equal(t, []RequestStep{
|
||||
{
|
||||
Converter: ConverterOpenAIResponsesToOpenAIChat,
|
||||
From: types.RelayFormatOpenAIResponses,
|
||||
To: types.RelayFormatOpenAI,
|
||||
},
|
||||
{
|
||||
Converter: ConverterOpenAIChatToGeminiContent,
|
||||
From: types.RelayFormatOpenAI,
|
||||
To: types.RelayFormatGemini,
|
||||
},
|
||||
}, result.Steps)
|
||||
}
|
||||
|
||||
func TestConvertRequestByIDDeduplicatesConversionChain(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses},
|
||||
}
|
||||
req := &dto.GeneralOpenAIRequest{
|
||||
Model: "gpt-test",
|
||||
Messages: []dto.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequestByID(nil, info, ConverterOpenAIChatToOpenAIResponses, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
|
||||
require.Len(t, result.Steps, 1)
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.RequestConversionChain)
|
||||
}
|
||||
|
||||
func TestConvertRequestByIDExecutesMultiHopConverter(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatClaude,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatClaude},
|
||||
}
|
||||
req := &dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequestByID(nil, info, requestConverterClaudeToResponses, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
|
||||
assert.Equal(t, requestConverterClaudeToResponses, result.Converter)
|
||||
assert.Equal(t, RequestConverterQualityFair, result.Quality)
|
||||
assert.Equal(t, []RequestStep{
|
||||
{
|
||||
Converter: ConverterClaudeMessagesToOpenAIChat,
|
||||
From: types.RelayFormatClaude,
|
||||
To: types.RelayFormatOpenAI,
|
||||
},
|
||||
{
|
||||
Converter: ConverterOpenAIChatToOpenAIResponses,
|
||||
From: types.RelayFormatOpenAI,
|
||||
To: types.RelayFormatOpenAIResponses,
|
||||
},
|
||||
}, result.Steps)
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.RequestConversionChain)
|
||||
}
|
||||
|
||||
func TestConvertRequestRejectsUnsupportedConverterAndNilRequest(t *testing.T) {
|
||||
_, err := ConvertRequestByID(nil, &relaycommon.RelayInfo{}, "missing_converter", &dto.GeneralOpenAIRequest{Model: "gpt-test"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not registered")
|
||||
|
||||
_, err = ConvertRequest(nil, &relaycommon.RelayInfo{}, types.RelayFormatOpenAIResponses, (*dto.GeneralOpenAIRequest)(nil))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "request is nil")
|
||||
}
|
||||
|
||||
func TestConvertRequestByIDRejectsWrongSourceFormat(t *testing.T) {
|
||||
_, err := ConvertRequestByID(
|
||||
nil,
|
||||
&relaycommon.RelayInfo{},
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
&dto.ClaudeRequest{Model: "claude-test"},
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "expects openai request")
|
||||
}
|
||||
|
||||
func TestConvertRequestRejectsUnregisteredExplicitPath(t *testing.T) {
|
||||
_, err := ConvertRequest(
|
||||
nil,
|
||||
&relaycommon.RelayInfo{},
|
||||
types.RelayFormatEmbedding,
|
||||
&dto.ClaudeRequest{Model: "claude-test"},
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "from claude to embedding is not registered")
|
||||
}
|
||||
|
||||
func mustRawMessage(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
raw, err := common.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
claudemessages "github.com/QuantumNous/new-api/service/relayconvert/internal/claude_messages"
|
||||
geminichat "github.com/QuantumNous/new-api/service/relayconvert/internal/gemini_chat"
|
||||
oaichat "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_chat"
|
||||
oairesponses "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_responses"
|
||||
)
|
||||
|
||||
type ClaudeResponseInfo = claudemessages.ClaudeResponseInfo
|
||||
|
||||
type ChatToResponsesStreamEvent = oaichat.ChatToResponsesStreamEvent
|
||||
type ChatToResponsesStreamState = oaichat.ChatToResponsesStreamState
|
||||
type ResponsesToChatStreamState = oairesponses.ResponsesToChatStreamState
|
||||
type ResponsesBufferedAccumulator = oairesponses.ResponsesBufferedAccumulator
|
||||
|
||||
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
||||
return oaichat.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
|
||||
}
|
||||
|
||||
func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.ClaudeResponse {
|
||||
return oaichat.ResponseOpenAI2Claude(openAIResponse, info)
|
||||
}
|
||||
|
||||
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
|
||||
return oaichat.StreamResponseOpenAI2Claude(openAIResponse, info)
|
||||
}
|
||||
|
||||
func StopReasonClaudeToOpenAI(reason string) string {
|
||||
return claudemessages.StopReasonClaudeToOpenAI(reason)
|
||||
}
|
||||
|
||||
func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse {
|
||||
return claudemessages.StreamResponseClaude2OpenAI(claudeResponse)
|
||||
}
|
||||
|
||||
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
|
||||
return claudemessages.ResponseClaude2OpenAI(claudeResponse)
|
||||
}
|
||||
|
||||
func UsageFromClaudeAPIUsage(usage *dto.ClaudeUsage) *dto.Usage {
|
||||
return claudemessages.UsageFromClaudeAPIUsage(usage)
|
||||
}
|
||||
|
||||
func UsageFromClaudeUsage(usage *dto.Usage) *dto.Usage {
|
||||
return claudemessages.UsageFromClaudeUsage(usage)
|
||||
}
|
||||
|
||||
func BuildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage {
|
||||
return claudemessages.BuildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
|
||||
}
|
||||
|
||||
func PatchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
|
||||
return claudemessages.PatchClaudeMessageDeltaUsageData(data, usage)
|
||||
}
|
||||
|
||||
func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
|
||||
return claudemessages.FormatClaudeResponseInfo(claudeResponse, oaiResponse, claudeInfo)
|
||||
}
|
||||
|
||||
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
|
||||
return oaichat.ResponseOpenAI2Gemini(openAIResponse, info)
|
||||
}
|
||||
|
||||
func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
|
||||
return oaichat.StreamResponseOpenAI2Gemini(openAIResponse, info)
|
||||
}
|
||||
|
||||
func UsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) *dto.Usage {
|
||||
return geminichat.UsageFromGeminiMetadata(metadata, fallbackPromptTokens)
|
||||
}
|
||||
|
||||
func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
|
||||
return geminichat.ResponseGeminiChat2OpenAI(id, created, response)
|
||||
}
|
||||
|
||||
func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
|
||||
return geminichat.StreamResponseGeminiChat2OpenAI(geminiResponse)
|
||||
}
|
||||
|
||||
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
|
||||
return oaichat.ChatCompletionsResponseToResponsesResponse(resp, id)
|
||||
}
|
||||
|
||||
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
|
||||
return oaichat.ResponsesStatusFromChatFinishReason(finishReason)
|
||||
}
|
||||
|
||||
func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
|
||||
return oaichat.UsageFromChatUsage(src)
|
||||
}
|
||||
|
||||
func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState {
|
||||
return oaichat.NewChatToResponsesStreamState(id, model)
|
||||
}
|
||||
|
||||
func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) {
|
||||
return oaichat.ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
|
||||
}
|
||||
|
||||
func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent {
|
||||
return oaichat.FinalizeChatCompletionsStreamToResponses(state)
|
||||
}
|
||||
|
||||
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
|
||||
return oairesponses.ResponsesFinishReasonFromStatus(resp)
|
||||
}
|
||||
|
||||
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
||||
return oairesponses.ResponsesResponseToChatCompletionsResponse(resp, id)
|
||||
}
|
||||
|
||||
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
|
||||
return oairesponses.UsageFromResponsesUsage(src)
|
||||
}
|
||||
|
||||
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
return oairesponses.ExtractOutputTextFromResponses(resp)
|
||||
}
|
||||
|
||||
func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
return oairesponses.ExtractReasoningTextFromResponses(resp)
|
||||
}
|
||||
|
||||
func NewResponsesToChatStreamState(model string, includeUsage bool) *ResponsesToChatStreamState {
|
||||
return oairesponses.NewResponsesToChatStreamState(model, includeUsage)
|
||||
}
|
||||
|
||||
func ResponsesStreamEventToChatChunks(event *dto.ResponsesStreamResponse, state *ResponsesToChatStreamState) ([]dto.ChatCompletionsStreamResponse, error) {
|
||||
return oairesponses.ResponsesStreamEventToChatChunks(event, state)
|
||||
}
|
||||
|
||||
func FinalizeResponsesToChatStream(state *ResponsesToChatStreamState) []dto.ChatCompletionsStreamResponse {
|
||||
return oairesponses.FinalizeResponsesToChatStream(state)
|
||||
}
|
||||
|
||||
func NewResponsesBufferedAccumulator() *ResponsesBufferedAccumulator {
|
||||
return oairesponses.NewResponsesBufferedAccumulator()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,668 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLookupBuiltinResponseConverters(t *testing.T) {
|
||||
tests := []struct {
|
||||
lookupID string
|
||||
id string
|
||||
from types.RelayFormat
|
||||
to types.RelayFormat
|
||||
quality ResponseConverterQuality
|
||||
stepConverters []string
|
||||
}{
|
||||
{lookupID: ResponseConverterOAIChatToOAIResponses, id: ConverterOpenAIChatToOpenAIResponses, from: types.RelayFormatOpenAI, to: types.RelayFormatOpenAIResponses, quality: ResponseConverterQualityGood},
|
||||
{lookupID: ResponseConverterOAIResponsesToOAIChat, id: ConverterOpenAIResponsesToOpenAIChat, from: types.RelayFormatOpenAIResponses, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityGood},
|
||||
{lookupID: ResponseConverterOAIChatToClaudeMessages, id: ConverterOpenAIChatToClaudeMessages, from: types.RelayFormatOpenAI, to: types.RelayFormatClaude, quality: ResponseConverterQualityFair},
|
||||
{lookupID: ResponseConverterOAIChatToGeminiChat, id: ConverterOpenAIChatToGeminiContent, from: types.RelayFormatOpenAI, to: types.RelayFormatGemini, quality: ResponseConverterQualityFair},
|
||||
{lookupID: ResponseConverterClaudeMessagesToOAIChat, id: ConverterClaudeMessagesToOpenAIChat, from: types.RelayFormatClaude, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityFair},
|
||||
{lookupID: ResponseConverterGeminiChatToOAIChat, id: ConverterGeminiContentToOpenAIChat, from: types.RelayFormatGemini, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityFair},
|
||||
{
|
||||
lookupID: responseConverterClaudeToGemini,
|
||||
id: requestConverterClaudeToGemini,
|
||||
from: types.RelayFormatClaude,
|
||||
to: types.RelayFormatGemini,
|
||||
quality: ResponseConverterQualityDiscouraged,
|
||||
stepConverters: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToGeminiContent,
|
||||
},
|
||||
},
|
||||
{
|
||||
lookupID: responseConverterClaudeToResponses,
|
||||
id: requestConverterClaudeToResponses,
|
||||
from: types.RelayFormatClaude,
|
||||
to: types.RelayFormatOpenAIResponses,
|
||||
quality: ResponseConverterQualityFair,
|
||||
stepConverters: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
},
|
||||
{
|
||||
lookupID: responseConverterGeminiToClaude,
|
||||
id: requestConverterGeminiToClaude,
|
||||
from: types.RelayFormatGemini,
|
||||
to: types.RelayFormatClaude,
|
||||
quality: ResponseConverterQualityDiscouraged,
|
||||
stepConverters: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToClaudeMessages,
|
||||
},
|
||||
},
|
||||
{
|
||||
lookupID: responseConverterGeminiToResponses,
|
||||
id: requestConverterGeminiToResponses,
|
||||
from: types.RelayFormatGemini,
|
||||
to: types.RelayFormatOpenAIResponses,
|
||||
quality: ResponseConverterQualityFair,
|
||||
stepConverters: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
},
|
||||
{
|
||||
lookupID: responseConverterResponsesToClaude,
|
||||
id: requestConverterResponsesToClaude,
|
||||
from: types.RelayFormatOpenAIResponses,
|
||||
to: types.RelayFormatClaude,
|
||||
quality: ResponseConverterQualityFair,
|
||||
stepConverters: []string{
|
||||
ConverterOpenAIResponsesToOpenAIChat,
|
||||
ConverterOpenAIChatToClaudeMessages,
|
||||
},
|
||||
},
|
||||
{
|
||||
lookupID: responseConverterResponsesToGemini,
|
||||
id: ConverterOpenAIResponsesToGemini,
|
||||
from: types.RelayFormatOpenAIResponses,
|
||||
to: types.RelayFormatGemini,
|
||||
quality: ResponseConverterQualityFair,
|
||||
stepConverters: []string{
|
||||
ConverterOpenAIResponsesToOpenAIChat,
|
||||
ConverterOpenAIChatToGeminiContent,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.lookupID, func(t *testing.T) {
|
||||
spec, ok := LookupResponseConverter(tt.lookupID)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tt.id, spec.ID)
|
||||
assert.Equal(t, tt.from, spec.From)
|
||||
assert.Equal(t, tt.to, spec.To)
|
||||
assert.Equal(t, tt.quality, spec.Quality)
|
||||
assert.Equal(t, tt.stepConverters, spec.StepConverters)
|
||||
if len(tt.stepConverters) == 0 {
|
||||
assert.NotNil(t, spec.Convert)
|
||||
} else {
|
||||
assert.Nil(t, spec.Convert)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_, ok := LookupResponseConverter("missing")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestConvertResponseRejectsNilAndUnsupportedRoute(t *testing.T) {
|
||||
_, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, (*dto.OpenAITextResponse)(nil))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = ConvertResponse(nil, nil, types.RelayFormatEmbedding, &dto.OpenAITextResponse{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConvertResponseDirectConverters(t *testing.T) {
|
||||
chat := textRegistryChatResponse()
|
||||
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}
|
||||
|
||||
toResponses, err := ConvertResponse(nil, info, types.RelayFormatOpenAIResponses, chat)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConverterOpenAIChatToOpenAIResponses, toResponses.Converter)
|
||||
assert.Equal(t, ResponseConverterQualityGood, toResponses.Quality)
|
||||
assert.Equal(t, types.RelayFormatOpenAI, toResponses.From)
|
||||
assert.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), toResponses.To)
|
||||
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIChatToOpenAIResponses, From: types.RelayFormatOpenAI, To: types.RelayFormatOpenAIResponses}}, toResponses.Steps)
|
||||
require.IsType(t, &dto.OpenAIResponsesResponse{}, toResponses.Value)
|
||||
assert.Equal(t, 9, toResponses.Usage.TotalTokens)
|
||||
require.NotNil(t, toResponses.Usage.BillingUsage)
|
||||
require.NotNil(t, toResponses.Usage.BillingUsage.OpenAIUsage)
|
||||
assert.Equal(t, dto.BillingUsageSourceOAIChat, toResponses.Usage.BillingUsage.Source)
|
||||
assert.Equal(t, 4, toResponses.Usage.BillingUsage.OpenAIUsage.PromptTokens)
|
||||
|
||||
responses := &dto.OpenAIResponsesResponse{
|
||||
ID: "resp_1",
|
||||
CreatedAt: 123,
|
||||
Model: "gpt-test",
|
||||
Status: []byte(`"completed"`),
|
||||
Output: []dto.ResponsesOutput{
|
||||
{
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{Type: "output_text", Text: "hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Usage: &dto.Usage{InputTokens: 4, OutputTokens: 6, TotalTokens: 10},
|
||||
}
|
||||
toChat, err := ConvertResponse(nil, info, types.RelayFormatOpenAI, responses)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConverterOpenAIResponsesToOpenAIChat, toChat.Converter)
|
||||
assert.Equal(t, ResponseConverterQualityGood, toChat.Quality)
|
||||
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
|
||||
assert.Equal(t, 10, toChat.Usage.TotalTokens)
|
||||
require.NotNil(t, toChat.Usage.BillingUsage)
|
||||
require.NotNil(t, toChat.Usage.BillingUsage.OpenAIUsage)
|
||||
assert.Equal(t, dto.BillingUsageSourceOAIResponses, toChat.Usage.BillingUsage.Source)
|
||||
assert.Equal(t, 4, toChat.Usage.BillingUsage.OpenAIUsage.InputTokens)
|
||||
|
||||
toClaude, err := ConvertResponse(nil, info, types.RelayFormatClaude, chat)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConverterOpenAIChatToClaudeMessages, toClaude.Converter)
|
||||
assert.Equal(t, ResponseConverterQualityFair, toClaude.Quality)
|
||||
require.IsType(t, &dto.ClaudeResponse{}, toClaude.Value)
|
||||
assert.Equal(t, 9, toClaude.Usage.TotalTokens)
|
||||
require.NotNil(t, toClaude.Usage.BillingUsage)
|
||||
require.NotNil(t, toClaude.Usage.BillingUsage.OpenAIUsage)
|
||||
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
|
||||
require.NotNil(t, claudeValue.Usage)
|
||||
require.NotNil(t, claudeValue.Usage.BillingUsage)
|
||||
require.NotNil(t, claudeValue.Usage.BillingUsage.OpenAIUsage)
|
||||
|
||||
toGemini, err := ConvertResponse(nil, info, types.RelayFormatGemini, chat)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConverterOpenAIChatToGeminiContent, toGemini.Converter)
|
||||
assert.Equal(t, ResponseConverterQualityFair, toGemini.Quality)
|
||||
require.IsType(t, &dto.GeminiChatResponse{}, toGemini.Value)
|
||||
assert.Equal(t, 9, toGemini.Usage.TotalTokens)
|
||||
require.NotNil(t, toGemini.Usage.BillingUsage)
|
||||
require.NotNil(t, toGemini.Usage.BillingUsage.OpenAIUsage)
|
||||
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
|
||||
require.NotNil(t, geminiValue.UsageMetadata.BillingUsage)
|
||||
require.NotNil(t, geminiValue.UsageMetadata.BillingUsage.OpenAIUsage)
|
||||
}
|
||||
|
||||
func TestConvertResponseMultiHopConverters(t *testing.T) {
|
||||
responses := textRegistryResponsesResponse()
|
||||
|
||||
toClaude, err := ConvertResponse(nil, &relaycommon.RelayInfo{}, types.RelayFormatClaude, responses)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, requestConverterResponsesToClaude, toClaude.Converter)
|
||||
assert.Equal(t, ResponseConverterQualityFair, toClaude.Quality)
|
||||
assert.Equal(t, []ResponseStep{
|
||||
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
|
||||
{Converter: ConverterOpenAIChatToClaudeMessages, From: types.RelayFormatOpenAI, To: types.RelayFormatClaude},
|
||||
}, toClaude.Steps)
|
||||
require.IsType(t, &dto.ClaudeResponse{}, toClaude.Value)
|
||||
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
|
||||
require.Len(t, claudeValue.Content, 2)
|
||||
assert.Equal(t, "text", claudeValue.Content[0].Type)
|
||||
assert.Equal(t, "tool_use", claudeValue.Content[1].Type)
|
||||
assert.Equal(t, "lookup", claudeValue.Content[1].Name)
|
||||
assert.Equal(t, map[string]interface{}{"q": "x"}, claudeValue.Content[1].Input)
|
||||
assert.Equal(t, 11, toClaude.Usage.TotalTokens)
|
||||
|
||||
toGemini, err := ConvertResponse(nil, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}, types.RelayFormatGemini, responses)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConverterOpenAIResponsesToGemini, toGemini.Converter)
|
||||
assert.Equal(t, ResponseConverterQualityFair, toGemini.Quality)
|
||||
assert.Equal(t, []ResponseStep{
|
||||
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
|
||||
{Converter: ConverterOpenAIChatToGeminiContent, From: types.RelayFormatOpenAI, To: types.RelayFormatGemini},
|
||||
}, toGemini.Steps)
|
||||
require.IsType(t, &dto.GeminiChatResponse{}, toGemini.Value)
|
||||
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
|
||||
require.Len(t, geminiValue.Candidates, 1)
|
||||
require.Len(t, geminiValue.Candidates[0].Content.Parts, 2)
|
||||
assert.Equal(t, "hello", geminiValue.Candidates[0].Content.Parts[0].Text)
|
||||
require.NotNil(t, geminiValue.Candidates[0].Content.Parts[1].FunctionCall)
|
||||
assert.Equal(t, "lookup", geminiValue.Candidates[0].Content.Parts[1].FunctionCall.FunctionName)
|
||||
assert.Equal(t, map[string]interface{}{"q": "x"}, geminiValue.Candidates[0].Content.Parts[1].FunctionCall.Arguments)
|
||||
assert.Equal(t, 11, toGemini.Usage.TotalTokens)
|
||||
}
|
||||
|
||||
func TestConvertResponseByIDExecutesMultiHopAndChecksSource(t *testing.T) {
|
||||
responses := textRegistryResponsesResponse()
|
||||
|
||||
result, err := ConvertResponseByID(nil, nil, responseConverterResponsesToGemini, responses)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
|
||||
assert.Equal(t, []ResponseStep{
|
||||
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
|
||||
{Converter: ConverterOpenAIChatToGeminiContent, From: types.RelayFormatOpenAI, To: types.RelayFormatGemini},
|
||||
}, result.Steps)
|
||||
|
||||
_, err = ConvertResponseByID(nil, nil, responseConverterResponsesToGemini, textRegistryChatResponse())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConvertResponseProviderToOAIChatUsage(t *testing.T) {
|
||||
claude := &dto.ClaudeResponse{
|
||||
Id: "msg_1",
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Model: "claude-test",
|
||||
StopReason: "end_turn",
|
||||
Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_use", Id: "toolu_1", Name: "lookup", Input: map[string]interface{}{"q": "x"}},
|
||||
},
|
||||
Usage: &dto.ClaudeUsage{
|
||||
InputTokens: 10,
|
||||
CacheReadInputTokens: 3,
|
||||
CacheCreationInputTokens: 4,
|
||||
OutputTokens: 5,
|
||||
CacheCreation: &dto.ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: 1,
|
||||
Ephemeral1hInputTokens: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
toChat, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, claude)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConverterClaudeMessagesToOpenAIChat, toChat.Converter)
|
||||
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
|
||||
assert.Equal(t, 17, toChat.Usage.PromptTokens)
|
||||
assert.Equal(t, 5, toChat.Usage.CompletionTokens)
|
||||
assert.Equal(t, 22, toChat.Usage.TotalTokens)
|
||||
assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.CachedTokens)
|
||||
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CachedCreationTokens)
|
||||
require.NotNil(t, toChat.Usage.BillingUsage)
|
||||
require.NotNil(t, toChat.Usage.BillingUsage.ClaudeUsage)
|
||||
assert.Equal(t, dto.BillingUsageSourceClaudeMessages, toChat.Usage.BillingUsage.Source)
|
||||
assert.Equal(t, dto.BillingUsageSemanticAnthropic, toChat.Usage.BillingUsage.Semantic)
|
||||
assert.Equal(t, 10, toChat.Usage.BillingUsage.ClaudeUsage.InputTokens)
|
||||
assert.Equal(t, 3, toChat.Usage.BillingUsage.ClaudeUsage.CacheReadInputTokens)
|
||||
assert.Equal(t, 4, toChat.Usage.BillingUsage.ClaudeUsage.CacheCreationInputTokens)
|
||||
assert.Equal(t, 5, toChat.Usage.BillingUsage.ClaudeUsage.OutputTokens)
|
||||
chatValue := toChat.Value.(*dto.OpenAITextResponse)
|
||||
require.Len(t, chatValue.Choices, 1)
|
||||
require.Len(t, chatValue.Choices[0].Message.ParseToolCalls(), 1)
|
||||
assert.JSONEq(t, `{"q":"x"}`, chatValue.Choices[0].Message.ParseToolCalls()[0].Function.Arguments)
|
||||
|
||||
gemini := &dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "hello"},
|
||||
{FunctionCall: &dto.FunctionCall{FunctionName: "lookup", Arguments: map[string]interface{}{"q": "x"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 7,
|
||||
ToolUsePromptTokenCount: 2,
|
||||
CandidatesTokenCount: 5,
|
||||
ThoughtsTokenCount: 3,
|
||||
TotalTokenCount: 17,
|
||||
CachedContentTokenCount: 4,
|
||||
PromptTokensDetails: []dto.GeminiPromptTokensDetails{
|
||||
{Modality: "TEXT", TokenCount: 5},
|
||||
{Modality: "IMAGE", TokenCount: 1},
|
||||
},
|
||||
ToolUsePromptTokensDetails: []dto.GeminiPromptTokensDetails{
|
||||
{Modality: "AUDIO", TokenCount: 3},
|
||||
},
|
||||
CandidatesTokensDetails: []dto.GeminiPromptTokensDetails{
|
||||
{Modality: "TEXT", TokenCount: 4},
|
||||
{Modality: "IMAGE", TokenCount: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
toChat, err = ConvertResponse(nil, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}, types.RelayFormatOpenAI, gemini)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConverterGeminiContentToOpenAIChat, toChat.Converter)
|
||||
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
|
||||
assert.Equal(t, 9, toChat.Usage.PromptTokens)
|
||||
assert.Equal(t, 8, toChat.Usage.CompletionTokens)
|
||||
assert.Equal(t, 17, toChat.Usage.TotalTokens)
|
||||
assert.Equal(t, 3, toChat.Usage.CompletionTokenDetails.ReasoningTokens)
|
||||
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CachedTokens)
|
||||
assert.Equal(t, 5, toChat.Usage.PromptTokensDetails.TextTokens)
|
||||
assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 1, toChat.Usage.PromptTokensDetails.ImageTokens)
|
||||
assert.Equal(t, 4, toChat.Usage.CompletionTokenDetails.TextTokens)
|
||||
assert.Equal(t, 1, toChat.Usage.CompletionTokenDetails.ImageTokens)
|
||||
require.NotNil(t, toChat.Usage.BillingUsage)
|
||||
require.NotNil(t, toChat.Usage.BillingUsage.GeminiUsageMetadata)
|
||||
assert.Equal(t, dto.BillingUsageSourceGeminiChat, toChat.Usage.BillingUsage.Source)
|
||||
assert.Equal(t, dto.BillingUsageSemanticGemini, toChat.Usage.BillingUsage.Semantic)
|
||||
assert.Equal(t, 7, toChat.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||
assert.Equal(t, 2, toChat.Usage.BillingUsage.GeminiUsageMetadata.ToolUsePromptTokenCount)
|
||||
assert.Equal(t, 17, toChat.Usage.BillingUsage.GeminiUsageMetadata.TotalTokenCount)
|
||||
}
|
||||
|
||||
func TestConvertResponsePreservesBillingUsageAcrossChatResponsesBridge(t *testing.T) {
|
||||
chat := textRegistryChatResponse()
|
||||
chat.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
|
||||
InputTokens: 10,
|
||||
CacheReadInputTokens: 3,
|
||||
CacheCreationInputTokens: 4,
|
||||
OutputTokens: 5,
|
||||
})
|
||||
|
||||
toResponses, err := ConvertResponse(nil, nil, types.RelayFormatOpenAIResponses, chat)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, toResponses.Usage.BillingUsage)
|
||||
require.NotNil(t, toResponses.Usage.BillingUsage.ClaudeUsage)
|
||||
assert.Equal(t, 10, toResponses.Usage.BillingUsage.ClaudeUsage.InputTokens)
|
||||
|
||||
responsesValue := toResponses.Value.(*dto.OpenAIResponsesResponse)
|
||||
toChat, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, responsesValue)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, toChat.Usage.BillingUsage)
|
||||
require.NotNil(t, toChat.Usage.BillingUsage.ClaudeUsage)
|
||||
assert.Equal(t, 4, toChat.Usage.BillingUsage.ClaudeUsage.CacheCreationInputTokens)
|
||||
}
|
||||
|
||||
func TestConvertResponseUsesBillingUsageWhenRestoringNativeTargets(t *testing.T) {
|
||||
chat := textRegistryChatResponse()
|
||||
chat.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
|
||||
InputTokens: 10,
|
||||
CacheReadInputTokens: 3,
|
||||
CacheCreationInputTokens: 4,
|
||||
OutputTokens: 5,
|
||||
})
|
||||
|
||||
toClaude, err := ConvertResponse(nil, nil, types.RelayFormatClaude, chat)
|
||||
require.NoError(t, err)
|
||||
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
|
||||
require.NotNil(t, claudeValue.Usage)
|
||||
assert.Equal(t, 10, claudeValue.Usage.InputTokens)
|
||||
assert.Equal(t, 3, claudeValue.Usage.CacheReadInputTokens)
|
||||
assert.Equal(t, 4, claudeValue.Usage.CacheCreationInputTokens)
|
||||
assert.Equal(t, 5, claudeValue.Usage.OutputTokens)
|
||||
|
||||
chat.Usage.BillingUsage = dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 7,
|
||||
ToolUsePromptTokenCount: 2,
|
||||
CandidatesTokenCount: 5,
|
||||
ThoughtsTokenCount: 3,
|
||||
TotalTokenCount: 17,
|
||||
})
|
||||
|
||||
toGemini, err := ConvertResponse(nil, nil, types.RelayFormatGemini, chat)
|
||||
require.NoError(t, err)
|
||||
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
|
||||
assert.Equal(t, 7, geminiValue.UsageMetadata.PromptTokenCount)
|
||||
assert.Equal(t, 2, geminiValue.UsageMetadata.ToolUsePromptTokenCount)
|
||||
assert.Equal(t, 5, geminiValue.UsageMetadata.CandidatesTokenCount)
|
||||
assert.Equal(t, 3, geminiValue.UsageMetadata.ThoughtsTokenCount)
|
||||
assert.Equal(t, 17, geminiValue.UsageMetadata.TotalTokenCount)
|
||||
}
|
||||
|
||||
func TestConvertStreamResponseDirectConverters(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||
LastMessagesType: relaycommon.LastMessageTypeNone,
|
||||
},
|
||||
}
|
||||
info.SendResponseCount = 1
|
||||
finishReason := "stop"
|
||||
result, err := ConvertStreamResponse(nil, info, types.RelayFormatClaude, &dto.ChatCompletionsStreamResponse{
|
||||
Id: "chatcmpl_1",
|
||||
Model: "gpt-test",
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{
|
||||
FinishReason: &finishReason,
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
Content: respPtr("hello"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Stream)
|
||||
assert.Equal(t, ConverterOpenAIChatToClaudeMessages, result.Converter)
|
||||
require.IsType(t, []*dto.ClaudeResponse{}, result.Value)
|
||||
assert.Equal(t, 5, result.Usage.TotalTokens)
|
||||
|
||||
result, err = ConvertStreamResponse(nil, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}, types.RelayFormatOpenAI, &dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{{Content: dto.GeminiChatContent{Parts: []dto.GeminiPart{{Text: "hello"}}}}},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 1,
|
||||
CandidatesTokenCount: 2,
|
||||
TotalTokenCount: 3,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Stream)
|
||||
assert.Equal(t, ConverterGeminiContentToOpenAIChat, result.Converter)
|
||||
require.IsType(t, &dto.ChatCompletionsStreamResponse{}, result.Value)
|
||||
assert.Equal(t, 3, result.Usage.TotalTokens)
|
||||
}
|
||||
|
||||
func TestConvertStreamResponseStatefulDirectConverters(t *testing.T) {
|
||||
chatState, err := NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, ResponseStreamOptions{
|
||||
ID: "resp_1",
|
||||
Model: "gpt-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
chatResults, err := ConvertStreamResponseChunk(nil, nil, chatState, &dto.ChatCompletionsStreamResponse{
|
||||
Id: "chatcmpl_1",
|
||||
Model: "gpt-test",
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: respPtr("hello")}},
|
||||
},
|
||||
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chatResults)
|
||||
assert.Equal(t, ConverterOpenAIChatToOpenAIResponses, chatResults[0].Converter)
|
||||
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIChatToOpenAIResponses, From: types.RelayFormatOpenAI, To: types.RelayFormatOpenAIResponses}}, chatResults[0].Steps)
|
||||
assert.Equal(t, 5, chatState.Usage().TotalTokens)
|
||||
|
||||
finalResults, err := FinalizeStreamResponse(nil, nil, chatState)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, finalResults)
|
||||
lastEvent, ok := finalResults[len(finalResults)-1].Value.(ChatToResponsesStreamEvent)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "response.completed", lastEvent.Type)
|
||||
|
||||
responsesState, err := NewResponseStreamState(types.RelayFormatOpenAIResponses, types.RelayFormatOpenAI, ResponseStreamOptions{
|
||||
ID: "chatcmpl_1",
|
||||
Model: "gpt-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
responsesResults, err := ConvertStreamResponseChunk(nil, nil, responsesState, &dto.ResponsesStreamResponse{
|
||||
Type: "response.output_text.delta",
|
||||
Delta: "hello",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, responsesResults)
|
||||
assert.Equal(t, ConverterOpenAIResponsesToOpenAIChat, responsesResults[0].Converter)
|
||||
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI}}, responsesResults[0].Steps)
|
||||
require.IsType(t, dto.ChatCompletionsStreamResponse{}, responsesResults[len(responsesResults)-1].Value)
|
||||
}
|
||||
|
||||
func TestConvertStreamResponseStatefulMultiHopResponsesToClaude(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||
LastMessagesType: relaycommon.LastMessageTypeNone,
|
||||
},
|
||||
}
|
||||
state, err := NewResponseStreamState(types.RelayFormatOpenAIResponses, types.RelayFormatClaude, ResponseStreamOptions{
|
||||
ID: "chatcmpl_1",
|
||||
Model: "gpt-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results, err := ConvertStreamResponseChunk(nil, info, state, &dto.ResponsesStreamResponse{
|
||||
Type: "response.output_text.delta",
|
||||
Delta: "hello",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, results)
|
||||
assert.Equal(t, requestConverterResponsesToClaude, results[0].Converter)
|
||||
assert.Equal(t, []ResponseStep{
|
||||
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
|
||||
{Converter: ConverterOpenAIChatToClaudeMessages, From: types.RelayFormatOpenAI, To: types.RelayFormatClaude},
|
||||
}, results[0].Steps)
|
||||
|
||||
var sawTextDelta bool
|
||||
for _, result := range results {
|
||||
claudeResponse, ok := result.Value.(*dto.ClaudeResponse)
|
||||
if !ok || claudeResponse == nil {
|
||||
continue
|
||||
}
|
||||
if claudeResponse.Type == "content_block_delta" && claudeResponse.Delta != nil && claudeResponse.Delta.Text != nil && *claudeResponse.Delta.Text == "hello" {
|
||||
sawTextDelta = true
|
||||
}
|
||||
}
|
||||
assert.True(t, sawTextDelta)
|
||||
|
||||
state.SetUsage(&dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5})
|
||||
_, err = FinalizeStreamResponse(nil, info, state)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5, state.Usage().TotalTokens)
|
||||
}
|
||||
|
||||
func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
|
||||
chat := textRegistryChatResponse()
|
||||
chat.Usage = dto.Usage{
|
||||
PromptTokens: 10,
|
||||
CompletionTokens: 5,
|
||||
TotalTokens: 20,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 3,
|
||||
CachedCreationTokens: 2,
|
||||
TextTokens: 4,
|
||||
AudioTokens: 1,
|
||||
ImageTokens: 5,
|
||||
},
|
||||
CompletionTokenDetails: dto.OutputTokenDetails{
|
||||
ReasoningTokens: 2,
|
||||
TextTokens: 2,
|
||||
AudioTokens: 1,
|
||||
ImageTokens: 2,
|
||||
},
|
||||
}
|
||||
result, err := ConvertResponse(nil, nil, types.RelayFormatOpenAIResponses, chat)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 10, result.Usage.InputTokens)
|
||||
assert.Equal(t, 5, result.Usage.OutputTokens)
|
||||
assert.Equal(t, 20, result.Usage.TotalTokens)
|
||||
require.NotNil(t, result.Usage.InputTokensDetails)
|
||||
assert.Equal(t, 3, result.Usage.InputTokensDetails.CachedTokens)
|
||||
assert.Equal(t, 2, result.Usage.InputTokensDetails.CachedCreationTokens)
|
||||
assert.Equal(t, 4, result.Usage.InputTokensDetails.TextTokens)
|
||||
assert.Equal(t, 1, result.Usage.InputTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 5, result.Usage.InputTokensDetails.ImageTokens)
|
||||
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.ReasoningTokens)
|
||||
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.TextTokens)
|
||||
assert.Equal(t, 1, result.Usage.CompletionTokenDetails.AudioTokens)
|
||||
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.ImageTokens)
|
||||
|
||||
responses := &dto.OpenAIResponsesResponse{
|
||||
ID: "resp_1",
|
||||
Status: []byte(`"completed"`),
|
||||
Model: "gpt-test",
|
||||
Output: []dto.ResponsesOutput{},
|
||||
CreatedAt: 123,
|
||||
Usage: &dto.Usage{
|
||||
InputTokens: 12,
|
||||
OutputTokens: 8,
|
||||
TotalTokens: 21,
|
||||
InputTokensDetails: &dto.InputTokenDetails{
|
||||
CachedTokens: 4,
|
||||
CachedCreationTokens: 1,
|
||||
TextTokens: 5,
|
||||
AudioTokens: 2,
|
||||
ImageTokens: 1,
|
||||
},
|
||||
CompletionTokenDetails: dto.OutputTokenDetails{
|
||||
ReasoningTokens: 3,
|
||||
TextTokens: 4,
|
||||
AudioTokens: 1,
|
||||
ImageTokens: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
result, err = ConvertResponse(nil, nil, types.RelayFormatOpenAI, responses)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 12, result.Usage.PromptTokens)
|
||||
assert.Equal(t, 8, result.Usage.CompletionTokens)
|
||||
assert.Equal(t, 21, result.Usage.TotalTokens)
|
||||
assert.Equal(t, 4, result.Usage.PromptTokensDetails.CachedTokens)
|
||||
assert.Equal(t, 1, result.Usage.PromptTokensDetails.CachedCreationTokens)
|
||||
assert.Equal(t, 5, result.Usage.PromptTokensDetails.TextTokens)
|
||||
assert.Equal(t, 2, result.Usage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 1, result.Usage.PromptTokensDetails.ImageTokens)
|
||||
assert.Equal(t, 3, result.Usage.CompletionTokenDetails.ReasoningTokens)
|
||||
assert.Equal(t, 4, result.Usage.CompletionTokenDetails.TextTokens)
|
||||
assert.Equal(t, 1, result.Usage.CompletionTokenDetails.AudioTokens)
|
||||
assert.Equal(t, 3, result.Usage.CompletionTokenDetails.ImageTokens)
|
||||
}
|
||||
|
||||
func textRegistryChatResponse() *dto.OpenAITextResponse {
|
||||
msg := dto.Message{
|
||||
Role: "assistant",
|
||||
Content: "hello",
|
||||
}
|
||||
msg.SetToolCalls([]dto.ToolCallRequest{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: "lookup",
|
||||
Arguments: `{"q":"x"}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
return &dto.OpenAITextResponse{
|
||||
Id: "chatcmpl_1",
|
||||
Model: "gpt-test",
|
||||
Created: 123,
|
||||
Choices: []dto.OpenAITextResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: "tool_calls",
|
||||
},
|
||||
},
|
||||
Usage: dto.Usage{PromptTokens: 4, CompletionTokens: 5, TotalTokens: 9},
|
||||
}
|
||||
}
|
||||
|
||||
func textRegistryResponsesResponse() *dto.OpenAIResponsesResponse {
|
||||
return &dto.OpenAIResponsesResponse{
|
||||
ID: "resp_1",
|
||||
CreatedAt: 123,
|
||||
Model: "gpt-test",
|
||||
Status: []byte(`"completed"`),
|
||||
Output: []dto.ResponsesOutput{
|
||||
{
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{Type: "output_text", Text: "hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "function_call",
|
||||
ID: "call_1",
|
||||
CallId: "call_1",
|
||||
Name: "lookup",
|
||||
Arguments: []byte(`{"q":"x"}`),
|
||||
},
|
||||
},
|
||||
Usage: &dto.Usage{InputTokens: 4, OutputTokens: 7, TotalTokens: 11},
|
||||
}
|
||||
}
|
||||
|
||||
func respPtr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
)
|
||||
|
||||
type TextConverterQuality string
|
||||
|
||||
const (
|
||||
TextConverterQualityGood TextConverterQuality = "good"
|
||||
TextConverterQualityFair TextConverterQuality = "fair"
|
||||
TextConverterQualityDiscouraged TextConverterQuality = "discouraged"
|
||||
)
|
||||
|
||||
type TextRequestSide struct {
|
||||
Convert RequestConverterFunc
|
||||
StepConverters []string
|
||||
}
|
||||
|
||||
type TextResponseSide struct {
|
||||
Convert ResponseConverterFunc
|
||||
ConvertStream ResponseStreamConverterFunc
|
||||
NewStreamState ResponseStreamStateFactory
|
||||
ConvertStreamChunk ResponseStreamChunkConverterFunc
|
||||
FinalizeStream ResponseStreamFinalizerFunc
|
||||
StepConverters []string
|
||||
Aliases []string
|
||||
}
|
||||
|
||||
type TextConverterSpec struct {
|
||||
ID string
|
||||
From types.RelayFormat
|
||||
To types.RelayFormat
|
||||
Quality TextConverterQuality
|
||||
Req TextRequestSide
|
||||
Resp TextResponseSide
|
||||
}
|
||||
|
||||
var (
|
||||
textConverterMu sync.RWMutex
|
||||
textConverters = make(map[string]TextConverterSpec)
|
||||
textConverterAliases = make(map[string]string)
|
||||
)
|
||||
|
||||
var builtinTextConverters = []TextConverterSpec{
|
||||
{
|
||||
ID: ConverterClaudeMessagesToOpenAIChat,
|
||||
From: types.RelayFormatClaude,
|
||||
To: types.RelayFormatOpenAI,
|
||||
Quality: TextConverterQualityFair,
|
||||
Req: TextRequestSide{
|
||||
Convert: convertClaudeRequestToOpenAI,
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
Convert: convertClaudeMessagesResponseToOAIChat,
|
||||
ConvertStream: convertClaudeMessagesStreamResponseToOAIChat,
|
||||
Aliases: []string{ResponseConverterClaudeMessagesToOAIChat},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: ConverterOpenAIChatToClaudeMessages,
|
||||
From: types.RelayFormatOpenAI,
|
||||
To: types.RelayFormatClaude,
|
||||
Quality: TextConverterQualityFair,
|
||||
Req: TextRequestSide{
|
||||
Convert: convertOpenAIRequestToClaude,
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
Convert: convertOAIChatResponseToClaudeMessages,
|
||||
ConvertStream: convertOAIChatStreamResponseToClaudeMessages,
|
||||
Aliases: []string{ResponseConverterOAIChatToClaudeMessages},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: ConverterGeminiContentToOpenAIChat,
|
||||
From: types.RelayFormatGemini,
|
||||
To: types.RelayFormatOpenAI,
|
||||
Quality: TextConverterQualityFair,
|
||||
Req: TextRequestSide{
|
||||
Convert: convertGeminiRequestToOpenAI,
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
Convert: convertGeminiChatResponseToOAIChat,
|
||||
ConvertStream: convertGeminiChatStreamResponseToOAIChat,
|
||||
Aliases: []string{ResponseConverterGeminiChatToOAIChat},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: ConverterOpenAIChatToGeminiContent,
|
||||
From: types.RelayFormatOpenAI,
|
||||
To: types.RelayFormatGemini,
|
||||
Quality: TextConverterQualityFair,
|
||||
Req: TextRequestSide{
|
||||
Convert: convertOpenAIRequestToGemini,
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
Convert: convertOAIChatResponseToGeminiChat,
|
||||
ConvertStream: convertOAIChatStreamResponseToGeminiChat,
|
||||
Aliases: []string{ResponseConverterOAIChatToGeminiChat},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: ConverterOpenAIChatToOpenAIResponses,
|
||||
From: types.RelayFormatOpenAI,
|
||||
To: types.RelayFormatOpenAIResponses,
|
||||
Quality: TextConverterQualityGood,
|
||||
Req: TextRequestSide{
|
||||
Convert: convertChatRequestToResponses,
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
Convert: convertOAIChatResponseToOAIResponses,
|
||||
NewStreamState: newOAIChatToOAIResponsesStreamState,
|
||||
ConvertStreamChunk: convertOAIChatStreamResponseToOAIResponses,
|
||||
FinalizeStream: finalizeOAIChatStreamResponseToOAIResponses,
|
||||
Aliases: []string{ResponseConverterOAIChatToOAIResponses},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: ConverterOpenAIResponsesToOpenAIChat,
|
||||
From: types.RelayFormatOpenAIResponses,
|
||||
To: types.RelayFormatOpenAI,
|
||||
Quality: TextConverterQualityGood,
|
||||
Req: TextRequestSide{
|
||||
Convert: convertResponsesRequestToChat,
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
Convert: convertOAIResponsesResponseToOAIChat,
|
||||
NewStreamState: newOAIResponsesToOAIChatStreamState,
|
||||
ConvertStreamChunk: convertOAIResponsesStreamResponseToOAIChat,
|
||||
FinalizeStream: finalizeOAIResponsesStreamResponseToOAIChat,
|
||||
Aliases: []string{ResponseConverterOAIResponsesToOAIChat},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: requestConverterClaudeToGemini,
|
||||
From: types.RelayFormatClaude,
|
||||
To: types.RelayFormatGemini,
|
||||
Quality: TextConverterQualityDiscouraged,
|
||||
Req: TextRequestSide{
|
||||
StepConverters: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToGeminiContent,
|
||||
},
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
StepConverters: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToGeminiContent,
|
||||
},
|
||||
Aliases: []string{responseConverterClaudeToGemini},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: requestConverterClaudeToResponses,
|
||||
From: types.RelayFormatClaude,
|
||||
To: types.RelayFormatOpenAIResponses,
|
||||
Quality: TextConverterQualityFair,
|
||||
Req: TextRequestSide{
|
||||
StepConverters: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
StepConverters: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
Aliases: []string{responseConverterClaudeToResponses},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: requestConverterGeminiToClaude,
|
||||
From: types.RelayFormatGemini,
|
||||
To: types.RelayFormatClaude,
|
||||
Quality: TextConverterQualityDiscouraged,
|
||||
Req: TextRequestSide{
|
||||
StepConverters: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToClaudeMessages,
|
||||
},
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
StepConverters: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToClaudeMessages,
|
||||
},
|
||||
Aliases: []string{responseConverterGeminiToClaude},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: requestConverterGeminiToResponses,
|
||||
From: types.RelayFormatGemini,
|
||||
To: types.RelayFormatOpenAIResponses,
|
||||
Quality: TextConverterQualityFair,
|
||||
Req: TextRequestSide{
|
||||
StepConverters: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
StepConverters: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
Aliases: []string{responseConverterGeminiToResponses},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: requestConverterResponsesToClaude,
|
||||
From: types.RelayFormatOpenAIResponses,
|
||||
To: types.RelayFormatClaude,
|
||||
Quality: TextConverterQualityFair,
|
||||
Req: TextRequestSide{
|
||||
Convert: convertOpenAIResponsesRequestToClaudeMessages,
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
StepConverters: []string{
|
||||
ConverterOpenAIResponsesToOpenAIChat,
|
||||
ConverterOpenAIChatToClaudeMessages,
|
||||
},
|
||||
Aliases: []string{responseConverterResponsesToClaude},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: ConverterOpenAIResponsesToGemini,
|
||||
From: types.RelayFormatOpenAIResponses,
|
||||
To: types.RelayFormatGemini,
|
||||
Quality: TextConverterQualityFair,
|
||||
Req: TextRequestSide{
|
||||
Convert: convertOpenAIResponsesRequestToGeminiChat,
|
||||
},
|
||||
Resp: TextResponseSide{
|
||||
StepConverters: []string{
|
||||
ConverterOpenAIResponsesToOpenAIChat,
|
||||
ConverterOpenAIChatToGeminiContent,
|
||||
},
|
||||
Aliases: []string{responseConverterResponsesToGemini},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
for _, spec := range builtinTextConverters {
|
||||
registerBuiltinTextConverter(spec)
|
||||
}
|
||||
}
|
||||
|
||||
func LookupTextConverter(converter string) (TextConverterSpec, bool) {
|
||||
textConverterMu.RLock()
|
||||
defer textConverterMu.RUnlock()
|
||||
|
||||
converterID := resolveTextConverterID(converter)
|
||||
spec, ok := textConverters[converterID]
|
||||
if !ok {
|
||||
return TextConverterSpec{}, false
|
||||
}
|
||||
return cloneTextConverterSpec(spec), true
|
||||
}
|
||||
|
||||
func registerBuiltinTextConverter(spec TextConverterSpec) {
|
||||
spec.ID = strings.TrimSpace(spec.ID)
|
||||
if spec.ID == "" {
|
||||
panic("text converter ID is required")
|
||||
}
|
||||
if spec.From == "" || spec.To == "" {
|
||||
panic(fmt.Sprintf("text converter %q must declare from and to formats", spec.ID))
|
||||
}
|
||||
if spec.Quality == "" {
|
||||
panic(fmt.Sprintf("text converter %q must declare quality", spec.ID))
|
||||
}
|
||||
if !textRequestSideConfigured(spec.Req) {
|
||||
panic(fmt.Sprintf("text converter %q must declare request conversion", spec.ID))
|
||||
}
|
||||
if !textResponseSideConfigured(spec.Resp) {
|
||||
panic(fmt.Sprintf("text converter %q must declare response conversion", spec.ID))
|
||||
}
|
||||
if _, exists := textConverters[spec.ID]; exists {
|
||||
panic(fmt.Sprintf("text converter %q is already registered", spec.ID))
|
||||
}
|
||||
|
||||
registerBuiltinRequestConverter(RequestConverterSpec{
|
||||
ID: spec.ID,
|
||||
From: spec.From,
|
||||
To: spec.To,
|
||||
Quality: RequestConverterQuality(spec.Quality),
|
||||
Convert: spec.Req.Convert,
|
||||
StepConverters: cloneTextConverterStrings(spec.Req.StepConverters),
|
||||
})
|
||||
registerBuiltinResponseConverter(ResponseConverterSpec{
|
||||
ID: spec.ID,
|
||||
From: spec.From,
|
||||
To: spec.To,
|
||||
Quality: ResponseConverterQuality(spec.Quality),
|
||||
Convert: spec.Resp.Convert,
|
||||
ConvertStream: spec.Resp.ConvertStream,
|
||||
NewStreamState: spec.Resp.NewStreamState,
|
||||
ConvertStreamChunk: spec.Resp.ConvertStreamChunk,
|
||||
FinalizeStream: spec.Resp.FinalizeStream,
|
||||
StepConverters: cloneTextConverterStrings(spec.Resp.StepConverters),
|
||||
})
|
||||
|
||||
textConverters[spec.ID] = cloneTextConverterSpec(spec)
|
||||
for _, alias := range spec.Resp.Aliases {
|
||||
registerResponseConverterAlias(alias, spec.ID)
|
||||
registerTextConverterAlias(alias, spec.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func registerTextConverterAlias(alias string, converter string) {
|
||||
alias = strings.TrimSpace(alias)
|
||||
converter = strings.TrimSpace(converter)
|
||||
if alias == "" {
|
||||
panic("text converter alias is required")
|
||||
}
|
||||
if converter == "" {
|
||||
panic(fmt.Sprintf("text converter alias %q target is required", alias))
|
||||
}
|
||||
if alias == converter {
|
||||
return
|
||||
}
|
||||
if _, exists := textConverters[alias]; exists {
|
||||
panic(fmt.Sprintf("text converter alias %q conflicts with registered converter", alias))
|
||||
}
|
||||
if _, exists := textConverters[converter]; !exists {
|
||||
panic(fmt.Sprintf("text converter alias %q references unknown converter %q", alias, converter))
|
||||
}
|
||||
if existing, exists := textConverterAliases[alias]; exists && existing != converter {
|
||||
panic(fmt.Sprintf("text converter alias %q is already registered for %q", alias, existing))
|
||||
}
|
||||
textConverterAliases[alias] = converter
|
||||
}
|
||||
|
||||
func textRequestSideConfigured(side TextRequestSide) bool {
|
||||
return side.Convert != nil || len(side.StepConverters) > 0
|
||||
}
|
||||
|
||||
func textResponseSideConfigured(side TextResponseSide) bool {
|
||||
return side.Convert != nil ||
|
||||
side.ConvertStream != nil ||
|
||||
side.NewStreamState != nil ||
|
||||
side.ConvertStreamChunk != nil ||
|
||||
side.FinalizeStream != nil ||
|
||||
len(side.StepConverters) > 0
|
||||
}
|
||||
|
||||
func resolveTextConverterID(converter string) string {
|
||||
converter = strings.TrimSpace(converter)
|
||||
if canonical, ok := textConverterAliases[converter]; ok {
|
||||
return canonical
|
||||
}
|
||||
return converter
|
||||
}
|
||||
|
||||
func cloneTextConverterSpec(spec TextConverterSpec) TextConverterSpec {
|
||||
spec.Req.StepConverters = cloneTextConverterStrings(spec.Req.StepConverters)
|
||||
spec.Resp.StepConverters = cloneTextConverterStrings(spec.Resp.StepConverters)
|
||||
spec.Resp.Aliases = cloneTextConverterStrings(spec.Resp.Aliases)
|
||||
return spec
|
||||
}
|
||||
|
||||
func cloneTextConverterStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string{}, values...)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLookupBuiltinTextConverters(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
from types.RelayFormat
|
||||
to types.RelayFormat
|
||||
quality TextConverterQuality
|
||||
reqSteps []string
|
||||
respSteps []string
|
||||
reqDirect bool
|
||||
respDirect bool
|
||||
respAlias string
|
||||
streamDirect bool
|
||||
}{
|
||||
{id: ConverterClaudeMessagesToOpenAIChat, from: types.RelayFormatClaude, to: types.RelayFormatOpenAI, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterClaudeMessagesToOAIChat},
|
||||
{id: ConverterOpenAIChatToClaudeMessages, from: types.RelayFormatOpenAI, to: types.RelayFormatClaude, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIChatToClaudeMessages},
|
||||
{id: ConverterGeminiContentToOpenAIChat, from: types.RelayFormatGemini, to: types.RelayFormatOpenAI, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterGeminiChatToOAIChat},
|
||||
{id: ConverterOpenAIChatToGeminiContent, from: types.RelayFormatOpenAI, to: types.RelayFormatGemini, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIChatToGeminiChat},
|
||||
{id: ConverterOpenAIChatToOpenAIResponses, from: types.RelayFormatOpenAI, to: types.RelayFormatOpenAIResponses, quality: TextConverterQualityGood, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIChatToOAIResponses, streamDirect: true},
|
||||
{id: ConverterOpenAIResponsesToOpenAIChat, from: types.RelayFormatOpenAIResponses, to: types.RelayFormatOpenAI, quality: TextConverterQualityGood, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIResponsesToOAIChat, streamDirect: true},
|
||||
{
|
||||
id: requestConverterClaudeToGemini,
|
||||
from: types.RelayFormatClaude,
|
||||
to: types.RelayFormatGemini,
|
||||
quality: TextConverterQualityDiscouraged,
|
||||
reqSteps: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToGeminiContent,
|
||||
},
|
||||
respSteps: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToGeminiContent,
|
||||
},
|
||||
respAlias: responseConverterClaudeToGemini,
|
||||
},
|
||||
{
|
||||
id: requestConverterClaudeToResponses,
|
||||
from: types.RelayFormatClaude,
|
||||
to: types.RelayFormatOpenAIResponses,
|
||||
quality: TextConverterQualityFair,
|
||||
reqSteps: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
respSteps: []string{
|
||||
ConverterClaudeMessagesToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
respAlias: responseConverterClaudeToResponses,
|
||||
},
|
||||
{
|
||||
id: requestConverterGeminiToClaude,
|
||||
from: types.RelayFormatGemini,
|
||||
to: types.RelayFormatClaude,
|
||||
quality: TextConverterQualityDiscouraged,
|
||||
reqSteps: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToClaudeMessages,
|
||||
},
|
||||
respSteps: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToClaudeMessages,
|
||||
},
|
||||
respAlias: responseConverterGeminiToClaude,
|
||||
},
|
||||
{
|
||||
id: requestConverterGeminiToResponses,
|
||||
from: types.RelayFormatGemini,
|
||||
to: types.RelayFormatOpenAIResponses,
|
||||
quality: TextConverterQualityFair,
|
||||
reqSteps: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
respSteps: []string{
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
respAlias: responseConverterGeminiToResponses,
|
||||
},
|
||||
{
|
||||
id: requestConverterResponsesToClaude,
|
||||
from: types.RelayFormatOpenAIResponses,
|
||||
to: types.RelayFormatClaude,
|
||||
quality: TextConverterQualityFair,
|
||||
reqDirect: true,
|
||||
respSteps: []string{
|
||||
ConverterOpenAIResponsesToOpenAIChat,
|
||||
ConverterOpenAIChatToClaudeMessages,
|
||||
},
|
||||
respAlias: responseConverterResponsesToClaude,
|
||||
},
|
||||
{
|
||||
id: ConverterOpenAIResponsesToGemini,
|
||||
from: types.RelayFormatOpenAIResponses,
|
||||
to: types.RelayFormatGemini,
|
||||
quality: TextConverterQualityFair,
|
||||
reqDirect: true,
|
||||
respSteps: []string{
|
||||
ConverterOpenAIResponsesToOpenAIChat,
|
||||
ConverterOpenAIChatToGeminiContent,
|
||||
},
|
||||
respAlias: responseConverterResponsesToGemini,
|
||||
},
|
||||
}
|
||||
|
||||
require.Len(t, textConverters, len(tests))
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.id, func(t *testing.T) {
|
||||
spec, ok := LookupTextConverter(tt.id)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tt.id, spec.ID)
|
||||
assert.Equal(t, tt.from, spec.From)
|
||||
assert.Equal(t, tt.to, spec.To)
|
||||
assert.Equal(t, tt.quality, spec.Quality)
|
||||
assert.Equal(t, tt.reqSteps, spec.Req.StepConverters)
|
||||
assert.Equal(t, tt.respSteps, spec.Resp.StepConverters)
|
||||
assert.Equal(t, tt.reqDirect, spec.Req.Convert != nil)
|
||||
assert.Equal(t, tt.respDirect, spec.Resp.Convert != nil)
|
||||
assert.Equal(t, tt.streamDirect, spec.Resp.NewStreamState != nil && spec.Resp.ConvertStreamChunk != nil && spec.Resp.FinalizeStream != nil)
|
||||
|
||||
aliasSpec, ok := LookupTextConverter(tt.respAlias)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tt.id, aliasSpec.ID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
relayconvert.SetMediaResolver(relayconvert.MediaResolver{
|
||||
GetBase64Data: GetBase64Data,
|
||||
DecodeBase64FileData: DecodeBase64FileData,
|
||||
})
|
||||
}
|
||||
|
||||
func ConvertRequest(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, request any) (*relayconvert.RequestResult, error) {
|
||||
return relayconvert.ConvertRequest(c, info, target, request)
|
||||
}
|
||||
|
||||
func ConvertRequestByID(c *gin.Context, info *relaycommon.RelayInfo, converter string, request any) (*relayconvert.RequestResult, error) {
|
||||
return relayconvert.ConvertRequestByID(c, info, converter, request)
|
||||
}
|
||||
|
||||
func ConvertRequestVia(c *gin.Context, info *relaycommon.RelayInfo, request any, path ...types.RelayFormat) (*relayconvert.RequestResult, error) {
|
||||
return relayconvert.ConvertRequestVia(c, info, request, path...)
|
||||
}
|
||||
|
||||
func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAI, &claudeRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
openAIRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
|
||||
}
|
||||
return openAIRequest, nil
|
||||
}
|
||||
|
||||
func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAI, geminiRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
openAIRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
|
||||
}
|
||||
return openAIRequest, nil
|
||||
}
|
||||
+10
-5
@@ -175,6 +175,9 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS
|
||||
return total
|
||||
}
|
||||
|
||||
// calculateTextQuotaSummary expects a usage already remapped by
|
||||
// effectiveBillingUsage; PostTextConsumeQuota performs that remap once and shares
|
||||
// the result with tiered billing, affinity observation and logging.
|
||||
func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary {
|
||||
summary := textQuotaSummary{
|
||||
ModelName: relayInfo.OriginModelName,
|
||||
@@ -335,15 +338,16 @@ func usageSemanticFromUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage)
|
||||
|
||||
func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent []string) {
|
||||
originUsage := usage
|
||||
billingUsage := effectiveBillingUsage(usage)
|
||||
if usage == nil {
|
||||
extraContent = append(extraContent, "上游无计费信息")
|
||||
}
|
||||
if originUsage != nil {
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, billingUsage, relayInfo.GetFinalRequestRelayFormat())
|
||||
}
|
||||
|
||||
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, billingUsage)
|
||||
|
||||
var tieredResult *billingexpr.TieredResult
|
||||
tieredBillingApplied := false
|
||||
@@ -352,7 +356,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
|
||||
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
|
||||
tieredUsedVars = billingexpr.UsedVars(snap.ExprString)
|
||||
}
|
||||
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(usage, summary.IsClaudeUsageSemantic, tieredUsedVars))
|
||||
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(billingUsage, summary.IsClaudeUsageSemantic, tieredUsedVars))
|
||||
if tieredOk {
|
||||
tieredBillingApplied = true
|
||||
tieredResult = tieredRes
|
||||
@@ -412,6 +416,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
|
||||
} else {
|
||||
other = GenerateTextOtherInfo(ctx, relayInfo, summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio, summary.CacheTokens, summary.CacheRatio, summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
||||
}
|
||||
appendUsageBillingPathForLog(other, common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens), originUsage)
|
||||
if adminRejectReason != "" {
|
||||
other["reject_reason"] = adminRejectReason
|
||||
}
|
||||
@@ -462,12 +467,12 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
|
||||
// to cache_creation_tokens.
|
||||
other["cache_write_tokens"] = cacheWriteTokens
|
||||
}
|
||||
if relayInfo.GetFinalRequestRelayFormat() != types.RelayFormatClaude && usage != nil && usage.UsageSource != "" && usage.InputTokens > 0 {
|
||||
if relayInfo.GetFinalRequestRelayFormat() != types.RelayFormatClaude && billingUsage != nil && billingUsage.UsageSource != "" && billingUsage.InputTokens > 0 {
|
||||
// input_tokens_total: explicit normalized total input used by the usage log UI.
|
||||
// Only write this field when upstream/current conversion has already provided a
|
||||
// reliable total input value and tagged the usage source. Do not infer it from
|
||||
// prompt/cache fields here, otherwise old upstream payloads may be double-counted.
|
||||
other["input_tokens_total"] = usage.InputTokens
|
||||
other["input_tokens_total"] = billingUsage.InputTokens
|
||||
}
|
||||
if tieredBillingApplied {
|
||||
InjectTieredBillingInfo(other, relayInfo, tieredResult)
|
||||
|
||||
@@ -150,6 +150,172 @@ func TestCalculateTextQuotaSummaryUsesAnthropicUsageSemanticFromUpstreamUsage(t
|
||||
require.Equal(t, 1488, summary.Quota)
|
||||
}
|
||||
|
||||
func TestCalculateTextQuotaSummaryUsesClaudeBillingUsageBeforeTopLevelUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
OriginModelName: "claude-3-7-sonnet",
|
||||
PriceData: types.PriceData{
|
||||
ModelRatio: 1,
|
||||
CompletionRatio: 2,
|
||||
CacheRatio: 0.1,
|
||||
CacheCreationRatio: 1.25,
|
||||
CacheCreation5mRatio: 1.25,
|
||||
CacheCreation1hRatio: 2,
|
||||
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
|
||||
},
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 999,
|
||||
CompletionTokens: 999,
|
||||
TotalTokens: 1998,
|
||||
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
|
||||
InputTokens: 70,
|
||||
CacheReadInputTokens: 30,
|
||||
CacheCreationInputTokens: 20,
|
||||
OutputTokens: 7,
|
||||
CacheCreation: &dto.ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: 12,
|
||||
Ephemeral1hInputTokens: 8,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveBillingUsage(usage))
|
||||
|
||||
require.True(t, summary.IsClaudeUsageSemantic)
|
||||
require.Equal(t, dto.BillingUsageSemanticAnthropic, summary.UsageSemantic)
|
||||
require.Equal(t, 70, summary.PromptTokens)
|
||||
require.Equal(t, 7, summary.CompletionTokens)
|
||||
require.Equal(t, 30, summary.CacheTokens)
|
||||
require.Equal(t, 20, summary.CacheCreationTokens)
|
||||
require.Equal(t, 12, summary.CacheCreationTokens5m)
|
||||
require.Equal(t, 8, summary.CacheCreationTokens1h)
|
||||
require.Equal(t, 118, summary.Quota)
|
||||
}
|
||||
|
||||
func TestCalculateTextQuotaSummaryUsesGeminiBillingUsageBeforeTopLevelUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
OriginModelName: "gemini-2.5-flash",
|
||||
PriceData: types.PriceData{
|
||||
ModelRatio: 1,
|
||||
CompletionRatio: 2,
|
||||
CacheRatio: 0.1,
|
||||
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
|
||||
},
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 999,
|
||||
CompletionTokens: 999,
|
||||
TotalTokens: 1998,
|
||||
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 100,
|
||||
ToolUsePromptTokenCount: 5,
|
||||
CandidatesTokenCount: 20,
|
||||
ThoughtsTokenCount: 3,
|
||||
TotalTokenCount: 128,
|
||||
CachedContentTokenCount: 7,
|
||||
}),
|
||||
}
|
||||
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveBillingUsage(usage))
|
||||
|
||||
require.False(t, summary.IsClaudeUsageSemantic)
|
||||
require.Equal(t, dto.BillingUsageSemanticGemini, summary.UsageSemantic)
|
||||
require.Equal(t, 105, summary.PromptTokens)
|
||||
require.Equal(t, 23, summary.CompletionTokens)
|
||||
require.Equal(t, 7, summary.CacheTokens)
|
||||
require.Equal(t, 128, summary.TotalTokens)
|
||||
require.Equal(t, 145, summary.Quota)
|
||||
}
|
||||
|
||||
func TestCalculateTextQuotaSummaryUsesOpenAIBillingUsageBeforeTopLevelUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatClaude,
|
||||
OriginModelName: "gpt-4o",
|
||||
PriceData: types.PriceData{
|
||||
ModelRatio: 1,
|
||||
CompletionRatio: 2,
|
||||
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
|
||||
},
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 999,
|
||||
CompletionTokens: 999,
|
||||
TotalTokens: 1998,
|
||||
BillingUsage: dto.NewOpenAIChatBillingUsage(&dto.Usage{
|
||||
PromptTokens: 80,
|
||||
CompletionTokens: 9,
|
||||
TotalTokens: 89,
|
||||
}),
|
||||
}
|
||||
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveBillingUsage(usage))
|
||||
|
||||
require.False(t, summary.IsClaudeUsageSemantic)
|
||||
require.Equal(t, dto.BillingUsageSemanticOpenAI, summary.UsageSemantic)
|
||||
require.Equal(t, 80, summary.PromptTokens)
|
||||
require.Equal(t, 9, summary.CompletionTokens)
|
||||
require.Equal(t, 89, summary.TotalTokens)
|
||||
require.Equal(t, 98, summary.Quota)
|
||||
}
|
||||
|
||||
func TestUsageBillingPathForLog(t *testing.T) {
|
||||
require.Equal(t, usageBillingPathLocal, usageBillingPathForLog(true, &dto.Usage{
|
||||
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
|
||||
}))
|
||||
require.Equal(t, usageBillingPathUpstream, usageBillingPathForLog(false, &dto.Usage{}))
|
||||
require.Equal(t, usageBillingPathOpenAI, usageBillingPathForLog(false, &dto.Usage{
|
||||
BillingUsage: dto.NewOpenAIChatBillingUsage(&dto.Usage{PromptTokens: 1}),
|
||||
}))
|
||||
require.Equal(t, usageBillingPathAnthropic, usageBillingPathForLog(false, &dto.Usage{
|
||||
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
|
||||
}))
|
||||
require.Equal(t, usageBillingPathGemini, usageBillingPathForLog(false, &dto.Usage{
|
||||
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{PromptTokenCount: 1}),
|
||||
}))
|
||||
require.Equal(t, usageBillingPathGeminiEstimated, usageBillingPathForLog(false, &dto.Usage{
|
||||
BillingUsage: dto.NewEstimatedGeminiChatBillingUsage(&dto.Usage{PromptTokens: 1}),
|
||||
}))
|
||||
}
|
||||
|
||||
func TestAppendUsageBillingPathForLogWritesAdminInfo(t *testing.T) {
|
||||
other := map[string]interface{}{
|
||||
"admin_info": map[string]interface{}{},
|
||||
}
|
||||
appendUsageBillingPathForLog(other, false, &dto.Usage{
|
||||
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
|
||||
})
|
||||
|
||||
adminInfo, ok := other["admin_info"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, usageBillingPathAnthropic, adminInfo["usage_billing_path"])
|
||||
|
||||
other = map[string]interface{}{}
|
||||
appendUsageBillingPathForLog(other, true, nil)
|
||||
adminInfo, ok = other["admin_info"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, usageBillingPathLocal, adminInfo["usage_billing_path"])
|
||||
}
|
||||
|
||||
func TestCacheWriteTokensTotal(t *testing.T) {
|
||||
t.Run("split cache creation", func(t *testing.T) {
|
||||
summary := textQuotaSummary{
|
||||
|
||||
Reference in New Issue
Block a user