Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
689 lines
21 KiB
Go
689 lines
21 KiB
Go
package reasoning
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
|
"github.com/QuantumNous/new-api/relaykit/types"
|
|
)
|
|
|
|
type Effort string
|
|
|
|
const (
|
|
EffortNone Effort = "none"
|
|
EffortMinimal Effort = "minimal"
|
|
EffortLow Effort = "low"
|
|
EffortMedium Effort = "medium"
|
|
EffortHigh Effort = "high"
|
|
EffortXHigh Effort = "xhigh"
|
|
EffortMax Effort = "max"
|
|
)
|
|
|
|
type Mode string
|
|
|
|
type Source string
|
|
|
|
// ClientError marks invalid user-supplied reasoning controls so host handlers
|
|
// can return a 4xx without classifying unrelated adapter failures as client
|
|
// errors.
|
|
type ClientError struct {
|
|
err error
|
|
}
|
|
|
|
func (e *ClientError) Error() string { return e.err.Error() }
|
|
func (e *ClientError) Unwrap() error { return e.err }
|
|
|
|
func AsClientError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var clientErr *ClientError
|
|
if errors.As(err, &clientErr) {
|
|
return err
|
|
}
|
|
return &ClientError{err: err}
|
|
}
|
|
|
|
func IsClientError(err error) bool {
|
|
var clientErr *ClientError
|
|
return errors.As(err, &clientErr)
|
|
}
|
|
|
|
const (
|
|
ModeUnset Mode = ""
|
|
ModeEnabled Mode = "enabled"
|
|
ModeAdaptive Mode = "adaptive"
|
|
ModeDisabled Mode = "disabled"
|
|
)
|
|
|
|
const (
|
|
SourceExplicit Source = "explicit"
|
|
SourceNative Source = "native"
|
|
SourceSuffix Source = "suffix"
|
|
SourcePivot Source = "pivot"
|
|
)
|
|
|
|
var (
|
|
ErrEffortConflict = errors.New("reasoning settings conflict")
|
|
ErrUnsupportedEffort = errors.New("unsupported reasoning effort")
|
|
ErrThinkingNotDisabled = errors.New("thinking cannot be disabled")
|
|
)
|
|
|
|
// Intent is the protocol-independent part of a request's reasoning controls.
|
|
// Summary visibility is intentionally independent from reasoning strength.
|
|
type Intent struct {
|
|
Mode Mode
|
|
Effort Effort
|
|
BudgetTokens *int
|
|
IncludeThoughts *bool
|
|
Source Source
|
|
BudgetSource Source
|
|
}
|
|
|
|
func (i Intent) HasStrength() bool {
|
|
return i.Mode != ModeUnset || i.Effort != "" || i.BudgetTokens != nil
|
|
}
|
|
|
|
func (i Intent) IsEmpty() bool {
|
|
return !i.HasStrength() && i.IncludeThoughts == nil
|
|
}
|
|
|
|
// IntentFromState reconstructs a portable intent from host- or pivot-carried
|
|
// conversion state. A nil state is an empty intent.
|
|
func IntentFromState(state *dto.ReasoningConversionState) Intent {
|
|
if state == nil {
|
|
return Intent{}
|
|
}
|
|
return Intent{
|
|
Mode: Mode(state.Mode),
|
|
Effort: Effort(state.Effort),
|
|
BudgetTokens: state.BudgetTokens,
|
|
IncludeThoughts: state.IncludeThoughts,
|
|
Source: SourceSuffix,
|
|
BudgetSource: SourceSuffix,
|
|
}
|
|
}
|
|
|
|
// StateFromIntent copies the portable fields of an intent into conversion
|
|
// state. Empty intents produce nil so callers can omit the field.
|
|
func StateFromIntent(intent Intent) *dto.ReasoningConversionState {
|
|
if intent.IsEmpty() {
|
|
return nil
|
|
}
|
|
return &dto.ReasoningConversionState{
|
|
Mode: string(intent.Mode),
|
|
Effort: string(intent.Effort),
|
|
BudgetTokens: intent.BudgetTokens,
|
|
IncludeThoughts: intent.IncludeThoughts,
|
|
}
|
|
}
|
|
|
|
func ParseEffort(value string) (Effort, error) {
|
|
effort := Effort(strings.ToLower(strings.TrimSpace(value)))
|
|
if effort == "" {
|
|
return "", nil
|
|
}
|
|
switch effort {
|
|
case EffortNone, EffortMinimal, EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax:
|
|
return effort, nil
|
|
default:
|
|
return "", fmt.Errorf("%w: %q", ErrUnsupportedEffort, value)
|
|
}
|
|
}
|
|
|
|
// 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{}, nil, err
|
|
}
|
|
intent.Effort = effort
|
|
|
|
switch intent.Mode {
|
|
case ModeUnset, ModeEnabled, ModeAdaptive, ModeDisabled:
|
|
default:
|
|
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 {
|
|
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) {
|
|
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 {
|
|
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
|
|
}
|
|
}
|
|
|
|
if intent.Effort == EffortNone {
|
|
if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
|
|
diagnostics = append(diagnostics, reasoningDiagnostic(
|
|
"explicit_fields_conflict",
|
|
"effort none disables thinking; the enabling mode was ignored",
|
|
))
|
|
}
|
|
intent.Mode = ModeDisabled
|
|
}
|
|
|
|
return intent, diagnostics, nil
|
|
}
|
|
|
|
// MergeExplicitAndSuffix combines structured request fields with a model-name
|
|
// 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{}, nil, err
|
|
}
|
|
suffix, suffixDiagnostics, err := normalizeIntent(suffix)
|
|
if err != nil {
|
|
return Intent{}, nil, err
|
|
}
|
|
diagnostics = append(diagnostics, suffixDiagnostics...)
|
|
|
|
if !explicit.HasStrength() {
|
|
if explicit.IncludeThoughts != nil {
|
|
suffix.IncludeThoughts = explicit.IncludeThoughts
|
|
}
|
|
return suffix, diagnostics, nil
|
|
}
|
|
if !suffix.HasStrength() {
|
|
if explicit.IncludeThoughts == nil {
|
|
explicit.IncludeThoughts = suffix.IncludeThoughts
|
|
}
|
|
return explicit, diagnostics, nil
|
|
}
|
|
|
|
merged := suffix
|
|
if explicit.IncludeThoughts != nil {
|
|
merged.IncludeThoughts = explicit.IncludeThoughts
|
|
}
|
|
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.
|
|
// 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{}, nil, err
|
|
}
|
|
secondary, secondaryDiagnostics, err := normalizeIntent(secondary)
|
|
if err != nil {
|
|
return Intent{}, nil, err
|
|
}
|
|
diagnostics = append(diagnostics, secondaryDiagnostics...)
|
|
|
|
if primary.IsEmpty() {
|
|
return secondary, diagnostics, nil
|
|
}
|
|
if secondary.IsEmpty() {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
if primary.Mode != ModeUnset {
|
|
merged.Mode = primary.Mode
|
|
}
|
|
if primary.Effort != "" {
|
|
merged.Effort = primary.Effort
|
|
}
|
|
if primary.BudgetTokens != nil {
|
|
merged.BudgetTokens = primary.BudgetTokens
|
|
merged.BudgetSource = primary.BudgetSource
|
|
}
|
|
if primary.IncludeThoughts != nil {
|
|
merged.IncludeThoughts = primary.IncludeThoughts
|
|
}
|
|
merged, mergedDiagnostics, err := normalizeIntent(merged)
|
|
if err != nil {
|
|
return Intent{}, nil, err
|
|
}
|
|
return merged, append(diagnostics, mergedDiagnostics...), nil
|
|
}
|
|
|
|
type openRouterReasoning struct {
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
Effort string `json:"effort,omitempty"`
|
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
|
Exclude *bool `json:"exclude,omitempty"`
|
|
}
|
|
|
|
func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, []types.ConversionDiagnostic, error) {
|
|
if req == nil {
|
|
return Intent{}, nil, nil
|
|
}
|
|
|
|
var intent Intent
|
|
intent.Source = SourceExplicit
|
|
if req.ReasoningEffort != "" {
|
|
effort, err := ParseEffort(req.ReasoningEffort)
|
|
if err != nil {
|
|
return Intent{}, nil, err
|
|
}
|
|
intent.Effort = effort
|
|
if effort == EffortNone {
|
|
intent.Mode = ModeDisabled
|
|
} else {
|
|
intent.Mode = ModeEnabled
|
|
}
|
|
}
|
|
|
|
var diagnostics []types.ConversionDiagnostic
|
|
if len(req.Reasoning) > 0 {
|
|
var raw openRouterReasoning
|
|
if err := kitutil.Unmarshal(req.Reasoning, &raw); err != nil {
|
|
return Intent{}, nil, fmt.Errorf("invalid reasoning config: %w", err)
|
|
}
|
|
nested := Intent{BudgetTokens: raw.MaxTokens, Source: SourceExplicit, BudgetSource: SourceExplicit}
|
|
if raw.Enabled != nil {
|
|
if *raw.Enabled {
|
|
nested.Mode = ModeEnabled
|
|
} else {
|
|
nested.Mode = ModeDisabled
|
|
nested.Effort = EffortNone
|
|
}
|
|
}
|
|
if raw.Effort != "" {
|
|
effort, err := ParseEffort(raw.Effort)
|
|
if err != nil {
|
|
return Intent{}, nil, err
|
|
}
|
|
nested.Effort = effort
|
|
if effort == EffortNone {
|
|
nested.Mode = ModeDisabled
|
|
} else if nested.Mode == ModeUnset {
|
|
nested.Mode = ModeEnabled
|
|
}
|
|
}
|
|
if raw.Exclude != nil {
|
|
include := !*raw.Exclude
|
|
nested.IncludeThoughts = &include
|
|
}
|
|
merged, mergeDiagnostics, err := MergeExplicit(intent, nested, req.Model)
|
|
if err != nil {
|
|
return Intent{}, nil, err
|
|
}
|
|
intent = merged
|
|
diagnostics = append(diagnostics, mergeDiagnostics...)
|
|
}
|
|
|
|
if req.ReasoningConversion == nil {
|
|
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),
|
|
Effort: Effort(req.ReasoningConversion.Effort),
|
|
BudgetTokens: req.ReasoningConversion.BudgetTokens,
|
|
IncludeThoughts: req.ReasoningConversion.IncludeThoughts,
|
|
Source: SourcePivot,
|
|
BudgetSource: SourcePivot,
|
|
}
|
|
if req.ReasoningEffort != "" {
|
|
pivotEffort := EffectiveEffort(pivot)
|
|
if Effort(req.ReasoningEffort) == pivotEffort {
|
|
intent.Effort = ""
|
|
intent.Mode = ModeUnset
|
|
}
|
|
}
|
|
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
|
|
// pivot. reasoning_effort carries level-based strength; a JSON-excluded DTO
|
|
// state retains exact budgets and summary visibility across in-process steps.
|
|
func ApplyToOpenAIChat(req *dto.GeneralOpenAIRequest, intent Intent) error {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
intent, _, err := normalizeIntent(intent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if effort := EffectiveEffort(intent); effort != "" {
|
|
req.ReasoningEffort = string(effort)
|
|
}
|
|
|
|
if intent.IsEmpty() {
|
|
return nil
|
|
}
|
|
req.ReasoningConversion = &dto.ReasoningConversionState{
|
|
Mode: string(intent.Mode),
|
|
Effort: string(intent.Effort),
|
|
BudgetTokens: intent.BudgetTokens,
|
|
IncludeThoughts: intent.IncludeThoughts,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ApplyToOpenAIResponses writes the portable portion of an intent directly to
|
|
// a Responses request. The JSON-excluded state retains exact provider-native
|
|
// controls for any later in-process conversion.
|
|
func ApplyToOpenAIResponses(req *dto.OpenAIResponsesRequest, intent Intent) error {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
intent, _, err := normalizeIntent(intent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if effort := EffectiveEffort(intent); effort != "" {
|
|
summary := "detailed"
|
|
if effort == EffortNone || (intent.IncludeThoughts != nil && !*intent.IncludeThoughts) {
|
|
summary = ""
|
|
}
|
|
req.Reasoning = &dto.Reasoning{
|
|
Effort: string(effort),
|
|
Summary: summary,
|
|
}
|
|
}
|
|
|
|
if intent.IsEmpty() {
|
|
return nil
|
|
}
|
|
state := &dto.ReasoningConversionState{
|
|
Mode: string(intent.Mode),
|
|
Effort: string(intent.Effort),
|
|
BudgetTokens: intent.BudgetTokens,
|
|
IncludeThoughts: intent.IncludeThoughts,
|
|
}
|
|
req.ReasoningConversion = state
|
|
return nil
|
|
}
|
|
|
|
func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, []types.ConversionDiagnostic, error) {
|
|
if req == nil {
|
|
return Intent{}, nil, nil
|
|
}
|
|
var intent Intent
|
|
if req.Reasoning != nil {
|
|
intent.Source = SourceExplicit
|
|
if req.Reasoning.Effort != "" {
|
|
effort, err := ParseEffort(req.Reasoning.Effort)
|
|
if err != nil {
|
|
return Intent{}, nil, err
|
|
}
|
|
intent.Effort = effort
|
|
intent.Mode = ModeEnabled
|
|
if effort == EffortNone {
|
|
intent.Mode = ModeDisabled
|
|
}
|
|
}
|
|
if req.Reasoning.Summary != "" {
|
|
include := true
|
|
intent.IncludeThoughts = &include
|
|
}
|
|
}
|
|
if req.ReasoningConversion == nil {
|
|
return normalizeIntent(intent)
|
|
}
|
|
pivot := Intent{
|
|
Mode: Mode(req.ReasoningConversion.Mode),
|
|
Effort: Effort(req.ReasoningConversion.Effort),
|
|
BudgetTokens: req.ReasoningConversion.BudgetTokens,
|
|
IncludeThoughts: req.ReasoningConversion.IncludeThoughts,
|
|
Source: SourcePivot,
|
|
BudgetSource: SourcePivot,
|
|
}
|
|
if req.Reasoning != nil && req.Reasoning.Effort != "" {
|
|
pivotEffort := EffectiveEffort(pivot)
|
|
if Effort(req.Reasoning.Effort) == pivotEffort {
|
|
intent.Effort = ""
|
|
intent.Mode = ModeUnset
|
|
}
|
|
}
|
|
return MergeExplicit(intent, pivot, req.Model)
|
|
}
|
|
|
|
// 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, nil
|
|
}
|
|
var intent Intent
|
|
var diagnostics []types.ConversionDiagnostic
|
|
intent.Source = SourceNative
|
|
if req.Thinking != nil {
|
|
switch req.Thinking.Type {
|
|
case "", "enabled":
|
|
intent.Mode = ModeEnabled
|
|
case "adaptive":
|
|
intent.Mode = ModeAdaptive
|
|
case "disabled":
|
|
intent.Mode = ModeDisabled
|
|
intent.Effort = EffortNone
|
|
default:
|
|
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 {
|
|
intent.BudgetSource = SourceNative
|
|
}
|
|
switch req.Thinking.Display {
|
|
case "summarized":
|
|
include := true
|
|
intent.IncludeThoughts = &include
|
|
case "omitted":
|
|
include := false
|
|
intent.IncludeThoughts = &include
|
|
}
|
|
}
|
|
if len(req.OutputConfig) > 0 {
|
|
var output dto.OutputConfigForEffort
|
|
if err := kitutil.Unmarshal(req.OutputConfig, &output); err != nil {
|
|
return Intent{}, nil, fmt.Errorf("invalid Claude output_config: %w", err)
|
|
}
|
|
if output.Effort != "" {
|
|
effort, err := ParseEffort(output.Effort)
|
|
if err != nil {
|
|
return Intent{}, nil, err
|
|
}
|
|
intent.Effort = effort
|
|
}
|
|
}
|
|
if intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone {
|
|
return intent, diagnostics, nil
|
|
}
|
|
normalized, normalizeDiagnostics, err := normalizeIntent(intent)
|
|
if err != nil {
|
|
return Intent{}, nil, err
|
|
}
|
|
return normalized, append(diagnostics, normalizeDiagnostics...), nil
|
|
}
|
|
|
|
// 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, nil
|
|
}
|
|
config := req.GenerationConfig.ThinkingConfig
|
|
if config.ThinkingBudget != nil && config.ThinkingLevel != "" {
|
|
return Intent{}, nil, fmt.Errorf("%w: Gemini thinkingBudget and thinkingLevel cannot both be set", ErrEffortConflict)
|
|
}
|
|
intent := Intent{
|
|
BudgetTokens: config.ThinkingBudget,
|
|
IncludeThoughts: config.IncludeThoughts,
|
|
Source: SourceNative,
|
|
BudgetSource: SourceNative,
|
|
}
|
|
if config.ThinkingLevel != "" {
|
|
effort, err := ParseEffort(config.ThinkingLevel)
|
|
if err != nil {
|
|
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)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if intent.Mode == ModeDisabled {
|
|
return EffortNone
|
|
}
|
|
if intent.Effort != "" {
|
|
return intent.Effort
|
|
}
|
|
if intent.BudgetTokens != nil {
|
|
return EffortFromBudget(*intent.BudgetTokens)
|
|
}
|
|
if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
|
|
return EffortHigh
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func EffortFromBudget(budget int) Effort {
|
|
if budget == 0 {
|
|
return EffortNone
|
|
}
|
|
if budget < 0 {
|
|
return EffortHigh
|
|
}
|
|
if budget <= 1024 {
|
|
return EffortLow
|
|
}
|
|
if budget <= 8192 {
|
|
return EffortMedium
|
|
}
|
|
return EffortHigh
|
|
}
|