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:
1 parent
aa6c3f50f4
commit
ceddfd3d6b
828 files changed
+122253
-13702
No files matched your search
@@ -9,6 +9,7 @@ import (
|
||||
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -208,3 +209,46 @@ func claudeDefaultsMeta(defaultMaxTokens func(string) int) convmeta.Meta {
|
||||
Claude: convmeta.ClaudeOptions{DefaultMaxTokens: defaultMaxTokens},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestClaudeManualThinkingBoundsAreCoerced(t *testing.T) {
|
||||
// OpenAI requests to a manual-thinking Claude model used to be rejected with
|
||||
// 400 when budget_tokens or max_tokens fell outside Anthropic's bounds. The
|
||||
// renderer now adjusts them and reports what changed.
|
||||
tests := []struct {
|
||||
name string
|
||||
reasoning string
|
||||
maxTokens uint
|
||||
wantBudget int
|
||||
wantMaxTokens uint
|
||||
wantCode string
|
||||
}{
|
||||
{name: "budget below 1024 is raised", reasoning: `{"max_tokens":512}`, maxTokens: 4096, wantBudget: 1024, wantMaxTokens: 4096, wantCode: "claude_budget_adjusted"},
|
||||
{name: "budget at max_tokens is lowered", reasoning: `{"max_tokens":4096}`, maxTokens: 4096, wantBudget: 4095, wantMaxTokens: 4096, wantCode: "claude_budget_adjusted"},
|
||||
{name: "max_tokens too small for manual thinking is raised", reasoning: `{"max_tokens":2048}`, maxTokens: 1000, wantBudget: 2048, wantMaxTokens: 2049, wantCode: "claude_max_tokens_raised"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
maxTokens := tt.maxTokens
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-3-7-sonnet",
|
||||
MaxTokens: &maxTokens,
|
||||
Reasoning: []byte(tt.reasoning),
|
||||
Messages: []dto.Message{{Role: "user", Content: "hello"}},
|
||||
}
|
||||
result, err := ConvertRequest(context.Background(), &convmeta.Values{}, types.RelayFormatClaude, &request)
|
||||
require.NoError(t, err)
|
||||
converted, ok := result.Value.(*dto.ClaudeRequest)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, converted.Thinking)
|
||||
require.NotNil(t, converted.Thinking.BudgetTokens)
|
||||
assert.Equal(t, tt.wantBudget, *converted.Thinking.BudgetTokens)
|
||||
require.NotNil(t, converted.MaxTokens)
|
||||
assert.Equal(t, tt.wantMaxTokens, *converted.MaxTokens)
|
||||
codes := make([]string, 0, len(result.Diagnostics))
|
||||
for _, diagnostic := range result.Diagnostics {
|
||||
codes = append(codes, diagnostic.Code)
|
||||
}
|
||||
assert.Contains(t, codes, tt.wantCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -15,7 +15,10 @@ type ClaudeRender struct {
|
||||
EffectiveEffort Effort
|
||||
ClearSampling bool
|
||||
ConstrainThinkingSampling bool
|
||||
Diagnostics []types.ConversionDiagnostic
|
||||
// MaxTokens is set when manual thinking needed a larger max_tokens than
|
||||
// the request carried; callers apply it to the outgoing request.
|
||||
MaxTokens *uint
|
||||
Diagnostics []types.ConversionDiagnostic
|
||||
}
|
||||
|
||||
type claudeCapabilities struct {
|
||||
@@ -82,15 +85,18 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
return ClaudeRender{}, err
|
||||
}
|
||||
intent.Effort = effort
|
||||
} else {
|
||||
}
|
||||
diagnostics := make([]types.ConversionDiagnostic, 0, 1)
|
||||
if !disabledWithEffort {
|
||||
var normalizeDiagnostics []types.ConversionDiagnostic
|
||||
var err error
|
||||
intent, err = normalizeIntent(intent)
|
||||
intent, normalizeDiagnostics, err = normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return ClaudeRender{}, err
|
||||
}
|
||||
diagnostics = append(diagnostics, normalizeDiagnostics...)
|
||||
}
|
||||
capabilities := claudeCapabilitiesFor(model)
|
||||
diagnostics := make([]types.ConversionDiagnostic, 0, 1)
|
||||
if disabledWithEffort {
|
||||
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
|
||||
"claude_disabled_effort_ignored",
|
||||
@@ -225,13 +231,26 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
}
|
||||
}
|
||||
if intent.Mode == ModeUnset {
|
||||
return ClaudeRender{OutputEffort: intent.Effort, EffectiveEffort: intent.Effort}, nil
|
||||
return ClaudeRender{OutputEffort: intent.Effort, EffectiveEffort: intent.Effort, Diagnostics: diagnostics}, nil
|
||||
}
|
||||
if maxTokens == nil {
|
||||
return ClaudeRender{}, fmt.Errorf("max_tokens is required for manual Claude thinking")
|
||||
}
|
||||
if *maxTokens <= 1024 {
|
||||
return ClaudeRender{}, fmt.Errorf("max_tokens must be greater than 1024 for manual Claude thinking")
|
||||
var raisedMaxTokens *uint
|
||||
if maxTokens == nil || *maxTokens <= 1024 {
|
||||
// Manual thinking needs room above the 1024-token minimum budget.
|
||||
// Raising max_tokens keeps the request usable instead of rejecting it.
|
||||
raised := uint(1280)
|
||||
if intent.BudgetTokens != nil && *intent.BudgetTokens >= 0 && *intent.BudgetTokens < math.MaxInt32/2 {
|
||||
raised = max(raised, uint(*intent.BudgetTokens)+1)
|
||||
}
|
||||
current := "a missing max_tokens"
|
||||
if maxTokens != nil {
|
||||
current = fmt.Sprintf("max_tokens %d", *maxTokens)
|
||||
}
|
||||
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
|
||||
"claude_max_tokens_raised",
|
||||
fmt.Sprintf("model %q requires max_tokens greater than 1024 for manual thinking; raised %s to %d", model, current, raised),
|
||||
))
|
||||
raisedMaxTokens = &raised
|
||||
maxTokens = &raised
|
||||
}
|
||||
if uint64(*maxTokens) > uint64(math.MaxInt) {
|
||||
return ClaudeRender{}, fmt.Errorf("max_tokens is too large for a thinking budget")
|
||||
@@ -288,6 +307,7 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
OutputEffort: outputEffort,
|
||||
EffectiveEffort: effectiveEffort,
|
||||
ConstrainThinkingSampling: true,
|
||||
MaxTokens: raisedMaxTokens,
|
||||
Diagnostics: diagnostics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
type GeminiRender struct {
|
||||
Config *dto.GeminiThinkingConfig
|
||||
EffectiveEffort Effort
|
||||
Diagnostics []types.ConversionDiagnostic
|
||||
}
|
||||
|
||||
type geminiThinkingKind int
|
||||
@@ -61,46 +63,85 @@ func geminiCapabilitiesFor(model string) geminiCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
// RenderGemini maps a portable intent onto the target model's thinkingConfig.
|
||||
// The capability table only maps and clamps: budgets become levels on Gemini 3,
|
||||
// efforts become budgets on Gemini 2.5, out-of-range values are clamped, and
|
||||
// models that cannot disable thinking get their lowest setting. Every such
|
||||
// coercion is reported in Diagnostics. Errors are reserved for unknown effort
|
||||
// values and numeric overflow.
|
||||
func RenderGemini(model string, intent Intent, maxOutputTokens *uint, adapterBudgetPercentage float64) (GeminiRender, error) {
|
||||
intent, err := normalizeIntent(intent)
|
||||
intent, diagnostics, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return GeminiRender{}, err
|
||||
}
|
||||
if intent.IsEmpty() {
|
||||
return GeminiRender{}, nil
|
||||
return GeminiRender{Diagnostics: diagnostics}, nil
|
||||
}
|
||||
|
||||
capabilities := geminiCapabilitiesFor(model)
|
||||
if capabilities.kind == geminiThinkingNotConfigurable {
|
||||
if !intent.HasStrength() && capabilities.supportsIncludeThoughts {
|
||||
return GeminiRender{Config: &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}, EffectiveEffort: EffortHigh}, nil
|
||||
render := GeminiRender{Diagnostics: diagnostics}
|
||||
if capabilities.supportsIncludeThoughts {
|
||||
render.EffectiveEffort = EffortHigh
|
||||
if intent.IncludeThoughts != nil {
|
||||
render.Config = &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}
|
||||
}
|
||||
}
|
||||
return GeminiRender{}, fmt.Errorf("model %q does not support configurable thinking", model)
|
||||
if intent.HasStrength() || (intent.IncludeThoughts != nil && !capabilities.supportsIncludeThoughts) {
|
||||
render.Diagnostics = append(render.Diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_thinking_unsupported",
|
||||
fmt.Sprintf("model %q does not support configurable thinking; the requested thinking controls were dropped", model),
|
||||
))
|
||||
}
|
||||
return render, nil
|
||||
}
|
||||
if capabilities.kind == geminiThinkingUnknown {
|
||||
// Without a capability entry there is no way to know whether the model
|
||||
// takes a budget or a level, so no strength is synthesized. Provider-
|
||||
// native controls the client sent stay untouched by the caller.
|
||||
render := GeminiRender{Diagnostics: diagnostics}
|
||||
if intent.HasStrength() {
|
||||
return GeminiRender{}, fmt.Errorf("model %q does not have a known Gemini thinking configuration", model)
|
||||
render.Diagnostics = append(render.Diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_unknown_capability",
|
||||
fmt.Sprintf("model %q has no known Gemini thinking configuration; the requested thinking strength was not applied", model),
|
||||
))
|
||||
}
|
||||
return GeminiRender{Config: &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}}, nil
|
||||
if intent.IncludeThoughts != nil {
|
||||
render.Config = &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}
|
||||
}
|
||||
return render, nil
|
||||
}
|
||||
|
||||
config := &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}
|
||||
if capabilities.kind == geminiThinkingBudget {
|
||||
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
|
||||
if !capabilities.supportsDisable {
|
||||
return GeminiRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
|
||||
}
|
||||
budget := 0
|
||||
effort := EffortNone
|
||||
if !capabilities.supportsDisable {
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_thinking_disable_unsupported",
|
||||
fmt.Sprintf("model %q cannot disable thinking; using its minimum thinking budget %d", model, capabilities.minBudget),
|
||||
))
|
||||
budget = capabilities.minBudget
|
||||
effort = EffortFromBudget(budget)
|
||||
}
|
||||
config.ThinkingBudget = &budget
|
||||
return GeminiRender{Config: config, EffectiveEffort: EffortNone}, nil
|
||||
return GeminiRender{Config: config, EffectiveEffort: effort, Diagnostics: diagnostics}, nil
|
||||
}
|
||||
|
||||
budget := 0
|
||||
hasBudget := false
|
||||
if intent.BudgetTokens != nil {
|
||||
budget = *intent.BudgetTokens
|
||||
if intent.BudgetSource != SourceNative && budget != -1 {
|
||||
budget = clampGeminiBudget(budget, capabilities)
|
||||
if budget != -1 {
|
||||
clamped := clampGeminiBudget(budget, capabilities)
|
||||
if clamped != budget {
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_budget_clamped",
|
||||
fmt.Sprintf("thinking budget %d is outside the supported range [%d,%d] for model %q; using %d", budget, capabilities.minBudget, capabilities.maxBudget, model, clamped),
|
||||
))
|
||||
budget = clamped
|
||||
}
|
||||
}
|
||||
hasBudget = true
|
||||
} else if intent.Effort != "" {
|
||||
@@ -121,9 +162,6 @@ func RenderGemini(model string, intent Intent, maxOutputTokens *uint, adapterBud
|
||||
hasBudget = true
|
||||
}
|
||||
if hasBudget {
|
||||
if err := validateGeminiBudget(model, budget, capabilities); err != nil {
|
||||
return GeminiRender{}, err
|
||||
}
|
||||
config.ThinkingBudget = &budget
|
||||
}
|
||||
effort := intent.Effort
|
||||
@@ -132,27 +170,46 @@ func RenderGemini(model string, intent Intent, maxOutputTokens *uint, adapterBud
|
||||
} else if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
|
||||
effort = geminiDefaultEffort(model)
|
||||
}
|
||||
return GeminiRender{Config: config, EffectiveEffort: effort}, nil
|
||||
return GeminiRender{Config: config, EffectiveEffort: effort, Diagnostics: diagnostics}, nil
|
||||
}
|
||||
|
||||
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
|
||||
return GeminiRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
|
||||
level, err := geminiLevelForEffort(model, EffortMinimal)
|
||||
if err != nil {
|
||||
return GeminiRender{}, err
|
||||
}
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_thinking_disable_unsupported",
|
||||
fmt.Sprintf("model %q cannot disable thinking; using its lowest thinking level %q", model, level),
|
||||
))
|
||||
config.ThinkingLevel = level
|
||||
return GeminiRender{Config: config, EffectiveEffort: Effort(level), Diagnostics: diagnostics}, nil
|
||||
}
|
||||
effort := intent.Effort
|
||||
if effort == "" && intent.BudgetTokens != nil {
|
||||
effort = EffortFromBudget(*intent.BudgetTokens)
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_budget_to_level",
|
||||
fmt.Sprintf("model %q uses thinkingLevel; thinking budget %d was converted to effort %q", model, *intent.BudgetTokens, effort),
|
||||
))
|
||||
}
|
||||
if effort != "" {
|
||||
level, err := geminiLevelForEffort(model, effort)
|
||||
if err != nil {
|
||||
return GeminiRender{}, err
|
||||
}
|
||||
if !strings.EqualFold(level, string(effort)) {
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_level_adjusted",
|
||||
fmt.Sprintf("model %q does not support thinking level %q; using %q", model, effort, level),
|
||||
))
|
||||
}
|
||||
config.ThinkingLevel = level
|
||||
effort = Effort(level)
|
||||
} else if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
|
||||
effort = geminiDefaultEffort(model)
|
||||
}
|
||||
return GeminiRender{Config: config, EffectiveEffort: effort}, nil
|
||||
return GeminiRender{Config: config, EffectiveEffort: effort, Diagnostics: diagnostics}, nil
|
||||
}
|
||||
|
||||
func geminiDefaultEffort(model string) Effort {
|
||||
@@ -176,49 +233,165 @@ func geminiDefaultEffort(model string) Effort {
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateGeminiThinkingConfig(model string, config *dto.GeminiThinkingConfig) (Effort, error) {
|
||||
if config == nil {
|
||||
return "", nil
|
||||
}
|
||||
intent, err := FromGemini(&dto.GeminiChatRequest{GenerationConfig: dto.GeminiChatGenerationConfig{ThinkingConfig: config}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
// NormalizeGeminiThinkingConfig rewrites a provider-native thinkingConfig in
|
||||
// place into the dialect the target model accepts: budgets become levels on
|
||||
// Gemini 3, levels become budgets on Gemini 2.5, out-of-range budgets are
|
||||
// clamped, unsupported levels move to the nearest supported one, and models
|
||||
// without configurable thinking lose the config entirely. Models missing from
|
||||
// the capability table keep their config verbatim. The returned effort is the
|
||||
// accounting label for whatever is now in the config.
|
||||
//
|
||||
// The only error is a config that sets both thinkingBudget and thinkingLevel
|
||||
// on a model whose dialect is unknown; when the dialect is known the field the
|
||||
// model does not use is dropped with a diagnostic.
|
||||
func NormalizeGeminiThinkingConfig(model string, generation *dto.GeminiChatGenerationConfig) (Effort, []types.ConversionDiagnostic, error) {
|
||||
if generation == nil || generation.ThinkingConfig == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
config := generation.ThinkingConfig
|
||||
capabilities := geminiCapabilitiesFor(model)
|
||||
if capabilities.kind == geminiThinkingNotConfigurable {
|
||||
if !intent.HasStrength() && capabilities.supportsIncludeThoughts {
|
||||
return EffortHigh, nil
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
if config.ThinkingBudget != nil && config.ThinkingLevel != "" {
|
||||
switch capabilities.kind {
|
||||
case geminiThinkingBudget:
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_budget_level_conflict",
|
||||
fmt.Sprintf("model %q uses thinkingBudget; thinkingLevel %q was dropped because both were set", model, config.ThinkingLevel),
|
||||
))
|
||||
config.ThinkingLevel = ""
|
||||
case geminiThinkingLevel:
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_budget_level_conflict",
|
||||
fmt.Sprintf("model %q uses thinkingLevel; thinkingBudget %d was dropped because both were set", model, *config.ThinkingBudget),
|
||||
))
|
||||
config.ThinkingBudget = nil
|
||||
}
|
||||
return "", fmt.Errorf("model %q does not support configurable thinking", model)
|
||||
}
|
||||
if capabilities.kind == geminiThinkingNotConfigurable {
|
||||
dropped := config.ThinkingBudget != nil || config.ThinkingLevel != "" || (config.IncludeThoughts != nil && !capabilities.supportsIncludeThoughts)
|
||||
if dropped {
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_thinking_unsupported",
|
||||
fmt.Sprintf("model %q does not support configurable thinking; the thinking configuration was dropped", model),
|
||||
))
|
||||
}
|
||||
if !capabilities.supportsIncludeThoughts || config.IncludeThoughts == nil {
|
||||
generation.ThinkingConfig = nil
|
||||
return "", diagnostics, nil
|
||||
}
|
||||
config.ThinkingBudget = nil
|
||||
config.ThinkingLevel = ""
|
||||
return EffortHigh, diagnostics, nil
|
||||
}
|
||||
|
||||
intent, intentDiagnostics, err := FromGemini(&dto.GeminiChatRequest{GenerationConfig: *generation})
|
||||
if err != nil {
|
||||
return "", diagnostics, err
|
||||
}
|
||||
diagnostics = append(diagnostics, intentDiagnostics...)
|
||||
if capabilities.kind == geminiThinkingUnknown {
|
||||
return EffectiveEffort(intent), nil
|
||||
return EffectiveEffort(intent), diagnostics, nil
|
||||
}
|
||||
|
||||
if capabilities.kind == geminiThinkingBudget {
|
||||
if config.ThinkingLevel != "" {
|
||||
return "", fmt.Errorf("Gemini 2.5 model %q requires thinkingBudget, not thinkingLevel", model)
|
||||
budget := gemini25BudgetForEffort(intent.Effort)
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_level_to_budget",
|
||||
fmt.Sprintf("model %q uses thinkingBudget; thinkingLevel %q was converted to budget %d", model, config.ThinkingLevel, budget),
|
||||
))
|
||||
config.ThinkingLevel = ""
|
||||
config.ThinkingBudget = &budget
|
||||
}
|
||||
if config.ThinkingBudget != nil {
|
||||
if err := validateGeminiBudget(model, *config.ThinkingBudget, capabilities); err != nil {
|
||||
return "", err
|
||||
if config.ThinkingBudget == nil {
|
||||
return EffectiveEffort(intent), diagnostics, nil
|
||||
}
|
||||
budget := *config.ThinkingBudget
|
||||
switch {
|
||||
case budget < -1:
|
||||
budget = -1
|
||||
case budget == 0 && !capabilities.supportsDisable:
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_thinking_disable_unsupported",
|
||||
fmt.Sprintf("model %q cannot disable thinking; using its minimum thinking budget %d", model, capabilities.minBudget),
|
||||
))
|
||||
budget = capabilities.minBudget
|
||||
case budget != 0 && budget != -1:
|
||||
clamped := clampGeminiBudget(budget, capabilities)
|
||||
if clamped != budget {
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_budget_clamped",
|
||||
fmt.Sprintf("thinking budget %d is outside the supported range [%d,%d] for model %q; using %d", budget, capabilities.minBudget, capabilities.maxBudget, model, clamped),
|
||||
))
|
||||
budget = clamped
|
||||
}
|
||||
}
|
||||
return EffectiveEffort(intent), nil
|
||||
config.ThinkingBudget = &budget
|
||||
return EffortFromBudget(budget), diagnostics, nil
|
||||
}
|
||||
|
||||
if config.ThinkingBudget != nil {
|
||||
return "", fmt.Errorf("Gemini 3 model %q requires thinkingLevel, not thinkingBudget", model)
|
||||
}
|
||||
if config.ThinkingLevel != "" {
|
||||
level, err := geminiLevelForEffort(model, intent.Effort)
|
||||
budget := *config.ThinkingBudget
|
||||
config.ThinkingBudget = nil
|
||||
if budget == 0 {
|
||||
level, err := geminiLevelForEffort(model, EffortMinimal)
|
||||
if err != nil {
|
||||
return "", diagnostics, err
|
||||
}
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_thinking_disable_unsupported",
|
||||
fmt.Sprintf("model %q cannot disable thinking; using its lowest thinking level %q", model, level),
|
||||
))
|
||||
config.ThinkingLevel = level
|
||||
return Effort(level), diagnostics, nil
|
||||
}
|
||||
level, err := geminiLevelForEffort(model, EffortFromBudget(budget))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", diagnostics, err
|
||||
}
|
||||
if level != config.ThinkingLevel {
|
||||
return "", fmt.Errorf("thinkingLevel %q is not supported by model %q", config.ThinkingLevel, model)
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_budget_to_level",
|
||||
fmt.Sprintf("model %q uses thinkingLevel; thinkingBudget %d was converted to thinkingLevel %q", model, budget, level),
|
||||
))
|
||||
config.ThinkingLevel = level
|
||||
return Effort(level), diagnostics, nil
|
||||
}
|
||||
if config.ThinkingLevel == "" {
|
||||
return "", diagnostics, nil
|
||||
}
|
||||
if intent.Effort == EffortNone {
|
||||
level, err := geminiLevelForEffort(model, EffortMinimal)
|
||||
if err != nil {
|
||||
return "", diagnostics, err
|
||||
}
|
||||
return Effort(level), nil
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_thinking_disable_unsupported",
|
||||
fmt.Sprintf("model %q cannot disable thinking; using its lowest thinking level %q", model, level),
|
||||
))
|
||||
config.ThinkingLevel = level
|
||||
return Effort(level), diagnostics, nil
|
||||
}
|
||||
level, err := geminiLevelForEffort(model, intent.Effort)
|
||||
if err != nil {
|
||||
return "", diagnostics, err
|
||||
}
|
||||
if level != string(intent.Effort) {
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_level_adjusted",
|
||||
fmt.Sprintf("model %q does not support thinkingLevel %q; using %q", model, config.ThinkingLevel, level),
|
||||
))
|
||||
}
|
||||
config.ThinkingLevel = level
|
||||
return Effort(level), diagnostics, nil
|
||||
}
|
||||
|
||||
func geminiReasoningDiagnostic(code string, message string) types.ConversionDiagnostic {
|
||||
return types.ConversionDiagnostic{
|
||||
Code: code,
|
||||
Path: "generationConfig.thinkingConfig",
|
||||
Message: message,
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ResolveGeminiDefault materializes documented family defaults when a
|
||||
@@ -347,28 +520,6 @@ func geminiLevelForEffort(model string, effort Effort) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func validateGeminiBudget(model string, budget int, capabilities geminiCapabilities) error {
|
||||
if budget == -1 {
|
||||
return nil
|
||||
}
|
||||
if budget == 0 {
|
||||
if capabilities.supportsDisable {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
|
||||
}
|
||||
if budget < capabilities.minBudget || budget > capabilities.maxBudget {
|
||||
return fmt.Errorf("thinking budget %d is outside the supported range [%d,%d] for model %q", budget, capabilities.minBudget, capabilities.maxBudget, model)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clampGeminiBudget(budget int, capabilities geminiCapabilities) int {
|
||||
if budget < capabilities.minBudget {
|
||||
return capabilities.minBudget
|
||||
}
|
||||
if budget > capabilities.maxBudget {
|
||||
return capabilities.maxBudget
|
||||
}
|
||||
return budget
|
||||
return min(max(budget, capabilities.minBudget), capabilities.maxBudget)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package reasoning
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRenderGeminiMapsAndClampsInsteadOfRejecting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
budget8192 := 8192
|
||||
budget100000 := 100000
|
||||
budget100 := 100
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
intent Intent
|
||||
wantNil bool
|
||||
wantLevel string
|
||||
wantBudget *int
|
||||
wantEffort Effort
|
||||
wantCodes []string
|
||||
}{
|
||||
{
|
||||
name: "budget on gemini 3.1 pro becomes level",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
intent: Intent{BudgetTokens: &budget8192},
|
||||
wantLevel: "medium",
|
||||
wantEffort: EffortMedium,
|
||||
wantCodes: []string{"gemini_budget_to_level"},
|
||||
},
|
||||
{
|
||||
name: "medium on gemini 3 pro moves to high",
|
||||
model: "gemini-3-pro-preview",
|
||||
intent: Intent{Mode: ModeEnabled, Effort: EffortMedium},
|
||||
wantLevel: "high",
|
||||
wantEffort: EffortHigh,
|
||||
wantCodes: []string{"gemini_level_adjusted"},
|
||||
},
|
||||
{
|
||||
name: "xhigh on gemini 3 flash moves to high",
|
||||
model: "gemini-3-flash-preview",
|
||||
intent: Intent{Mode: ModeEnabled, Effort: EffortXHigh},
|
||||
wantLevel: "high",
|
||||
wantEffort: EffortHigh,
|
||||
wantCodes: []string{"gemini_level_adjusted"},
|
||||
},
|
||||
{
|
||||
name: "effort on gemini 2.5 flash becomes budget",
|
||||
model: "gemini-2.5-flash",
|
||||
intent: Intent{Mode: ModeEnabled, Effort: EffortHigh},
|
||||
wantBudget: intPtr(24576),
|
||||
wantEffort: EffortHigh,
|
||||
},
|
||||
{
|
||||
name: "oversized budget on gemini 2.5 flash is clamped",
|
||||
model: "gemini-2.5-flash",
|
||||
intent: Intent{BudgetTokens: &budget100000},
|
||||
wantBudget: intPtr(24576),
|
||||
wantEffort: EffortHigh,
|
||||
wantCodes: []string{"gemini_budget_clamped"},
|
||||
},
|
||||
{
|
||||
name: "undersized budget on gemini 2.5 flash lite is raised",
|
||||
model: "gemini-2.5-flash-lite",
|
||||
intent: Intent{BudgetTokens: &budget100},
|
||||
wantBudget: intPtr(512),
|
||||
wantEffort: EffortLow,
|
||||
wantCodes: []string{"gemini_budget_clamped"},
|
||||
},
|
||||
{
|
||||
name: "nothinking on gemini 3.1 pro uses lowest level",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
intent: Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix},
|
||||
wantLevel: "low",
|
||||
wantEffort: EffortLow,
|
||||
wantCodes: []string{"gemini_thinking_disable_unsupported"},
|
||||
},
|
||||
{
|
||||
name: "nothinking on gemini 2.5 pro uses minimum budget",
|
||||
model: "gemini-2.5-pro",
|
||||
intent: Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix},
|
||||
wantBudget: intPtr(128),
|
||||
wantEffort: EffortLow,
|
||||
wantCodes: []string{"gemini_thinking_disable_unsupported"},
|
||||
},
|
||||
{
|
||||
name: "nothinking on gemini 2.5 flash disables",
|
||||
model: "gemini-2.5-flash",
|
||||
intent: Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix},
|
||||
wantBudget: intPtr(0),
|
||||
wantEffort: EffortNone,
|
||||
},
|
||||
{
|
||||
name: "unknown model gets no synthesized config",
|
||||
model: "gemini-2.0-flash-thinking-exp",
|
||||
intent: Intent{Mode: ModeEnabled, Source: SourceSuffix},
|
||||
wantNil: true,
|
||||
wantCodes: []string{"gemini_unknown_capability"},
|
||||
},
|
||||
{
|
||||
name: "image model drops thinking controls",
|
||||
model: "gemini-2.5-flash-image",
|
||||
intent: Intent{Mode: ModeEnabled, Source: SourceSuffix},
|
||||
wantNil: true,
|
||||
wantCodes: []string{"gemini_thinking_unsupported"},
|
||||
},
|
||||
{
|
||||
name: "gemini 3 pro image keeps include thoughts only",
|
||||
model: "gemini-3-pro-image-preview",
|
||||
intent: Intent{Mode: ModeEnabled, Effort: EffortLow, IncludeThoughts: boolPtr(true)},
|
||||
wantEffort: EffortHigh,
|
||||
wantCodes: []string{"gemini_thinking_unsupported"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := RenderGemini(tt.model, tt.intent, nil, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantEffort, got.EffectiveEffort)
|
||||
assert.Equal(t, tt.wantCodes, diagnosticCodes(got.Diagnostics))
|
||||
if tt.wantNil {
|
||||
assert.Nil(t, got.Config)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, got.Config)
|
||||
assert.Equal(t, tt.wantLevel, got.Config.ThinkingLevel)
|
||||
if tt.wantBudget == nil {
|
||||
assert.Nil(t, got.Config.ThinkingBudget)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, got.Config.ThinkingBudget)
|
||||
assert.Equal(t, *tt.wantBudget, *got.Config.ThinkingBudget)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeGeminiThinkingConfigRewritesNativeConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
config dto.GeminiThinkingConfig
|
||||
wantErr error
|
||||
wantNil bool
|
||||
wantLevel string
|
||||
wantBudget *int
|
||||
wantThoughts *bool
|
||||
wantEffort Effort
|
||||
wantCodes []string
|
||||
}{
|
||||
{
|
||||
name: "budget on gemini 3.1 pro becomes level",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(8192)},
|
||||
wantLevel: "medium",
|
||||
wantEffort: EffortMedium,
|
||||
wantCodes: []string{"gemini_budget_to_level"},
|
||||
},
|
||||
{
|
||||
name: "dynamic budget on gemini 3 becomes high",
|
||||
model: "gemini-3-flash-preview",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(-1)},
|
||||
wantLevel: "high",
|
||||
wantEffort: EffortHigh,
|
||||
wantCodes: []string{"gemini_budget_to_level"},
|
||||
},
|
||||
{
|
||||
name: "level on gemini 2.5 flash becomes budget",
|
||||
model: "gemini-2.5-flash",
|
||||
config: dto.GeminiThinkingConfig{ThinkingLevel: "high"},
|
||||
wantBudget: intPtr(24576),
|
||||
wantEffort: EffortHigh,
|
||||
wantCodes: []string{"gemini_level_to_budget"},
|
||||
},
|
||||
{
|
||||
name: "medium on gemini 3 pro moves to high",
|
||||
model: "gemini-3-pro-preview",
|
||||
config: dto.GeminiThinkingConfig{ThinkingLevel: "medium"},
|
||||
wantLevel: "high",
|
||||
wantEffort: EffortHigh,
|
||||
wantCodes: []string{"gemini_level_adjusted"},
|
||||
},
|
||||
{
|
||||
name: "minimal on gemini 3.1 pro moves to low",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
config: dto.GeminiThinkingConfig{ThinkingLevel: "minimal"},
|
||||
wantLevel: "low",
|
||||
wantEffort: EffortLow,
|
||||
wantCodes: []string{"gemini_level_adjusted"},
|
||||
},
|
||||
{
|
||||
name: "supported level is kept without diagnostics",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
config: dto.GeminiThinkingConfig{ThinkingLevel: "low"},
|
||||
wantLevel: "low",
|
||||
wantEffort: EffortLow,
|
||||
},
|
||||
{
|
||||
name: "uppercase enum level is canonicalized without diagnostics",
|
||||
model: "gemini-3.7-flash",
|
||||
config: dto.GeminiThinkingConfig{ThinkingLevel: "MEDIUM"},
|
||||
wantLevel: "medium",
|
||||
wantEffort: EffortMedium,
|
||||
},
|
||||
{
|
||||
name: "mixed case level with whitespace is canonicalized without diagnostics",
|
||||
model: "gemini-3.7-flash",
|
||||
config: dto.GeminiThinkingConfig{ThinkingLevel: " Medium "},
|
||||
wantLevel: "medium",
|
||||
wantEffort: EffortMedium,
|
||||
},
|
||||
{
|
||||
name: "uppercase minimal on gemini 3 pro moves to low",
|
||||
model: "gemini-3-pro-preview",
|
||||
config: dto.GeminiThinkingConfig{ThinkingLevel: "MINIMAL"},
|
||||
wantLevel: "low",
|
||||
wantEffort: EffortLow,
|
||||
wantCodes: []string{"gemini_level_adjusted"},
|
||||
},
|
||||
{
|
||||
name: "xhigh on gemini 3 moves to high",
|
||||
model: "gemini-3.7-flash",
|
||||
config: dto.GeminiThinkingConfig{ThinkingLevel: "xhigh"},
|
||||
wantLevel: "high",
|
||||
wantEffort: EffortHigh,
|
||||
wantCodes: []string{"gemini_level_adjusted"},
|
||||
},
|
||||
{
|
||||
name: "unknown level is rejected",
|
||||
model: "gemini-3.7-flash",
|
||||
config: dto.GeminiThinkingConfig{ThinkingLevel: "ULTRA"},
|
||||
wantErr: ErrUnsupportedEffort,
|
||||
},
|
||||
{
|
||||
name: "oversized budget on gemini 2.5 flash is clamped",
|
||||
model: "gemini-2.5-flash",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(100000)},
|
||||
wantBudget: intPtr(24576),
|
||||
wantEffort: EffortHigh,
|
||||
wantCodes: []string{"gemini_budget_clamped"},
|
||||
},
|
||||
{
|
||||
name: "undersized budget on gemini 2.5 flash lite is raised",
|
||||
model: "gemini-2.5-flash-lite",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(100)},
|
||||
wantBudget: intPtr(512),
|
||||
wantEffort: EffortLow,
|
||||
wantCodes: []string{"gemini_budget_clamped"},
|
||||
},
|
||||
{
|
||||
name: "zero budget on gemini 2.5 pro uses minimum budget",
|
||||
model: "gemini-2.5-pro",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(0)},
|
||||
wantBudget: intPtr(128),
|
||||
wantEffort: EffortLow,
|
||||
wantCodes: []string{"gemini_thinking_disable_unsupported"},
|
||||
},
|
||||
{
|
||||
name: "zero budget on gemini 2.5 flash stays off",
|
||||
model: "gemini-2.5-flash",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(0)},
|
||||
wantBudget: intPtr(0),
|
||||
wantEffort: EffortNone,
|
||||
},
|
||||
{
|
||||
name: "zero budget on gemini 3.1 pro uses lowest level",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(0)},
|
||||
wantLevel: "low",
|
||||
wantEffort: EffortLow,
|
||||
wantCodes: []string{"gemini_thinking_disable_unsupported"},
|
||||
},
|
||||
{
|
||||
name: "dynamic budget on gemini 2.5 passes through",
|
||||
model: "gemini-2.5-flash",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(-1)},
|
||||
wantBudget: intPtr(-1),
|
||||
wantEffort: EffortHigh,
|
||||
},
|
||||
{
|
||||
name: "tts model drops the config",
|
||||
model: "gemini-2.5-flash-preview-tts",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(1024)},
|
||||
wantNil: true,
|
||||
wantCodes: []string{"gemini_thinking_unsupported"},
|
||||
},
|
||||
{
|
||||
name: "gemini 3 pro image keeps include thoughts only",
|
||||
model: "gemini-3-pro-image-preview",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(1024), IncludeThoughts: boolPtr(true)},
|
||||
wantThoughts: boolPtr(true),
|
||||
wantEffort: EffortHigh,
|
||||
wantCodes: []string{"gemini_thinking_unsupported"},
|
||||
},
|
||||
{
|
||||
name: "unknown model passes the config through",
|
||||
model: "gemini-2.0-flash-thinking-exp",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(4096)},
|
||||
wantBudget: intPtr(4096),
|
||||
wantEffort: EffortMedium,
|
||||
},
|
||||
{
|
||||
name: "budget and level on gemini 3 keeps the level",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(8192), ThinkingLevel: "low"},
|
||||
wantLevel: "low",
|
||||
wantEffort: EffortLow,
|
||||
wantCodes: []string{"gemini_budget_level_conflict"},
|
||||
},
|
||||
{
|
||||
name: "budget and level on gemini 2.5 keeps the budget",
|
||||
model: "gemini-2.5-flash",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(4096), ThinkingLevel: "high"},
|
||||
wantBudget: intPtr(4096),
|
||||
wantEffort: EffortMedium,
|
||||
wantCodes: []string{"gemini_budget_level_conflict"},
|
||||
},
|
||||
{
|
||||
name: "budget and level on an unknown model is rejected",
|
||||
model: "gemini-2.0-flash-thinking-exp",
|
||||
config: dto.GeminiThinkingConfig{ThinkingBudget: intPtr(1024), ThinkingLevel: "high"},
|
||||
wantErr: ErrEffortConflict,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
config := tt.config
|
||||
generation := &dto.GeminiChatGenerationConfig{ThinkingConfig: &config}
|
||||
effort, diagnostics, err := NormalizeGeminiThinkingConfig(tt.model, generation)
|
||||
if tt.wantErr != nil {
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantEffort, effort)
|
||||
assert.Equal(t, tt.wantCodes, diagnosticCodes(diagnostics))
|
||||
if tt.wantNil {
|
||||
assert.Nil(t, generation.ThinkingConfig)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, generation.ThinkingConfig)
|
||||
assert.Equal(t, tt.wantLevel, generation.ThinkingConfig.ThinkingLevel)
|
||||
if tt.wantBudget == nil {
|
||||
assert.Nil(t, generation.ThinkingConfig.ThinkingBudget)
|
||||
} else {
|
||||
require.NotNil(t, generation.ThinkingConfig.ThinkingBudget)
|
||||
assert.Equal(t, *tt.wantBudget, *generation.ThinkingConfig.ThinkingBudget)
|
||||
}
|
||||
if tt.wantThoughts != nil {
|
||||
require.NotNil(t, generation.ThinkingConfig.IncludeThoughts)
|
||||
assert.Equal(t, *tt.wantThoughts, *generation.ThinkingConfig.IncludeThoughts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
type Effort string
|
||||
@@ -133,32 +134,62 @@ func ParseEffort(value string) (Effort, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeIntent(intent Intent) (Intent, error) {
|
||||
// reasoningDiagnostic describes a best-effort resolution between reasoning
|
||||
// controls that is not tied to a single provider's field layout. The target
|
||||
// format is filled in by the conversion pipeline.
|
||||
func reasoningDiagnostic(code string, message string) types.ConversionDiagnostic {
|
||||
return types.ConversionDiagnostic{
|
||||
Code: code,
|
||||
Path: "reasoning",
|
||||
Message: message,
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeIntent canonicalizes one intent. Only unknown effort or mode values
|
||||
// are errors; contradictions between mode, effort, and budget are resolved in
|
||||
// favor of the more specific control (a numeric budget beats a mode or effort,
|
||||
// effort none beats an enabling mode) and reported as diagnostics.
|
||||
func normalizeIntent(intent Intent) (Intent, []types.ConversionDiagnostic, error) {
|
||||
effort, err := ParseEffort(string(intent.Effort))
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
intent.Effort = effort
|
||||
|
||||
switch intent.Mode {
|
||||
case ModeUnset, ModeEnabled, ModeAdaptive, ModeDisabled:
|
||||
default:
|
||||
return Intent{}, fmt.Errorf("unsupported reasoning mode %q", intent.Mode)
|
||||
return Intent{}, nil, fmt.Errorf("unsupported reasoning mode %q", intent.Mode)
|
||||
}
|
||||
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
if intent.BudgetTokens != nil {
|
||||
budget := *intent.BudgetTokens
|
||||
if budget < -1 {
|
||||
return Intent{}, fmt.Errorf("thinking budget must be -1 or non-negative, got %d", budget)
|
||||
diagnostics = append(diagnostics, reasoningDiagnostic(
|
||||
"reasoning_budget_adjusted",
|
||||
fmt.Sprintf("thinking budget %d is below -1; using the dynamic budget -1", budget),
|
||||
))
|
||||
budget = -1
|
||||
intent.BudgetTokens = &budget
|
||||
}
|
||||
if budget == 0 {
|
||||
if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive || (intent.Effort != "" && intent.Effort != EffortNone) {
|
||||
return Intent{}, fmt.Errorf("%w: zero budget disables thinking", ErrEffortConflict)
|
||||
diagnostics = append(diagnostics, reasoningDiagnostic(
|
||||
"explicit_fields_conflict",
|
||||
"a zero thinking budget disables thinking; the enabling mode or effort was ignored",
|
||||
))
|
||||
}
|
||||
intent.Mode = ModeDisabled
|
||||
intent.Effort = EffortNone
|
||||
} else if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
|
||||
return Intent{}, fmt.Errorf("%w: a non-zero budget enables thinking", ErrEffortConflict)
|
||||
diagnostics = append(diagnostics, reasoningDiagnostic(
|
||||
"explicit_fields_conflict",
|
||||
fmt.Sprintf("a non-zero thinking budget %d enables thinking; the disabling mode or effort was ignored", budget),
|
||||
))
|
||||
intent.Mode = ModeEnabled
|
||||
intent.Effort = ""
|
||||
} else if intent.Mode == ModeUnset {
|
||||
intent.Mode = ModeEnabled
|
||||
}
|
||||
@@ -166,108 +197,137 @@ func normalizeIntent(intent Intent) (Intent, error) {
|
||||
|
||||
if intent.Effort == EffortNone {
|
||||
if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
|
||||
return Intent{}, fmt.Errorf("%w: effort none disables thinking", ErrEffortConflict)
|
||||
diagnostics = append(diagnostics, reasoningDiagnostic(
|
||||
"explicit_fields_conflict",
|
||||
"effort none disables thinking; the enabling mode was ignored",
|
||||
))
|
||||
}
|
||||
intent.Mode = ModeDisabled
|
||||
}
|
||||
|
||||
return intent, nil
|
||||
return intent, diagnostics, nil
|
||||
}
|
||||
|
||||
// MergeExplicitAndSuffix combines structured request fields with a model-name
|
||||
// alias. Contradictions are rejected because the alias may carry a distinct
|
||||
// billing identity; silently choosing either side would make request semantics
|
||||
// and accounting disagree.
|
||||
func MergeExplicitAndSuffix(explicit Intent, suffix Intent, model string) (Intent, error) {
|
||||
var err error
|
||||
explicit, err = normalizeIntent(explicit)
|
||||
// alias or @ modifier. The model name wins wherever the two disagree: it is
|
||||
// also the billing identity, so upstream behavior and accounting must follow
|
||||
// it. The alias only overrides what it states explicitly; a bare enable such
|
||||
// as -thinking keeps the strength supplied by request fields. Every override
|
||||
// is reported as a suffix_overrode_request diagnostic.
|
||||
func MergeExplicitAndSuffix(explicit Intent, suffix Intent, model string) (Intent, []types.ConversionDiagnostic, error) {
|
||||
explicit, diagnostics, err := normalizeIntent(explicit)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
suffix, err = normalizeIntent(suffix)
|
||||
suffix, suffixDiagnostics, err := normalizeIntent(suffix)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
diagnostics = append(diagnostics, suffixDiagnostics...)
|
||||
|
||||
if !explicit.HasStrength() {
|
||||
if explicit.IncludeThoughts != nil {
|
||||
suffix.IncludeThoughts = explicit.IncludeThoughts
|
||||
}
|
||||
return suffix, nil
|
||||
return suffix, diagnostics, nil
|
||||
}
|
||||
if !suffix.HasStrength() {
|
||||
if explicit.IncludeThoughts == nil {
|
||||
explicit.IncludeThoughts = suffix.IncludeThoughts
|
||||
}
|
||||
return explicit, nil
|
||||
}
|
||||
|
||||
explicitDisabled := explicit.Mode == ModeDisabled || explicit.Effort == EffortNone
|
||||
suffixDisabled := suffix.Mode == ModeDisabled || suffix.Effort == EffortNone
|
||||
if explicitDisabled != suffixDisabled {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit fields and model suffix disagree about whether thinking is enabled", ErrEffortConflict, model)
|
||||
}
|
||||
if !explicitDisabled && explicit.Effort != "" && suffix.Effort != "" && explicit.Effort != suffix.Effort {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit effort %q differs from suffix effort %q", ErrEffortConflict, model, explicit.Effort, suffix.Effort)
|
||||
}
|
||||
if explicit.BudgetTokens != nil && suffix.BudgetTokens != nil && *explicit.BudgetTokens != *suffix.BudgetTokens {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit budget %d differs from suffix budget %d", ErrEffortConflict, model, *explicit.BudgetTokens, *suffix.BudgetTokens)
|
||||
}
|
||||
if (explicit.Effort != "" && explicit.Effort != EffortNone && suffix.BudgetTokens != nil) ||
|
||||
(explicit.BudgetTokens != nil && suffix.Effort != "" && suffix.Effort != EffortNone) {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: effort and an exact suffix budget cannot both select reasoning strength", ErrEffortConflict, model)
|
||||
return explicit, diagnostics, nil
|
||||
}
|
||||
|
||||
merged := suffix
|
||||
if explicit.Mode != ModeUnset {
|
||||
merged.Mode = explicit.Mode
|
||||
}
|
||||
if explicit.Effort != "" {
|
||||
merged.Effort = explicit.Effort
|
||||
}
|
||||
if explicit.BudgetTokens != nil {
|
||||
merged.BudgetTokens = explicit.BudgetTokens
|
||||
merged.BudgetSource = explicit.BudgetSource
|
||||
}
|
||||
if explicit.IncludeThoughts != nil {
|
||||
merged.IncludeThoughts = explicit.IncludeThoughts
|
||||
}
|
||||
return normalizeIntent(merged)
|
||||
explicitDisabled := explicit.Mode == ModeDisabled || explicit.Effort == EffortNone
|
||||
suffixDisabled := suffix.Mode == ModeDisabled || suffix.Effort == EffortNone
|
||||
if explicitDisabled != suffixDisabled {
|
||||
diagnostics = append(diagnostics, reasoningDiagnostic(
|
||||
"suffix_overrode_request",
|
||||
fmt.Sprintf("model %q: the model name and request fields disagree about whether thinking is enabled; the model name wins", model),
|
||||
))
|
||||
return merged, diagnostics, nil
|
||||
}
|
||||
if suffixDisabled {
|
||||
return merged, diagnostics, nil
|
||||
}
|
||||
|
||||
if merged.Mode == ModeEnabled && explicit.Mode == ModeAdaptive {
|
||||
merged.Mode = ModeAdaptive
|
||||
}
|
||||
if suffix.Effort == "" && suffix.BudgetTokens == nil {
|
||||
// A bare enable says nothing about strength; request fields supply it.
|
||||
merged.Effort = explicit.Effort
|
||||
merged.BudgetTokens = explicit.BudgetTokens
|
||||
merged.BudgetSource = explicit.BudgetSource
|
||||
return merged, diagnostics, nil
|
||||
}
|
||||
if explicit.Effort != "" && explicit.Effort != suffix.Effort {
|
||||
diagnostics = append(diagnostics, reasoningDiagnostic(
|
||||
"suffix_overrode_request",
|
||||
fmt.Sprintf("model %q: request effort %q was replaced by the reasoning strength in the model name", model, explicit.Effort),
|
||||
))
|
||||
}
|
||||
if explicit.BudgetTokens != nil && (suffix.BudgetTokens == nil || *explicit.BudgetTokens != *suffix.BudgetTokens) {
|
||||
diagnostics = append(diagnostics, reasoningDiagnostic(
|
||||
"suffix_overrode_request",
|
||||
fmt.Sprintf("model %q: request thinking budget %d was replaced by the reasoning strength in the model name", model, *explicit.BudgetTokens),
|
||||
))
|
||||
}
|
||||
return merged, diagnostics, nil
|
||||
}
|
||||
|
||||
// MergeExplicit combines two structured representations of the same request.
|
||||
// A numeric budget and an effort may coexist: Claude and OpenRouter expose both
|
||||
// controls, and keeping both is what lets an in-memory OpenAI pivot preserve an
|
||||
// exact budget for budget-based targets while retaining an effort for
|
||||
// level-based targets.
|
||||
func MergeExplicit(primary Intent, secondary Intent, model string) (Intent, error) {
|
||||
var err error
|
||||
primary, err = normalizeIntent(primary)
|
||||
// primary wins wherever they disagree; callers pass the more specific
|
||||
// representation first. A numeric budget and an effort may coexist: Claude and
|
||||
// OpenRouter expose both controls, and keeping both is what lets an in-memory
|
||||
// OpenAI pivot preserve an exact budget for budget-based targets while
|
||||
// retaining an effort for level-based targets. Disagreements are reported as
|
||||
// explicit_fields_conflict diagnostics.
|
||||
func MergeExplicit(primary Intent, secondary Intent, model string) (Intent, []types.ConversionDiagnostic, error) {
|
||||
primary, diagnostics, err := normalizeIntent(primary)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
secondary, err = normalizeIntent(secondary)
|
||||
secondary, secondaryDiagnostics, err := normalizeIntent(secondary)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
diagnostics = append(diagnostics, secondaryDiagnostics...)
|
||||
|
||||
if primary.IsEmpty() {
|
||||
return secondary, nil
|
||||
return secondary, diagnostics, nil
|
||||
}
|
||||
if secondary.IsEmpty() {
|
||||
return primary, nil
|
||||
return primary, diagnostics, nil
|
||||
}
|
||||
|
||||
primaryDisabled := primary.Mode == ModeDisabled || primary.Effort == EffortNone
|
||||
secondaryDisabled := secondary.Mode == ModeDisabled || secondary.Effort == EffortNone
|
||||
if primary.HasStrength() && secondary.HasStrength() && primaryDisabled != secondaryDisabled {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit fields disagree about whether thinking is enabled", ErrEffortConflict, model)
|
||||
diagnostics = append(diagnostics, reasoningDiagnostic(
|
||||
"explicit_fields_conflict",
|
||||
fmt.Sprintf("model %q: reasoning fields disagree about whether thinking is enabled; keeping the more specific field", model),
|
||||
))
|
||||
if primary.IncludeThoughts == nil {
|
||||
primary.IncludeThoughts = secondary.IncludeThoughts
|
||||
}
|
||||
return primary, diagnostics, nil
|
||||
}
|
||||
if primary.Effort != "" && secondary.Effort != "" && primary.Effort != secondary.Effort {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit efforts %q and %q differ", ErrEffortConflict, model, primary.Effort, secondary.Effort)
|
||||
diagnostics = append(diagnostics, reasoningDiagnostic(
|
||||
"explicit_fields_conflict",
|
||||
fmt.Sprintf("model %q: reasoning efforts %q and %q differ; keeping %q", model, primary.Effort, secondary.Effort, primary.Effort),
|
||||
))
|
||||
}
|
||||
if primary.BudgetTokens != nil && secondary.BudgetTokens != nil && *primary.BudgetTokens != *secondary.BudgetTokens {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit budgets %d and %d differ", ErrEffortConflict, model, *primary.BudgetTokens, *secondary.BudgetTokens)
|
||||
diagnostics = append(diagnostics, reasoningDiagnostic(
|
||||
"explicit_fields_conflict",
|
||||
fmt.Sprintf("model %q: thinking budgets %d and %d differ; keeping %d", model, *primary.BudgetTokens, *secondary.BudgetTokens, *primary.BudgetTokens),
|
||||
))
|
||||
}
|
||||
|
||||
merged := secondary
|
||||
@@ -284,7 +344,11 @@ func MergeExplicit(primary Intent, secondary Intent, model string) (Intent, erro
|
||||
if primary.IncludeThoughts != nil {
|
||||
merged.IncludeThoughts = primary.IncludeThoughts
|
||||
}
|
||||
return normalizeIntent(merged)
|
||||
merged, mergedDiagnostics, err := normalizeIntent(merged)
|
||||
if err != nil {
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
return merged, append(diagnostics, mergedDiagnostics...), nil
|
||||
}
|
||||
|
||||
type openRouterReasoning struct {
|
||||
@@ -294,9 +358,9 @@ type openRouterReasoning struct {
|
||||
Exclude *bool `json:"exclude,omitempty"`
|
||||
}
|
||||
|
||||
func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, error) {
|
||||
func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, []types.ConversionDiagnostic, error) {
|
||||
if req == nil {
|
||||
return Intent{}, nil
|
||||
return Intent{}, nil, nil
|
||||
}
|
||||
|
||||
var intent Intent
|
||||
@@ -304,7 +368,7 @@ func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, error) {
|
||||
if req.ReasoningEffort != "" {
|
||||
effort, err := ParseEffort(req.ReasoningEffort)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
intent.Effort = effort
|
||||
if effort == EffortNone {
|
||||
@@ -314,10 +378,11 @@ func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, error) {
|
||||
}
|
||||
}
|
||||
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
if len(req.Reasoning) > 0 {
|
||||
var raw openRouterReasoning
|
||||
if err := kitutil.Unmarshal(req.Reasoning, &raw); err != nil {
|
||||
return Intent{}, fmt.Errorf("invalid reasoning config: %w", err)
|
||||
return Intent{}, nil, fmt.Errorf("invalid reasoning config: %w", err)
|
||||
}
|
||||
nested := Intent{BudgetTokens: raw.MaxTokens, Source: SourceExplicit, BudgetSource: SourceExplicit}
|
||||
if raw.Enabled != nil {
|
||||
@@ -331,7 +396,7 @@ func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, error) {
|
||||
if raw.Effort != "" {
|
||||
effort, err := ParseEffort(raw.Effort)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
nested.Effort = effort
|
||||
if effort == EffortNone {
|
||||
@@ -344,15 +409,20 @@ func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, error) {
|
||||
include := !*raw.Exclude
|
||||
nested.IncludeThoughts = &include
|
||||
}
|
||||
var err error
|
||||
intent, err = MergeExplicit(intent, nested, req.Model)
|
||||
merged, mergeDiagnostics, err := MergeExplicit(intent, nested, req.Model)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
intent = merged
|
||||
diagnostics = append(diagnostics, mergeDiagnostics...)
|
||||
}
|
||||
|
||||
if req.ReasoningConversion == nil {
|
||||
return normalizeIntent(intent)
|
||||
normalized, normalizeDiagnostics, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
return normalized, append(diagnostics, normalizeDiagnostics...), nil
|
||||
}
|
||||
pivot := Intent{
|
||||
Mode: Mode(req.ReasoningConversion.Mode),
|
||||
@@ -369,7 +439,11 @@ func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, error) {
|
||||
intent.Mode = ModeUnset
|
||||
}
|
||||
}
|
||||
return MergeExplicit(intent, pivot, req.Model)
|
||||
merged, mergeDiagnostics, err := MergeExplicit(intent, pivot, req.Model)
|
||||
if err != nil {
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
return merged, append(diagnostics, mergeDiagnostics...), nil
|
||||
}
|
||||
|
||||
// ApplyToOpenAIChat writes the portable portion of an intent to the OpenAI
|
||||
@@ -379,7 +453,7 @@ func ApplyToOpenAIChat(req *dto.GeneralOpenAIRequest, intent Intent) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
intent, err := normalizeIntent(intent)
|
||||
intent, _, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -407,7 +481,7 @@ func ApplyToOpenAIResponses(req *dto.OpenAIResponsesRequest, intent Intent) erro
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
intent, err := normalizeIntent(intent)
|
||||
intent, _, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -436,9 +510,9 @@ func ApplyToOpenAIResponses(req *dto.OpenAIResponsesRequest, intent Intent) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, error) {
|
||||
func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, []types.ConversionDiagnostic, error) {
|
||||
if req == nil {
|
||||
return Intent{}, nil
|
||||
return Intent{}, nil, nil
|
||||
}
|
||||
var intent Intent
|
||||
if req.Reasoning != nil {
|
||||
@@ -446,7 +520,7 @@ func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, error) {
|
||||
if req.Reasoning.Effort != "" {
|
||||
effort, err := ParseEffort(req.Reasoning.Effort)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
intent.Effort = effort
|
||||
intent.Mode = ModeEnabled
|
||||
@@ -480,11 +554,16 @@ func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, error) {
|
||||
return MergeExplicit(intent, pivot, req.Model)
|
||||
}
|
||||
|
||||
func FromClaude(req *dto.ClaudeRequest) (Intent, error) {
|
||||
// FromClaude reads Claude's native thinking and output_config controls. Budget
|
||||
// bounds are not enforced here: the Claude renderer clamps budgets into
|
||||
// 1024 <= budget_tokens < max_tokens and reports the adjustment, and other
|
||||
// targets map the raw budget to an effort.
|
||||
func FromClaude(req *dto.ClaudeRequest) (Intent, []types.ConversionDiagnostic, error) {
|
||||
if req == nil {
|
||||
return Intent{}, nil
|
||||
return Intent{}, nil, nil
|
||||
}
|
||||
var intent Intent
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
intent.Source = SourceNative
|
||||
if req.Thinking != nil {
|
||||
switch req.Thinking.Type {
|
||||
@@ -496,17 +575,16 @@ func FromClaude(req *dto.ClaudeRequest) (Intent, error) {
|
||||
intent.Mode = ModeDisabled
|
||||
intent.Effort = EffortNone
|
||||
default:
|
||||
return Intent{}, fmt.Errorf("unsupported Claude thinking type %q", req.Thinking.Type)
|
||||
diagnostics = append(diagnostics, types.ConversionDiagnostic{
|
||||
Code: "claude_thinking_type_coerced",
|
||||
Path: "thinking",
|
||||
Message: fmt.Sprintf("unsupported Claude thinking type %q was treated as enabled", req.Thinking.Type),
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
})
|
||||
intent.Mode = ModeEnabled
|
||||
}
|
||||
intent.BudgetTokens = req.Thinking.BudgetTokens
|
||||
if req.Thinking.BudgetTokens != nil {
|
||||
budget := *req.Thinking.BudgetTokens
|
||||
if budget < 1024 {
|
||||
return Intent{}, fmt.Errorf("Claude thinking budget_tokens must be at least 1024, got %d", budget)
|
||||
}
|
||||
if req.MaxTokens != nil && uint(budget) >= *req.MaxTokens {
|
||||
return Intent{}, fmt.Errorf("Claude thinking budget_tokens must be less than max_tokens")
|
||||
}
|
||||
intent.BudgetSource = SourceNative
|
||||
}
|
||||
switch req.Thinking.Display {
|
||||
@@ -521,29 +599,37 @@ func FromClaude(req *dto.ClaudeRequest) (Intent, error) {
|
||||
if len(req.OutputConfig) > 0 {
|
||||
var output dto.OutputConfigForEffort
|
||||
if err := kitutil.Unmarshal(req.OutputConfig, &output); err != nil {
|
||||
return Intent{}, fmt.Errorf("invalid Claude output_config: %w", err)
|
||||
return Intent{}, nil, fmt.Errorf("invalid Claude output_config: %w", err)
|
||||
}
|
||||
if output.Effort != "" {
|
||||
effort, err := ParseEffort(output.Effort)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
intent.Effort = effort
|
||||
}
|
||||
}
|
||||
if intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone {
|
||||
return intent, nil
|
||||
return intent, diagnostics, nil
|
||||
}
|
||||
return normalizeIntent(intent)
|
||||
normalized, normalizeDiagnostics, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
return normalized, append(diagnostics, normalizeDiagnostics...), nil
|
||||
}
|
||||
|
||||
func FromGemini(req *dto.GeminiChatRequest) (Intent, error) {
|
||||
// FromGemini reads a native thinkingConfig. A config carrying both a budget
|
||||
// and a level is rejected here because the intent alone cannot tell which one
|
||||
// the target model accepts; NormalizeGeminiThinkingConfig resolves that pair
|
||||
// first whenever the model's dialect is known.
|
||||
func FromGemini(req *dto.GeminiChatRequest) (Intent, []types.ConversionDiagnostic, error) {
|
||||
if req == nil || req.GenerationConfig.ThinkingConfig == nil {
|
||||
return Intent{}, nil
|
||||
return Intent{}, nil, nil
|
||||
}
|
||||
config := req.GenerationConfig.ThinkingConfig
|
||||
if config.ThinkingBudget != nil && config.ThinkingLevel != "" {
|
||||
return Intent{}, fmt.Errorf("%w: Gemini thinkingBudget and thinkingLevel cannot both be set", ErrEffortConflict)
|
||||
return Intent{}, nil, fmt.Errorf("%w: Gemini thinkingBudget and thinkingLevel cannot both be set", ErrEffortConflict)
|
||||
}
|
||||
intent := Intent{
|
||||
BudgetTokens: config.ThinkingBudget,
|
||||
@@ -554,16 +640,19 @@ func FromGemini(req *dto.GeminiChatRequest) (Intent, error) {
|
||||
if config.ThinkingLevel != "" {
|
||||
effort, err := ParseEffort(config.ThinkingLevel)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
return Intent{}, nil, err
|
||||
}
|
||||
intent.Effort = effort
|
||||
intent.Mode = ModeEnabled
|
||||
if effort == EffortNone {
|
||||
intent.Mode = ModeDisabled
|
||||
}
|
||||
}
|
||||
return normalizeIntent(intent)
|
||||
}
|
||||
|
||||
func EffectiveEffort(intent Intent) Effort {
|
||||
intent, err := normalizeIntent(intent)
|
||||
intent, _, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -13,16 +14,18 @@ func TestMergeExplicitAndSuffix(t *testing.T) {
|
||||
|
||||
budget1024 := 1024
|
||||
budget2048 := 2048
|
||||
budget4096 := 4096
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
explicit Intent
|
||||
suffix Intent
|
||||
wantErr bool
|
||||
wantMode Mode
|
||||
wantEffort Effort
|
||||
wantBudget *int
|
||||
wantNoBudget bool
|
||||
wantThoughts *bool
|
||||
wantCodes []string
|
||||
}{
|
||||
{
|
||||
name: "enabled plus matching effort merges",
|
||||
@@ -32,28 +35,62 @@ func TestMergeExplicitAndSuffix(t *testing.T) {
|
||||
wantEffort: EffortHigh,
|
||||
},
|
||||
{
|
||||
name: "enabled vs disabled conflict",
|
||||
explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
|
||||
suffix: Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix},
|
||||
wantErr: true,
|
||||
name: "suffix disable wins over explicit enable",
|
||||
explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
|
||||
suffix: Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix},
|
||||
wantMode: ModeDisabled,
|
||||
wantEffort: EffortNone,
|
||||
wantCodes: []string{"suffix_overrode_request"},
|
||||
},
|
||||
{
|
||||
name: "different efforts conflict",
|
||||
explicit: Intent{Mode: ModeEnabled, Effort: EffortLow},
|
||||
suffix: Intent{Mode: ModeEnabled, Effort: EffortHigh, Source: SourceSuffix},
|
||||
wantErr: true,
|
||||
name: "bare suffix enable wins over explicit disable",
|
||||
explicit: Intent{Mode: ModeDisabled, Effort: EffortNone},
|
||||
suffix: Intent{Mode: ModeEnabled, Source: SourceSuffix},
|
||||
wantMode: ModeEnabled,
|
||||
wantEffort: "",
|
||||
wantCodes: []string{"suffix_overrode_request"},
|
||||
},
|
||||
{
|
||||
name: "different budgets conflict",
|
||||
explicit: Intent{BudgetTokens: &budget1024},
|
||||
suffix: Intent{BudgetTokens: &budget2048, Source: SourceSuffix, BudgetSource: SourceSuffix},
|
||||
wantErr: true,
|
||||
name: "suffix effort wins over explicit effort",
|
||||
explicit: Intent{Mode: ModeEnabled, Effort: EffortLow},
|
||||
suffix: Intent{Mode: ModeEnabled, Effort: EffortHigh, Source: SourceSuffix},
|
||||
wantMode: ModeEnabled,
|
||||
wantEffort: EffortHigh,
|
||||
wantCodes: []string{"suffix_overrode_request"},
|
||||
},
|
||||
{
|
||||
name: "effort versus exact suffix budget conflict",
|
||||
explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
|
||||
suffix: Intent{BudgetTokens: &budget1024, Source: SourceSuffix, BudgetSource: SourceSuffix},
|
||||
wantErr: true,
|
||||
name: "suffix budget wins over explicit budget",
|
||||
explicit: Intent{BudgetTokens: &budget1024},
|
||||
suffix: Intent{BudgetTokens: &budget2048, Source: SourceSuffix, BudgetSource: SourceSuffix},
|
||||
wantMode: ModeEnabled,
|
||||
wantBudget: &budget2048,
|
||||
wantCodes: []string{"suffix_overrode_request"},
|
||||
},
|
||||
{
|
||||
name: "suffix budget drops explicit effort",
|
||||
explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
|
||||
suffix: Intent{BudgetTokens: &budget1024, Source: SourceSuffix, BudgetSource: SourceSuffix},
|
||||
wantMode: ModeEnabled,
|
||||
wantEffort: "",
|
||||
wantBudget: &budget1024,
|
||||
wantCodes: []string{"suffix_overrode_request"},
|
||||
},
|
||||
{
|
||||
name: "suffix effort drops explicit budget",
|
||||
explicit: Intent{BudgetTokens: &budget4096},
|
||||
suffix: Intent{Mode: ModeEnabled, Effort: EffortHigh, Source: SourceSuffix},
|
||||
wantMode: ModeEnabled,
|
||||
wantEffort: EffortHigh,
|
||||
wantNoBudget: true,
|
||||
wantCodes: []string{"suffix_overrode_request"},
|
||||
},
|
||||
{
|
||||
name: "bare suffix enable keeps explicit strength",
|
||||
explicit: Intent{Mode: ModeEnabled, Effort: EffortLow, BudgetTokens: &budget4096},
|
||||
suffix: Intent{Mode: ModeEnabled, Source: SourceSuffix},
|
||||
wantMode: ModeEnabled,
|
||||
wantEffort: EffortLow,
|
||||
wantBudget: &budget4096,
|
||||
},
|
||||
{
|
||||
name: "suffix only is adopted",
|
||||
@@ -75,12 +112,7 @@ func TestMergeExplicitAndSuffix(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := MergeExplicitAndSuffix(tt.explicit, tt.suffix, "claude-opus-4-8")
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrEffortConflict)
|
||||
return
|
||||
}
|
||||
got, diagnostics, err := MergeExplicitAndSuffix(tt.explicit, tt.suffix, "claude-opus-4-8")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantMode, got.Mode)
|
||||
assert.Equal(t, tt.wantEffort, got.Effort)
|
||||
@@ -88,14 +120,73 @@ func TestMergeExplicitAndSuffix(t *testing.T) {
|
||||
require.NotNil(t, got.BudgetTokens)
|
||||
assert.Equal(t, *tt.wantBudget, *got.BudgetTokens)
|
||||
}
|
||||
if tt.wantNoBudget {
|
||||
assert.Nil(t, got.BudgetTokens)
|
||||
}
|
||||
if tt.wantThoughts != nil {
|
||||
require.NotNil(t, got.IncludeThoughts)
|
||||
assert.Equal(t, *tt.wantThoughts, *got.IncludeThoughts)
|
||||
}
|
||||
assert.Equal(t, tt.wantCodes, diagnosticCodes(diagnostics))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromOpenAIChatResolvesFieldConflictsWithDiagnostics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("reasoning_effort wins over nested effort", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, diagnostics, err := FromOpenAIChat(&dto.GeneralOpenAIRequest{
|
||||
ReasoningEffort: "low",
|
||||
Reasoning: []byte(`{"effort":"high"}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ModeEnabled, got.Mode)
|
||||
assert.Equal(t, EffortLow, got.Effort)
|
||||
assert.Equal(t, []string{"explicit_fields_conflict"}, diagnosticCodes(diagnostics))
|
||||
})
|
||||
|
||||
t.Run("zero nested budget disables despite enabled flag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, diagnostics, err := FromOpenAIChat(&dto.GeneralOpenAIRequest{
|
||||
Reasoning: []byte(`{"enabled":true,"max_tokens":0}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ModeDisabled, got.Mode)
|
||||
assert.Equal(t, EffortNone, got.Effort)
|
||||
assert.Equal(t, []string{"explicit_fields_conflict"}, diagnosticCodes(diagnostics))
|
||||
})
|
||||
|
||||
t.Run("unknown effort value is still rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := FromOpenAIChat(&dto.GeneralOpenAIRequest{ReasoningEffort: "ultra"})
|
||||
require.ErrorIs(t, err, ErrUnsupportedEffort)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFromClaudeKeepsOutOfRangeBudgetsForTheRenderer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
maxTokens := uint(4096)
|
||||
for _, budget := range []int{512, 4096} {
|
||||
got, diagnostics, err := FromClaude(&dto.ClaudeRequest{
|
||||
MaxTokens: &maxTokens,
|
||||
Thinking: &dto.Thinking{Type: "enabled", BudgetTokens: &budget},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ModeEnabled, got.Mode)
|
||||
require.NotNil(t, got.BudgetTokens)
|
||||
assert.Equal(t, budget, *got.BudgetTokens)
|
||||
assert.Empty(t, diagnostics)
|
||||
}
|
||||
|
||||
got, diagnostics, err := FromClaude(&dto.ClaudeRequest{Thinking: &dto.Thinking{Type: "auto"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ModeEnabled, got.Mode)
|
||||
assert.Equal(t, []string{"claude_thinking_type_coerced"}, diagnosticCodes(diagnostics))
|
||||
}
|
||||
|
||||
func TestIntentStateRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -125,6 +216,14 @@ func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func diagnosticCodes(diagnostics []types.ConversionDiagnostic) []string {
|
||||
var codes []string
|
||||
for _, diagnostic := range diagnostics {
|
||||
codes = append(codes, diagnostic.Code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func TestOpenAIPivotRetainsExactStrengthAndBudget(t *testing.T) {
|
||||
budget, include := 16384, false
|
||||
for _, effort := range []Effort{EffortMax, EffortXHigh} {
|
||||
@@ -133,7 +232,7 @@ func TestOpenAIPivotRetainsExactStrengthAndBudget(t *testing.T) {
|
||||
chat := &dto.GeneralOpenAIRequest{}
|
||||
require.NoError(t, ApplyToOpenAIChat(chat, intent))
|
||||
assert.Equal(t, string(effort), chat.ReasoningEffort)
|
||||
restored, err := FromOpenAIChat(chat)
|
||||
restored, _, err := FromOpenAIChat(chat)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, effort, restored.Effort)
|
||||
require.NotNil(t, restored.BudgetTokens)
|
||||
@@ -145,7 +244,7 @@ func TestOpenAIPivotRetainsExactStrengthAndBudget(t *testing.T) {
|
||||
require.NoError(t, ApplyToOpenAIResponses(responses, restored))
|
||||
require.NotNil(t, responses.Reasoning)
|
||||
assert.Equal(t, string(effort), responses.Reasoning.Effort)
|
||||
restored, err = FromOpenAIResponses(responses)
|
||||
restored, _, err = FromOpenAIResponses(responses)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, effort, restored.Effort)
|
||||
require.NotNil(t, restored.BudgetTokens)
|
||||
@@ -156,17 +255,21 @@ func TestOpenAIPivotRetainsExactStrengthAndBudget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIPivotDoesNotTreatMaxAndXHighAsEquivalent(t *testing.T) {
|
||||
func TestOpenAIPivotExplicitEffortOverridesPivotWithDiagnostic(t *testing.T) {
|
||||
intent := Intent{Mode: ModeEnabled, Effort: EffortMax}
|
||||
chat := &dto.GeneralOpenAIRequest{}
|
||||
require.NoError(t, ApplyToOpenAIChat(chat, intent))
|
||||
chat.ReasoningEffort = "xhigh"
|
||||
_, err := FromOpenAIChat(chat)
|
||||
require.ErrorIs(t, err, ErrEffortConflict)
|
||||
got, diagnostics, err := FromOpenAIChat(chat)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, EffortXHigh, got.Effort)
|
||||
assert.Equal(t, []string{"explicit_fields_conflict"}, diagnosticCodes(diagnostics))
|
||||
|
||||
responses := &dto.OpenAIResponsesRequest{}
|
||||
require.NoError(t, ApplyToOpenAIResponses(responses, intent))
|
||||
responses.Reasoning.Effort = "xhigh"
|
||||
_, err = FromOpenAIResponses(responses)
|
||||
require.ErrorIs(t, err, ErrEffortConflict)
|
||||
got, diagnostics, err = FromOpenAIResponses(responses)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, EffortXHigh, got.Effort)
|
||||
assert.Equal(t, []string{"explicit_fields_conflict"}, diagnosticCodes(diagnostics))
|
||||
}
|
||||
@@ -30,7 +30,20 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
|
||||
}
|
||||
|
||||
func ApplyGeminiThinkingConfigChecked(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) error {
|
||||
return reasoning.AsClientError(sharedgemini.ApplyThinkingConfig(geminiRequest, info, oaiRequest...))
|
||||
ctx, collector := convdiag.WithCollector(context.Background())
|
||||
err := reasoning.AsClientError(sharedgemini.ApplyThinkingConfig(ctx, geminiRequest, info, oaiRequest...))
|
||||
if recorder, ok := info.(interface {
|
||||
RecordConversionDiagnostics(context.Context, []types.ConversionDiagnostic)
|
||||
}); ok {
|
||||
diagnostics := collector.Diagnostics()
|
||||
for i := range diagnostics {
|
||||
if diagnostics[i].To == "" {
|
||||
diagnostics[i].To = types.RelayFormatGemini
|
||||
}
|
||||
}
|
||||
recorder.RecordConversionDiagnostics(ctx, diagnostics)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ApplyClaudeThinkingModel(claudeRequest *dto.ClaudeRequest, info convmeta.Meta) error {
|
||||
|
||||
@@ -430,7 +430,7 @@ func isNilRequest(request any) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func convertChatRequestToResponses(_ context.Context, _ convmeta.Meta, request any) (any, error) {
|
||||
func convertChatRequestToResponses(c context.Context, _ convmeta.Meta, request any) (any, error) {
|
||||
chatRequest, ok := request.(*dto.GeneralOpenAIRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.GeneralOpenAIRequest); ok {
|
||||
@@ -440,10 +440,10 @@ func convertChatRequestToResponses(_ context.Context, _ convmeta.Meta, request a
|
||||
if chatRequest == nil {
|
||||
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
|
||||
}
|
||||
return oaichat.ChatCompletionsRequestToResponsesRequest(chatRequest)
|
||||
return oaichat.ChatCompletionsRequestToResponsesRequest(c, chatRequest)
|
||||
}
|
||||
|
||||
func convertClaudeRequestToOpenAI(_ context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
func convertClaudeRequestToOpenAI(c context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
claudeRequest, ok := request.(*dto.ClaudeRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.ClaudeRequest); ok {
|
||||
@@ -453,10 +453,10 @@ func convertClaudeRequestToOpenAI(_ context.Context, info convmeta.Meta, request
|
||||
if claudeRequest == nil {
|
||||
return nil, fmt.Errorf("expected Anthropic Messages request, got %T", request)
|
||||
}
|
||||
return claudemessages.ClaudeMessagesRequestToOpenAIChat(*claudeRequest, info)
|
||||
return claudemessages.ClaudeMessagesRequestToOpenAIChat(c, *claudeRequest, info)
|
||||
}
|
||||
|
||||
func convertClaudeRequestToOpenAIResponses(_ context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
func convertClaudeRequestToOpenAIResponses(c context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
claudeRequest, ok := request.(*dto.ClaudeRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.ClaudeRequest); ok {
|
||||
@@ -466,7 +466,7 @@ func convertClaudeRequestToOpenAIResponses(_ context.Context, info convmeta.Meta
|
||||
if claudeRequest == nil {
|
||||
return nil, fmt.Errorf("expected Anthropic Messages request, got %T", request)
|
||||
}
|
||||
return claudemessages.ClaudeMessagesRequestToOpenAIResponses(*claudeRequest, info)
|
||||
return claudemessages.ClaudeMessagesRequestToOpenAIResponses(c, *claudeRequest, info)
|
||||
}
|
||||
|
||||
func convertOpenAIRequestToClaude(c context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
@@ -482,7 +482,7 @@ func convertOpenAIRequestToClaude(c context.Context, info convmeta.Meta, request
|
||||
return oaichat.OpenAIChatRequestToClaudeMessages(c, info, *openAIRequest)
|
||||
}
|
||||
|
||||
func convertGeminiRequestToOpenAI(_ context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
func convertGeminiRequestToOpenAI(c context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
geminiRequest, ok := request.(*dto.GeminiChatRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.GeminiChatRequest); ok {
|
||||
@@ -492,7 +492,7 @@ func convertGeminiRequestToOpenAI(_ context.Context, info convmeta.Meta, request
|
||||
if geminiRequest == nil {
|
||||
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", request)
|
||||
}
|
||||
return geminichat.GeminiGenerateContentRequestToOpenAIChat(geminiRequest, info)
|
||||
return geminichat.GeminiGenerateContentRequestToOpenAIChat(c, geminiRequest, info)
|
||||
}
|
||||
|
||||
func convertOpenAIRequestToGemini(c context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
@@ -529,7 +529,7 @@ func convertOpenAIResponsesRequestToGeminiChat(c context.Context, info convmeta.
|
||||
return oairesponses.OpenAIResponsesRequestToGeminiChat(c, &prepared, info)
|
||||
}
|
||||
|
||||
func convertResponsesRequestToChat(_ context.Context, _ convmeta.Meta, request any) (any, error) {
|
||||
func convertResponsesRequestToChat(c context.Context, _ convmeta.Meta, request any) (any, error) {
|
||||
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
|
||||
@@ -539,5 +539,5 @@ func convertResponsesRequestToChat(_ context.Context, _ convmeta.Meta, request a
|
||||
if responsesRequest == nil {
|
||||
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
|
||||
}
|
||||
return oairesponses.ResponsesRequestToChatCompletionsRequest(responsesRequest)
|
||||
return oairesponses.ResponsesRequestToChatCompletionsRequest(c, responsesRequest)
|
||||
}
|
||||
@@ -277,6 +277,78 @@ func TestConvertRequestClaudeAdaptiveThinkingPreservesEffort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiThinkingLevelCaseInsensitiveAcrossPaths(t *testing.T) {
|
||||
newRequest := func(level string) *dto.GeminiChatRequest {
|
||||
return &dto.GeminiChatRequest{
|
||||
Contents: []dto.GeminiChatContent{{Role: "user", Parts: []dto.GeminiPart{{Text: "hello"}}}},
|
||||
GenerationConfig: dto.GeminiChatGenerationConfig{
|
||||
ThinkingConfig: &dto.GeminiThinkingConfig{ThinkingLevel: level},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("native passthrough records canonical effort without rewriting wire value", func(t *testing.T) {
|
||||
info := &convmeta.Values{OriginModelName: "gemini-3.7-flash", UpstreamModelName: "gemini-3.7-flash"}
|
||||
req := newRequest(" MEDIUM ")
|
||||
require.NoError(t, ApplyGeminiThinkingConfigChecked(req, info))
|
||||
assert.Equal(t, "medium", info.GetReasoningEffort())
|
||||
assert.Equal(t, " MEDIUM ", req.GenerationConfig.ThinkingConfig.ThinkingLevel)
|
||||
})
|
||||
|
||||
t.Run("native passthrough keeps unknown level as sent", func(t *testing.T) {
|
||||
info := &convmeta.Values{OriginModelName: "gemini-3.7-flash", UpstreamModelName: "gemini-3.7-flash"}
|
||||
req := newRequest("ULTRA")
|
||||
require.NoError(t, ApplyGeminiThinkingConfigChecked(req, info))
|
||||
assert.Equal(t, "ULTRA", info.GetReasoningEffort())
|
||||
})
|
||||
|
||||
t.Run("suffix state canonicalizes uppercase level against normalized effort", func(t *testing.T) {
|
||||
info := &convmeta.Values{
|
||||
OriginModelName: "gemini-3.7-flash-thinking-medium",
|
||||
UpstreamModelName: "gemini-3.7-flash",
|
||||
ChannelMetaAttached: true,
|
||||
ReasoningConversion: &dto.ReasoningConversionState{Mode: "enabled", Effort: "medium"},
|
||||
}
|
||||
req := newRequest("MEDIUM")
|
||||
require.NoError(t, ApplyGeminiThinkingConfigChecked(req, info))
|
||||
assert.Equal(t, "medium", info.GetReasoningEffort())
|
||||
assert.Equal(t, "medium", req.GenerationConfig.ThinkingConfig.ThinkingLevel)
|
||||
})
|
||||
|
||||
t.Run("gemini to openai conversion accepts uppercase level", func(t *testing.T) {
|
||||
info := &convmeta.Values{
|
||||
OriginModelName: "gemini-3.7-flash",
|
||||
UpstreamModelName: "gemini-3.7-flash",
|
||||
ConversionChain: []types.RelayFormat{types.RelayFormatGemini},
|
||||
}
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAI, newRequest("MEDIUM"))
|
||||
require.NoError(t, err)
|
||||
openaiReq, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "medium", openaiReq.ReasoningEffort)
|
||||
assert.Equal(t, "medium", info.GetReasoningEffort())
|
||||
})
|
||||
|
||||
t.Run("gemini to openai conversion adjusts unsupported level with a diagnostic", func(t *testing.T) {
|
||||
info := &convmeta.Values{
|
||||
OriginModelName: "gemini-3-pro-preview",
|
||||
UpstreamModelName: "gemini-3-pro-preview",
|
||||
ConversionChain: []types.RelayFormat{types.RelayFormatGemini},
|
||||
}
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAI, newRequest("MINIMAL"))
|
||||
require.NoError(t, err)
|
||||
openaiReq, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "low", openaiReq.ReasoningEffort)
|
||||
assert.Equal(t, "low", info.GetReasoningEffort())
|
||||
codes := make([]string, 0, len(result.Diagnostics))
|
||||
for _, diagnostic := range result.Diagnostics {
|
||||
codes = append(codes, diagnostic.Code)
|
||||
}
|
||||
assert.Contains(t, codes, "gemini_level_adjusted")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertRequestViaExecutesExplicitPath(t *testing.T) {
|
||||
info := &convmeta.Values{
|
||||
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAI},
|
||||
|
||||
Reference in new issue
Block a user