feat: sync deployed v1.0.0-rc.38-custom.42

Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

@@ -1,6 +1,7 @@
package claudemessages
import (
"context"
"fmt"
"strings"
@@ -23,7 +24,7 @@ type openRouterRequestReasoning struct {
Exclude bool `json:"exclude,omitempty"`
}
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
openAIRequest := dto.GeneralOpenAIRequest{
Model: claudeRequest.Model,
Temperature: claudeRequest.Temperature,
@@ -40,7 +41,7 @@ func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info con
if claudeRequest.Stream != nil {
openAIRequest.Stream = kitutil.GetPointer(*claudeRequest.Stream)
}
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(ctx, &claudeRequest, info)
if err != nil {
return nil, reasoning.AsClientError(err)
}
@@ -1,6 +1,7 @@
package claudemessages
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -8,11 +9,12 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func ClaudeMessagesRequestToOpenAIResponses(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.OpenAIResponsesRequest, error) {
func ClaudeMessagesRequestToOpenAIResponses(ctx context.Context, claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.OpenAIResponsesRequest, error) {
if strings.TrimSpace(claudeRequest.Model) == "" {
return nil, errors.New("model is required")
}
@@ -67,7 +69,7 @@ func ClaudeMessagesRequestToOpenAIResponses(claudeRequest dto.ClaudeRequest, inf
responsesRequest.MaxOutputTokens = &maxOutputTokens
}
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(ctx, &claudeRequest, info)
if err != nil {
return nil, reasoning.AsClientError(err)
}
@@ -81,20 +83,22 @@ func ClaudeMessagesRequestToOpenAIResponses(claudeRequest dto.ClaudeRequest, inf
return responsesRequest, nil
}
func claudeRequestReasoningIntent(claudeRequest *dto.ClaudeRequest, info convmeta.Meta) (reasoning.Intent, reasoning.Effort, error) {
reasoningIntent, err := reasoning.FromClaude(claudeRequest)
func claudeRequestReasoningIntent(ctx context.Context, claudeRequest *dto.ClaudeRequest, info convmeta.Meta) (reasoning.Intent, reasoning.Effort, error) {
reasoningIntent, diagnostics, err := reasoning.FromClaude(claudeRequest)
if err != nil {
return reasoning.Intent{}, "", err
}
convdiag.Add(ctx, diagnostics...)
sourceModel := claudeRequest.Model
if info != nil && info.GetOriginModelName() != "" {
sourceModel = info.GetOriginModelName()
}
if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModel)
reasoningIntent, diagnostics, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModel)
if err != nil {
return reasoning.Intent{}, "", err
}
convdiag.Add(ctx, diagnostics...)
}
reasoningIntent = reasoning.ResolveClaudeDefault(sourceModel, reasoningIntent)
return reasoningIntent, reasoning.EffectiveEffort(reasoningIntent), nil
@@ -1,17 +1,19 @@
package geminichat
import (
"context"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/jsonutil"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
func GeminiGenerateContentRequestToOpenAIChat(ctx context.Context, geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
modelName := ""
isStream := false
if info != nil {
@@ -22,30 +24,42 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
Model: modelName,
Stream: kitutil.GetPointer(isStream),
}
reasoningIntent, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return nil, reasoning.AsClientError(err)
}
sourceModelName := modelName
if info != nil && info.GetOriginModelName() != "" {
sourceModelName = info.GetOriginModelName()
}
baseSourceModel := sourceModelName
opts := convmeta.OptionsOf(info)
preserveSuffix := opts.ShouldPreserveThinkingSuffix(sourceModelName)
// Capability lookups need the unsuffixed Gemini model the client addressed.
// The host already folded any alias into ReasoningState; mirror its parser
// so gemini-pro-latest-thinking still resolves to gemini-pro-latest.
baseSourceModel := sourceModelName
if !preserveSuffix {
if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModelName)
if err != nil {
return nil, reasoning.AsClientError(err)
}
baseSourceModel = reasoning.ParseModelModifiers(sourceModelName).Base
if base, _, found, err := reasoning.ParseGeminiModelSuffix(baseSourceModel, opts.Gemini.ThinkingAdapterEnabled); err == nil && found {
baseSourceModel = base
}
}
if baseSourceModel != "" && geminiRequest.GenerationConfig.ThinkingConfig != nil {
_, err = reasoning.ValidateGeminiThinkingConfig(baseSourceModel, geminiRequest.GenerationConfig.ThinkingConfig)
_, diagnostics, err := reasoning.NormalizeGeminiThinkingConfig(baseSourceModel, &geminiRequest.GenerationConfig)
if err != nil {
return nil, reasoning.AsClientError(err)
}
convdiag.Add(ctx, diagnostics...)
}
reasoningIntent, diagnostics, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return nil, reasoning.AsClientError(err)
}
convdiag.Add(ctx, diagnostics...)
if !preserveSuffix {
if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
reasoningIntent, diagnostics, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModelName)
if err != nil {
return nil, reasoning.AsClientError(err)
}
convdiag.Add(ctx, diagnostics...)
}
}
reasoningIntent = reasoning.ResolveGeminiDefault(baseSourceModel, reasoningIntent)
effectiveEffort := reasoning.EffectiveEffort(reasoningIntent)
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
@@ -94,10 +95,11 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
}
}
sourceReasoning, err := reasoning.FromOpenAIChat(&textRequest)
sourceReasoning, diagnostics, err := reasoning.FromOpenAIChat(&textRequest)
if err != nil {
return nil, reasoning.AsClientError(err)
}
convdiag.Add(c, diagnostics...)
if err := sharedclaude.ApplyReasoning(c, &claudeRequest, info, sourceReasoning, true); err != nil {
return nil, reasoning.AsClientError(err)
}
@@ -137,7 +137,7 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
}
}
if err := sharedgemini.ApplyThinkingConfig(&geminiRequest, info, textRequest); err != nil {
if err := sharedgemini.ApplyThinkingConfig(c, &geminiRequest, info, textRequest); err != nil {
return nil, reasoning.AsClientError(err)
}
@@ -1,12 +1,14 @@
package oaichat
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/samber/lo"
@@ -74,7 +76,7 @@ func convertChatResponseFormatToResponsesText(reqFormat *dto.ResponseFormat) jso
return textRaw
}
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
func ChatCompletionsRequestToResponsesRequest(ctx context.Context, req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
if req == nil {
return nil, errors.New("request is nil")
}
@@ -412,10 +414,11 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
out.MaxOutputTokens = lo.ToPtr(maxOutputTokens)
}
reasoningIntent, err := reasoning.FromOpenAIChat(req)
reasoningIntent, diagnostics, err := reasoning.FromOpenAIChat(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
convdiag.Add(ctx, diagnostics...)
if err := reasoning.ApplyToOpenAIResponses(out, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
@@ -1,6 +1,7 @@
package oaichat
import (
"context"
"encoding/json"
"testing"
@@ -28,7 +29,7 @@ func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing
},
}
got, err := ChatCompletionsRequestToResponsesRequest(req)
got, err := ChatCompletionsRequestToResponsesRequest(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, "gpt-test", got.Model)
@@ -42,7 +43,7 @@ func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing
func TestChatCompletionsRequestToResponsesRequestPreservesPromptCacheKey(t *testing.T) {
t.Run("present", func(t *testing.T) {
key := "session-\"quoted\"\\path\n世界"
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
got, err := ChatCompletionsRequestToResponsesRequest(context.Background(), &dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
PromptCacheKey: key,
@@ -59,7 +60,7 @@ func TestChatCompletionsRequestToResponsesRequestPreservesPromptCacheKey(t *test
})
t.Run("absent", func(t *testing.T) {
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
got, err := ChatCompletionsRequestToResponsesRequest(context.Background(), &dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
})
@@ -92,7 +93,7 @@ func TestChatCompletionsRequestToResponsesRequestPreservesQwenThinkingBudget(t *
},
}
got, err := ChatCompletionsRequestToResponsesRequest(req)
got, err := ChatCompletionsRequestToResponsesRequest(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, tt.budget, got.ThinkingBudget)
@@ -108,7 +109,7 @@ func TestChatCompletionsRequestToResponsesRequestPreservesQwenThinkingBudget(t *
}
func TestChatCompletionsRequestToResponsesRequestRejectsMultipleChoices(t *testing.T) {
_, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
_, err := ChatCompletionsRequestToResponsesRequest(context.Background(), &dto.GeneralOpenAIRequest{
Model: "gpt-test",
N: lo.ToPtr(2),
})
@@ -147,7 +148,7 @@ func TestChatCompletionsRequestToResponsesRequestPreservesPenalties(t *testing.T
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
got, err := ChatCompletionsRequestToResponsesRequest(context.Background(), &dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
FrequencyPenalty: tt.frequency,
@@ -42,7 +42,7 @@ func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id
out := &dto.OpenAIResponsesResponse{
ID: id,
Object: "response",
CreatedAt: chatCreatedAt(resp.Created),
CreatedAt: dto.IntValue(chatCreatedAt(resp.Created)),
Status: []byte(`"completed"`),
Model: resp.Model,
Output: make([]dto.ResponsesOutput, 0),
@@ -665,7 +665,7 @@ func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesRespons
return &dto.OpenAIResponsesResponse{
ID: s.ID,
Object: "response",
CreatedAt: int(s.Created),
CreatedAt: dto.IntValue(s.Created),
Status: []byte(fmt.Sprintf("%q", s.status)),
IncompleteDetails: s.incompleteDetails,
Model: s.Model,
@@ -678,7 +678,7 @@ func (s *ChatToResponsesStreamState) createdResponse() *dto.OpenAIResponsesRespo
return &dto.OpenAIResponsesResponse{
ID: s.ID,
Object: "response",
CreatedAt: int(s.Created),
CreatedAt: dto.IntValue(s.Created),
Status: []byte(`"in_progress"`),
Model: s.Model,
Output: []dto.ResponsesOutput{},
@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
@@ -56,10 +57,11 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
if toolChoice != nil || RawJSONPresent(req.ParallelToolCalls) {
claudeRequest.ToolChoice = sharedclaude.MapOpenAIToolChoice(toolChoice, ParallelToolCalls(req.ParallelToolCalls))
}
sourceReasoning, err := reasoning.FromOpenAIResponses(req)
sourceReasoning, diagnostics, err := reasoning.FromOpenAIResponses(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
convdiag.Add(c, diagnostics...)
if err := sharedclaude.ApplyReasoning(c, claudeRequest, info, sourceReasoning, true); err != nil {
return nil, reasoning.AsClientError(err)
}
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
@@ -61,17 +62,18 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
if err := applyResponsesTextToGemini(req.Text, geminiRequest); err != nil {
return nil, err
}
reasoningIntent, err := reasoning.FromOpenAIResponses(req)
reasoningIntent, diagnostics, err := reasoning.FromOpenAIResponses(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
convdiag.Add(c, diagnostics...)
var reasoningPivot dto.GeneralOpenAIRequest
if err := reasoning.ApplyToOpenAIChat(&reasoningPivot, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
reasoningPivot.Model = req.Model
reasoningPivot.MaxCompletionTokens = req.MaxOutputTokens
if err := sharedgemini.ApplyThinkingConfig(geminiRequest, info, reasoningPivot); err != nil {
if err := sharedgemini.ApplyThinkingConfig(c, geminiRequest, info, reasoningPivot); err != nil {
return nil, reasoning.AsClientError(err)
}
@@ -1,12 +1,14 @@
package oairesponses
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
@@ -25,7 +27,7 @@ const (
ResponsesInputTypeCustomToolOutput = responsesInputTypeCustomToolOutput
)
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
func ResponsesRequestToChatCompletionsRequest(ctx context.Context, req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
if req == nil {
return nil, errors.New("request is nil")
}
@@ -86,9 +88,12 @@ func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (
return nil, fmt.Errorf("invalid presence_penalty: %w", err)
}
if reasoningIntent, err := reasoning.FromOpenAIResponses(req); err != nil {
reasoningIntent, diagnostics, err := reasoning.FromOpenAIResponses(req)
if err != nil {
return nil, reasoning.AsClientError(err)
} else if err := reasoning.ApplyToOpenAIChat(out, reasoningIntent); err != nil {
}
convdiag.Add(ctx, diagnostics...)
if err := reasoning.ApplyToOpenAIChat(out, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if req.ServiceTier != "" {
@@ -1,6 +1,7 @@
package oairesponses
import (
"context"
"encoding/json"
"testing"
@@ -19,7 +20,7 @@ func TestResponsesRequestToChatCompletionsRequestInstructionsAndScalarInput(t *t
maxOutputTokens := uint(128)
parallelToolCalls := true
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
Model: "gpt-test",
Instructions: mustRawMessage(t, "system rules"),
Input: mustRawMessage(t, "hello"),
@@ -68,7 +69,7 @@ func TestResponsesRequestToChatCompletionsRequestPreservesQwenThinkingBudget(t *
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
Model: "qwen-plus",
Input: mustRawMessage(t, "hello"),
EnableThinking: json.RawMessage(`true`),
@@ -89,7 +90,7 @@ func TestResponsesRequestToChatCompletionsRequestPreservesQwenThinkingBudget(t *
}
func TestResponsesRequestToChatCompletionsRequestMultimodalInput(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
@@ -123,7 +124,7 @@ func TestResponsesRequestToChatCompletionsRequestMultimodalInput(t *testing.T) {
}
func TestResponsesRequestToChatCompletionsRequestAssistantTextAndFunctionCallCoexist(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
@@ -162,7 +163,7 @@ func TestResponsesRequestToChatCompletionsRequestAssistantTextAndFunctionCallCoe
}
func TestResponsesRequestToChatCompletionsRequestOnlyFunctionCallCreatesAssistant(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
@@ -184,7 +185,7 @@ func TestResponsesRequestToChatCompletionsRequestOnlyFunctionCallCreatesAssistan
}
func TestResponsesRequestToChatCompletionsRequestToolsToolChoiceAndTextFormat(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, "hello"),
Tools: mustRawMessage(t, []map[string]any{
@@ -233,7 +234,7 @@ func TestResponsesRequestToChatCompletionsRequestToolsToolChoiceAndTextFormat(t
}
func TestResponsesRequestToChatCompletionsRequestCustomToolCallPreservesRawShape(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
@@ -287,7 +288,7 @@ func TestResponsesRequestToChatCompletionsRequestRejectsStatefulFields(t *testin
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ResponsesRequestToChatCompletionsRequest(tt.req)
_, err := ResponsesRequestToChatCompletionsRequest(context.Background(), tt.req)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
assert.Contains(t, err.Error(), "stateful fields")
@@ -326,7 +327,7 @@ func TestResponsesRequestToChatCompletionsRequestPreservesPenalties(t *testing.T
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, "hello"),
FrequencyPenalty: tt.frequencyRaw,
@@ -341,7 +342,7 @@ func TestResponsesRequestToChatCompletionsRequestPreservesPenalties(t *testing.T
}
func TestResponsesRequestToChatCompletionsRequestRejectsMalformedPenalty(t *testing.T) {
_, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
_, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, "hello"),
FrequencyPenalty: json.RawMessage(`"not-a-number"`),
@@ -66,7 +66,7 @@ func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesRespons
usage := UsageFromResponsesUsage(resp.Usage)
created := resp.CreatedAt
created := int64(resp.CreatedAt)
var toolCalls []dto.ToolCallResponse
if len(resp.Output) > 0 {
@@ -218,12 +218,7 @@ func usageFromResponsesUsage(src *dto.Usage, createBillingSnapshot bool) *dto.Us
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
if src.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens
usage.PromptTokensDetails.CacheWriteTokens = src.InputTokensDetails.CacheWriteTokens
usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
usage.PromptTokensDetails = src.InputTokensDetails.Clone()
}
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
src.CompletionTokenDetails.TextTokens != 0 ||
@@ -9,6 +9,49 @@ import (
"github.com/stretchr/testify/require"
)
func TestNormalizeResponsesUsagePreservesImageCacheDetails(t *testing.T) {
for _, tc := range []struct {
name string
imageTokens int
}{
{name: "image cache", imageTokens: 200},
{name: "explicit zero image cache", imageTokens: 0},
} {
t.Run(tc.name, func(t *testing.T) {
imageTokens, audioTokens := tc.imageTokens, 0
source := &dto.Usage{
InputTokens: 1000,
OutputTokens: 100,
InputTokensDetails: &dto.InputTokenDetails{
CachedTokens: 300,
CachedCreationTokens: 20,
CacheWriteTokens: 10,
TextTokens: 400,
ImageTokens: 600,
CachedTokensDetails: &dto.CachedTokenDetails{
ImageTokens: &imageTokens,
AudioTokens: &audioTokens,
},
},
}
usage := NormalizeResponsesUsage(source)
assert.Equal(t, *source.InputTokensDetails, usage.PromptTokensDetails)
details := usage.PromptTokensDetails.CachedTokensDetails
require.NotNil(t, details)
require.NotNil(t, details.ImageTokens)
require.NotNil(t, details.AudioTokens)
assert.Nil(t, details.TextTokens, "missing modalities must remain absent")
imageTokens, audioTokens = 999, 888
source.InputTokensDetails.ImageTokens = 9999
assert.Equal(t, tc.imageTokens, *details.ImageTokens, "later source updates must not change billable image cache")
assert.Zero(t, *details.AudioTokens, "explicit zero must survive normalization and source mutation")
assert.Equal(t, 600, usage.PromptTokensDetails.ImageTokens)
})
}
}
func TestResponsesResponseToChatCompletionsPreservesTextAndToolCalls(t *testing.T) {
resp := &dto.OpenAIResponsesResponse{
ID: "resp_1",
@@ -52,21 +52,24 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
return nil
}
native, err := reasoning.FromClaude(req)
native, diagnostics, err := reasoning.FromClaude(req)
if err != nil {
return err
}
explicit, err := reasoning.MergeExplicit(native, source, req.Model)
convdiag.Add(ctx, diagnostics...)
explicit, diagnostics, err := reasoning.MergeExplicit(native, source, req.Model)
if err != nil {
return err
}
convdiag.Add(ctx, diagnostics...)
if info != nil && !reasoning.IsKnownClaudeModel(capabilityModel) && reasoning.IsKnownClaudeModel(info.GetOriginModelName()) {
capabilityModel = info.GetOriginModelName()
}
intent, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, req.Model)
intent, diagnostics, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, req.Model)
if err != nil {
return err
}
convdiag.Add(ctx, diagnostics...)
knownClaudeModel := reasoning.IsKnownClaudeModel(capabilityModel)
if !knownClaudeModel && intent.Mode == reasoning.ModeAdaptive {
// Cross-protocol pivots cannot safely assume that an unknown
@@ -80,8 +83,8 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
}
if req.MaxTokens == nil && intent.HasStrength() {
// Adapter-provided defaults may be raised to accommodate an exact
// cross-protocol budget. Explicit client max_tokens values are never
// expanded and remain subject to the renderer's strict validation.
// cross-protocol budget. Values too small for manual thinking are
// raised again by the renderer, which reports the change.
minimum := uint(1280)
if configuredDefault, configured := opts.Claude.DefaultMaxTokensFor(capabilityModel); configured && configuredDefault > 0 {
minimum = uint(configuredDefault)
@@ -108,6 +111,9 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
}
convdiag.Add(ctx, rendered.Diagnostics...)
req.Model = baseModel
if rendered.MaxTokens != nil {
req.MaxTokens = rendered.MaxTokens
}
if rendered.Thinking != nil {
req.Thinking = rendered.Thinking
}
@@ -1,13 +1,16 @@
package gemini
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
)
var SupportedMimeTypes = map[string]bool{
@@ -73,7 +76,12 @@ func AttachFirstTextThoughtSignature(opts *convmeta.Options, parts []dto.GeminiP
return false
}
func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) error {
// ApplyThinkingConfig resolves every reasoning control that can reach a
// Gemini request (provider-native thinkingConfig, generic OpenAI fields, and
// host model-name aliases) into the target model's dialect. Precedence is
// model name, then provider-native config, then generic fields; each override
// and each capability coercion is reported through convdiag on ctx.
func ApplyThinkingConfig(ctx context.Context, geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) error {
opts := convmeta.OptionsOf(info)
if geminiRequest == nil {
return nil
@@ -86,11 +94,13 @@ func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Met
if modelName == "" {
modelName = oaiRequest[0].Model
}
var diagnostics []types.ConversionDiagnostic
var err error
source, err = reasoning.FromOpenAIChat(&oaiRequest[0])
source, diagnostics, err = reasoning.FromOpenAIChat(&oaiRequest[0])
if err != nil {
return err
}
convdiag.Add(ctx, diagnostics...)
}
baseModel := modelName
@@ -110,6 +120,12 @@ func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Met
effort := ""
if config := geminiRequest.GenerationConfig.ThinkingConfig; config != nil {
effort = config.ThinkingLevel
// Gemini accepts thinkingLevel case-insensitively; record the
// canonical effort so logs match other protocols. Unknown values
// stay as sent because this path does not validate.
if canonical, err := reasoning.ParseEffort(effort); err == nil {
effort = string(canonical)
}
if effort == "" && config.ThinkingBudget != nil {
effort = string(reasoning.EffortFromBudget(*config.ThinkingBudget))
}
@@ -118,33 +134,47 @@ func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Met
}
return nil
}
native, err := reasoning.FromGemini(geminiRequest)
// Rewrite the provider-native config into the target model's dialect
// first, so a budget on Gemini 3 or a level on Gemini 2.5 is converted
// rather than rejected and later comparisons see what will be sent.
nativeEffort, diagnostics, err := reasoning.NormalizeGeminiThinkingConfig(baseModel, &geminiRequest.GenerationConfig)
if err != nil {
return err
}
source = reasoning.ResolveGeminiEnabledDefault(baseModel, source, geminiRequest.GenerationConfig.MaxOutputTokens)
convdiag.Add(ctx, diagnostics...)
native, diagnostics, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return err
}
convdiag.Add(ctx, diagnostics...)
if native.HasStrength() && source.HasStrength() {
equivalent, compareErr := reasoning.EquivalentGeminiStrength(baseModel, native, source)
if compareErr != nil {
return compareErr
// Native Gemini configuration is the lossless representation and wins
// over the generic OpenAI fields. Only a generic effort or budget can
// disagree with it; a bare enable states no strength to compare.
if source.Effort != "" || source.BudgetTokens != nil {
equivalent, compareErr := reasoning.EquivalentGeminiStrength(baseModel, native, source)
if compareErr != nil {
return compareErr
}
if !equivalent {
convdiag.Add(ctx, types.ConversionDiagnostic{
Code: "native_overrode_standard",
Path: "generationConfig.thinkingConfig",
Message: fmt.Sprintf("model %q: Gemini thinking_config effort %q overrides the standard reasoning effort %q", modelName, reasoning.EffectiveEffort(native), reasoning.EffectiveEffort(source)),
Severity: types.ConversionDiagnosticWarning,
})
}
}
if !equivalent {
nativeEffort := reasoning.EffectiveEffort(native)
sourceEffort := reasoning.EffectiveEffort(source)
return fmt.Errorf("%w for model %q: Gemini thinking_config effort %q differs from standard effort %q", reasoning.ErrEffortConflict, modelName, nativeEffort, sourceEffort)
}
// Native Gemini configuration is the lossless representation. Once the
// two controls are equivalent, retain only portable visibility metadata
// from the standard representation.
if native.IncludeThoughts == nil {
native.IncludeThoughts = source.IncludeThoughts
}
source = reasoning.Intent{}
}
explicit, err := reasoning.MergeExplicit(native, source, modelName)
explicit, diagnostics, err := reasoning.MergeExplicit(native, source, modelName)
if err != nil {
return err
}
convdiag.Add(ctx, diagnostics...)
if explicit.HasStrength() && suffix.HasStrength() {
equivalent, compareErr := reasoning.EquivalentGeminiStrength(baseModel, explicit, suffix)
if compareErr != nil {
@@ -157,22 +187,21 @@ func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Met
suffix = reasoning.Intent{}
}
}
requested, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, modelName)
requested, diagnostics, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, modelName)
if err != nil {
return err
}
convdiag.Add(ctx, diagnostics...)
requested = reasoning.ResolveGeminiEnabledDefault(baseModel, requested, geminiRequest.GenerationConfig.MaxOutputTokens)
if native.HasStrength() && !suffix.HasStrength() {
// The normalized native config is what goes upstream; only portable
// visibility metadata is taken from the standard representation.
if explicit.IncludeThoughts != nil {
geminiRequest.GenerationConfig.ThinkingConfig.IncludeThoughts = explicit.IncludeThoughts
}
effort, err := reasoning.ValidateGeminiThinkingConfig(baseModel, geminiRequest.GenerationConfig.ThinkingConfig)
if err != nil {
return err
}
if info != nil && effort != "" {
info.SetReasoningEffort(string(effort))
if info != nil && nativeEffort != "" {
info.SetReasoningEffort(string(nativeEffort))
}
return nil
}
@@ -188,6 +217,7 @@ func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Met
if err != nil {
return err
}
convdiag.Add(ctx, rendered.Diagnostics...)
geminiRequest.GenerationConfig.ThinkingConfig = rendered.Config
if info != nil && rendered.EffectiveEffort != "" {
info.SetReasoningEffort(string(rendered.EffectiveEffort))