From 630e040ce6c9713e81746c8d2ed7e5add103d6db Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 00:27:08 +0700
Subject: [PATCH 01/41] Define model-aware reasoning capabilities
Co-authored-by: Cursor
---
internal/llm/reasoning.go | 107 +++++++++++++++++++++++++
internal/llm/reasoning_catalog.go | 82 +++++++++++++++++++
internal/llm/reasoning_test.go | 129 ++++++++++++++++++++++++++++++
internal/llm/types.go | 54 +++++++------
4 files changed, 346 insertions(+), 26 deletions(-)
create mode 100644 internal/llm/reasoning.go
create mode 100644 internal/llm/reasoning_catalog.go
create mode 100644 internal/llm/reasoning_test.go
diff --git a/internal/llm/reasoning.go b/internal/llm/reasoning.go
new file mode 100644
index 0000000..e71a220
--- /dev/null
+++ b/internal/llm/reasoning.go
@@ -0,0 +1,107 @@
+package llm
+
+import (
+ "fmt"
+)
+
+// ReasoningValueKind describes special behavior associated with a reasoning
+// effort value.
+type ReasoningValueKind string
+
+const ReasoningValueDisable ReasoningValueKind = "disable"
+
+// ReasoningValue is one provider-defined reasoning effort. Value is opaque:
+// callers must retain its spelling and case when sending it to a provider.
+type ReasoningValue struct {
+ Value string `json:"value"`
+ Label string `json:"label"`
+ Kind ReasoningValueKind `json:"kind,omitempty"`
+}
+
+// ReasoningCapabilitySource identifies how the capability was obtained.
+type ReasoningCapabilitySource string
+
+const (
+ ReasoningCapabilityLive ReasoningCapabilitySource = "live"
+ ReasoningCapabilityStatic ReasoningCapabilitySource = "static"
+)
+
+// ReasoningCapability describes the effort values supported by one model.
+type ReasoningCapability struct {
+ Values []ReasoningValue `json:"values"`
+ Default string `json:"default,omitempty"`
+ Mandatory bool `json:"mandatory"`
+ CanDisable bool `json:"can_disable"`
+ Source ReasoningCapabilitySource `json:"source"`
+}
+
+// NewReasoningCapability constructs a valid immutable-by-convention capability.
+func NewReasoningCapability(values []ReasoningValue, defaultValue string, mandatory bool, source ReasoningCapabilitySource) (*ReasoningCapability, error) {
+ allowed := make(map[string]struct{}, len(values))
+ disableCount := 0
+ for _, value := range values {
+ if value.Value == "" {
+ return nil, fmt.Errorf("reasoning capability contains an empty value")
+ }
+ if _, exists := allowed[value.Value]; exists {
+ return nil, fmt.Errorf("reasoning capability contains duplicate value %q", value.Value)
+ }
+ allowed[value.Value] = struct{}{}
+ switch value.Kind {
+ case "":
+ case ReasoningValueDisable:
+ disableCount++
+ default:
+ return nil, fmt.Errorf("reasoning capability value %q has unknown kind %q", value.Value, value.Kind)
+ }
+ }
+ if disableCount > 1 {
+ return nil, fmt.Errorf("reasoning capability contains multiple disable values")
+ }
+ if mandatory && disableCount != 0 {
+ return nil, fmt.Errorf("mandatory reasoning capability cannot include a disable value")
+ }
+ if defaultValue == "" {
+ return nil, fmt.Errorf("reasoning capability default is required")
+ }
+ if _, ok := allowed[defaultValue]; !ok {
+ return nil, fmt.Errorf("reasoning capability default %q is not allowed", defaultValue)
+ }
+
+ return &ReasoningCapability{
+ Values: append([]ReasoningValue(nil), values...),
+ Default: defaultValue,
+ Mandatory: mandatory,
+ CanDisable: disableCount == 1,
+ Source: source,
+ }, nil
+}
+
+// UnsupportedReasoningEffortError reports an override not advertised by a model.
+type UnsupportedReasoningEffortError struct {
+ Model string
+ Effort string
+ Allowed []string
+}
+
+func (e *UnsupportedReasoningEffortError) Error() string {
+ return fmt.Sprintf("reasoning effort %q is not supported by model %q (allowed: %v)", e.Effort, e.Model, e.Allowed)
+}
+
+// ValidateReasoningEffort accepts Auto (an empty effort) for every model and
+// otherwise requires an exact, advertised opaque value.
+func ValidateReasoningEffort(model string, capability *ReasoningCapability, effort string) error {
+ if effort == "" {
+ return nil
+ }
+ allowed := make([]string, 0)
+ if capability != nil {
+ for _, value := range capability.Values {
+ allowed = append(allowed, value.Value)
+ if effort == value.Value {
+ return nil
+ }
+ }
+ }
+ return &UnsupportedReasoningEffortError{Model: model, Effort: effort, Allowed: allowed}
+}
diff --git a/internal/llm/reasoning_catalog.go b/internal/llm/reasoning_catalog.go
new file mode 100644
index 0000000..abee854
--- /dev/null
+++ b/internal/llm/reasoning_catalog.go
@@ -0,0 +1,82 @@
+package llm
+
+import (
+ "strings"
+ "time"
+)
+
+type reasoningCapabilityEntry struct {
+ model string
+ values []ReasoningValue
+}
+
+var openAIReasoningCapabilities = []reasoningCapabilityEntry{
+ // https://platform.openai.com/docs/guides/reasoning
+ {model: "gpt-5", values: reasoningValuesFor("minimal", "low", "medium", "high")},
+}
+
+var codexReasoningCapabilities = []reasoningCapabilityEntry{
+ // https://platform.openai.com/docs/models/gpt-5.3-codex
+ {model: "gpt-5.3-codex", values: reasoningValuesFor("low", "medium", "high", "xhigh")},
+}
+
+var anthropicReasoningCapabilities = []reasoningCapabilityEntry{
+ // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking
+ {model: "claude-sonnet-5", values: reasoningValuesFor("low", "medium", "high", "xhigh", "max")},
+}
+
+var geminiReasoningCapabilities = []reasoningCapabilityEntry{
+ // https://ai.google.dev/gemini-api/docs/thinking
+ {model: "gemini-3.6-flash", values: reasoningValuesFor("minimal", "low", "medium", "high")},
+}
+
+func reasoningValuesFor(values ...string) []ReasoningValue {
+ out := make([]ReasoningValue, 0, len(values))
+ for _, value := range values {
+ out = append(out, ReasoningValue{Value: value, Label: strings.ToUpper(value)})
+ }
+ return out
+}
+
+// StaticReasoningCapability resolves documented model capabilities when a
+// provider cannot supply live metadata. It deliberately recognises only direct
+// vendor adapters and explicit model families; OpenAI-compatible endpoints
+// remain Auto-only because their upstream capabilities are unknowable.
+func StaticReasoningCapability(kind, provider, baseURL, model string) *ReasoningCapability {
+ var entries []reasoningCapabilityEntry
+ switch {
+ case kind == "openai" && provider == "openai" && baseURL == "https://api.openai.com/v1":
+ entries = openAIReasoningCapabilities
+ case kind == "codex" && provider == "openai" && baseURL == "https://api.openai.com/v1":
+ entries = codexReasoningCapabilities
+ case kind == "anthropic" && provider == "anthropic" && baseURL == "https://api.anthropic.com":
+ entries = anthropicReasoningCapabilities
+ case kind == "gemini" && provider == "google" && baseURL == "https://generativelanguage.googleapis.com/v1beta":
+ entries = geminiReasoningCapabilities
+ default:
+ return nil
+ }
+
+ for _, entry := range entries {
+ if exactModelOrDatedSnapshot(entry.model, model) {
+ capability, err := NewReasoningCapability(entry.values, entry.values[0].Value, false, ReasoningCapabilityStatic)
+ if err != nil {
+ panic(err)
+ }
+ return capability
+ }
+ }
+ return nil
+}
+
+func exactModelOrDatedSnapshot(family, model string) bool {
+ if model == family {
+ return true
+ }
+ snapshot, ok := strings.CutPrefix(model, family+"-")
+ if !ok {
+ return false
+ }
+ _, err := time.Parse("2006-01-02", snapshot)
+ return err == nil
+}
diff --git a/internal/llm/reasoning_test.go b/internal/llm/reasoning_test.go
new file mode 100644
index 0000000..2722007
--- /dev/null
+++ b/internal/llm/reasoning_test.go
@@ -0,0 +1,129 @@
+package llm
+
+import (
+ "errors"
+ "slices"
+ "strings"
+ "testing"
+)
+
+func TestReasoningCapabilityRejectsInconsistentDisableMetadata(t *testing.T) {
+ _, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "none", Label: "Off", Kind: ReasoningValueDisable}},
+ "", true, ReasoningCapabilityStatic,
+ )
+ if err == nil || !strings.Contains(err.Error(), "mandatory") {
+ t.Fatalf("err = %v, want mandatory/disable conflict", err)
+ }
+}
+
+func TestValidateReasoningEffortPreservesOpaqueValues(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{
+ {Value: "extra-high", Label: "Extra High"},
+ {Value: "xhigh", Label: "Extra High (new)"},
+ },
+ "extra-high", false, ReasoningCapabilityLive,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateReasoningEffort("gpt-example", cap, "extra-high"); err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateReasoningEffort("gpt-example", cap, "EXTRA-HIGH"); err == nil {
+ t.Fatal("case-normalized value was accepted")
+ }
+}
+
+func TestValidateReasoningEffortAcceptsAuto(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "Low"}},
+ "low", false, ReasoningCapabilityStatic,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateReasoningEffort("gpt-example", cap, ""); err != nil {
+ t.Fatalf("err = %v, want Auto accepted", err)
+ }
+}
+
+func TestValidateReasoningEffortRejectsUnknownOverride(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "Low"}},
+ "low", false, ReasoningCapabilityStatic,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = ValidateReasoningEffort("gpt-example", cap, "high")
+ var unsupported *UnsupportedReasoningEffortError
+ if !errors.As(err, &unsupported) {
+ t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err)
+ }
+ if unsupported.Model != "gpt-example" || unsupported.Effort != "high" || !slices.Equal(unsupported.Allowed, []string{"low"}) {
+ t.Fatalf("error = %#v", unsupported)
+ }
+}
+
+func TestReasoningCapabilityRequiresUniqueValues(t *testing.T) {
+ _, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "low", Label: "Low again"}},
+ "low", false, ReasoningCapabilityStatic,
+ )
+ if err == nil || !strings.Contains(err.Error(), "duplicate") {
+ t.Fatalf("err = %v, want duplicate value error", err)
+ }
+}
+
+func TestReasoningCapabilityDefaultMustBeAllowed(t *testing.T) {
+ _, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "Low"}},
+ "high", false, ReasoningCapabilityStatic,
+ )
+ if err == nil || !strings.Contains(err.Error(), "default") {
+ t.Fatalf("err = %v, want unsupported default error", err)
+ }
+}
+
+func TestStaticReasoningCapabilityRepresentativeFamilies(t *testing.T) {
+ tests := []struct {
+ kind, provider, baseURL, model string
+ want []string
+ disable bool
+ }{
+ {"openai", "openai", "https://api.openai.com/v1", "gpt-5", []string{"minimal", "low", "medium", "high"}, false},
+ {"codex", "openai", "https://api.openai.com/v1", "gpt-5.3-codex", []string{"low", "medium", "high", "xhigh"}, false},
+ {"anthropic", "anthropic", "https://api.anthropic.com", "claude-sonnet-5", []string{"low", "medium", "high", "xhigh", "max"}, false},
+ {"gemini", "google", "https://generativelanguage.googleapis.com/v1beta", "gemini-3.6-flash", []string{"minimal", "low", "medium", "high"}, false},
+ }
+ for _, tt := range tests {
+ cap := StaticReasoningCapability(tt.kind, tt.provider, tt.baseURL, tt.model)
+ if got := reasoningValues(cap); !slices.Equal(got, tt.want) {
+ t.Errorf("%s: got %v, want %v", tt.model, got, tt.want)
+ }
+ if cap == nil {
+ t.Errorf("%s: got nil capability", tt.model)
+ } else if cap.CanDisable != tt.disable {
+ t.Errorf("%s: can_disable=%v", tt.model, cap.CanDisable)
+ }
+ }
+}
+
+func TestStaticReasoningCapabilityDoesNotGuessUnknownCompatibleModels(t *testing.T) {
+ if got := StaticReasoningCapability("openai-compatible", "custom", "https://example.test/v1", "gpt-5"); got != nil {
+ t.Fatalf("got %#v, want Auto-only", got)
+ }
+}
+
+func reasoningValues(cap *ReasoningCapability) []string {
+ if cap == nil {
+ return nil
+ }
+ out := make([]string, 0, len(cap.Values))
+ for _, value := range cap.Values {
+ out = append(out, value.Value)
+ }
+ return out
+}
diff --git a/internal/llm/types.go b/internal/llm/types.go
index f36a426..6522174 100644
--- a/internal/llm/types.go
+++ b/internal/llm/types.go
@@ -68,19 +68,20 @@ type Tool struct {
// Request is a normalised completion request.
type Request struct {
- Model string
- System string
- Messages []Message
- Tools []Tool
- ToolChoice string // auto|none|required|
- Temperature float64
- TopP float64
- MaxTokens int
- StopSequences []string
- ReasoningEffort string // none|low|medium|high
- ParallelToolCalls bool
- PromptCache bool
- Extra map[string]any
+ Model string
+ System string
+ Messages []Message
+ Tools []Tool
+ ToolChoice string // auto|none|required|
+ Temperature float64
+ TopP float64
+ MaxTokens int
+ StopSequences []string
+ ReasoningEffort string // provider-defined opaque value; empty means Auto
+ ReasoningCapability *ReasoningCapability
+ ParallelToolCalls bool
+ PromptCache bool
+ Extra map[string]any
}
// Usage reports token accounting for one call.
@@ -113,9 +114,9 @@ func (u Usage) ContextSize() int {
// Response is the final result of a completion.
type Response struct {
- Content string `json:"content"`
- Reasoning string `json:"reasoning,omitempty"`
- ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ Content string `json:"content"`
+ Reasoning string `json:"reasoning,omitempty"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
// ThoughtSignature is Gemini part-level metadata for the final text turn.
// Agent history must copy this onto the assistant Message for multi-turn
// continuity when the model is not making tool calls.
@@ -153,16 +154,17 @@ type Event struct {
// ModelInfo describes one model offered by a provider.
type ModelInfo struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Provider string `json:"provider"`
- ContextWindow int `json:"context_window"`
- MaxOutput int `json:"max_output"`
- InputCost float64 `json:"input_cost"` // USD per 1M tokens
- OutputCost float64 `json:"output_cost"` // USD per 1M tokens
- Vision bool `json:"vision"`
- Tools bool `json:"tools"`
- Reasoning bool `json:"reasoning"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Provider string `json:"provider"`
+ ContextWindow int `json:"context_window"`
+ MaxOutput int `json:"max_output"`
+ InputCost float64 `json:"input_cost"` // USD per 1M tokens
+ OutputCost float64 `json:"output_cost"` // USD per 1M tokens
+ Vision bool `json:"vision"`
+ Tools bool `json:"tools"`
+ Reasoning bool `json:"reasoning"`
+ ReasoningCapability *ReasoningCapability `json:"reasoning_capability,omitempty"`
}
// Client is a provider adapter.
From 4388c43341740490befeda9d90d9eeb53b4d5981 Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 00:31:38 +0700
Subject: [PATCH 02/41] Redact unsupported reasoning overrides
Co-authored-by: Cursor
---
internal/llm/reasoning.go | 5 ++--
internal/llm/reasoning_test.go | 42 +++++++++++++++++++++++++++++++++-
internal/llm/types.go | 10 ++++++++
3 files changed, 53 insertions(+), 4 deletions(-)
diff --git a/internal/llm/reasoning.go b/internal/llm/reasoning.go
index e71a220..add7031 100644
--- a/internal/llm/reasoning.go
+++ b/internal/llm/reasoning.go
@@ -80,12 +80,11 @@ func NewReasoningCapability(values []ReasoningValue, defaultValue string, mandat
// UnsupportedReasoningEffortError reports an override not advertised by a model.
type UnsupportedReasoningEffortError struct {
Model string
- Effort string
Allowed []string
}
func (e *UnsupportedReasoningEffortError) Error() string {
- return fmt.Sprintf("reasoning effort %q is not supported by model %q (allowed: %v)", e.Effort, e.Model, e.Allowed)
+ return fmt.Sprintf("unsupported reasoning override for model %q (allowed: %v)", e.Model, e.Allowed)
}
// ValidateReasoningEffort accepts Auto (an empty effort) for every model and
@@ -103,5 +102,5 @@ func ValidateReasoningEffort(model string, capability *ReasoningCapability, effo
}
}
}
- return &UnsupportedReasoningEffortError{Model: model, Effort: effort, Allowed: allowed}
+ return &UnsupportedReasoningEffortError{Model: model, Allowed: allowed}
}
diff --git a/internal/llm/reasoning_test.go b/internal/llm/reasoning_test.go
index 2722007..67fd76e 100644
--- a/internal/llm/reasoning_test.go
+++ b/internal/llm/reasoning_test.go
@@ -2,6 +2,7 @@ package llm
import (
"errors"
+ "reflect"
"slices"
"strings"
"testing"
@@ -62,11 +63,50 @@ func TestValidateReasoningEffortRejectsUnknownOverride(t *testing.T) {
if !errors.As(err, &unsupported) {
t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err)
}
- if unsupported.Model != "gpt-example" || unsupported.Effort != "high" || !slices.Equal(unsupported.Allowed, []string{"low"}) {
+ if unsupported.Model != "gpt-example" || !slices.Equal(unsupported.Allowed, []string{"low"}) {
t.Fatalf("error = %#v", unsupported)
}
}
+func TestValidateReasoningEffortDoesNotExposeSubmittedOverride(t *testing.T) {
+ const submitted = "reasoning-override-secret"
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "Low"}},
+ "low", false, ReasoningCapabilityStatic,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = ValidateReasoningEffort("gpt-example", cap, submitted)
+ var unsupported *UnsupportedReasoningEffortError
+ if !errors.As(err, &unsupported) {
+ t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err)
+ }
+ if strings.Contains(err.Error(), submitted) {
+ t.Fatalf("error leaks submitted override: %q", err)
+ }
+ if _, found := reflect.TypeOf(*unsupported).FieldByName("Effort"); found {
+ t.Fatal("UnsupportedReasoningEffortError exposes the submitted override")
+ }
+}
+
+func TestModelInfoWithReasoningCapabilityEnablesReasoning(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "Low"}},
+ "low", false, ReasoningCapabilityStatic,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ info := (ModelInfo{ID: "gpt-example"}).WithReasoningCapability(cap)
+ if !info.Reasoning {
+ t.Fatal("Reasoning = false, want true when capability is attached")
+ }
+ if info.ReasoningCapability != cap {
+ t.Fatalf("ReasoningCapability = %#v, want %#v", info.ReasoningCapability, cap)
+ }
+}
+
func TestReasoningCapabilityRequiresUniqueValues(t *testing.T) {
_, err := NewReasoningCapability(
[]ReasoningValue{{Value: "low", Label: "Low"}, {Value: "low", Label: "Low again"}},
diff --git a/internal/llm/types.go b/internal/llm/types.go
index 6522174..c485c95 100644
--- a/internal/llm/types.go
+++ b/internal/llm/types.go
@@ -167,6 +167,16 @@ type ModelInfo struct {
ReasoningCapability *ReasoningCapability `json:"reasoning_capability,omitempty"`
}
+// WithReasoningCapability returns a copy enriched with model-specific
+// reasoning metadata. A capability implies the legacy Reasoning marker.
+func (m ModelInfo) WithReasoningCapability(capability *ReasoningCapability) ModelInfo {
+ m.ReasoningCapability = capability
+ if capability != nil {
+ m.Reasoning = true
+ }
+ return m
+}
+
// Client is a provider adapter.
type Client interface {
// Kind reports the adapter family (openai, anthropic, gemini, ...).
From 690310475c133a1357cbbb6fb317101fae01cd49 Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 00:48:22 +0700
Subject: [PATCH 03/41] Honor model-specific reasoning controls
Co-authored-by: Cursor
---
internal/llm/anthropic.go | 76 +++++++--
internal/llm/codex.go | 7 +-
internal/llm/gemini.go | 23 ++-
internal/llm/gemini_test.go | 83 ++++++++-
internal/llm/openai.go | 32 +++-
internal/llm/reasoning_request.go | 94 +++++++++++
internal/llm/reasoning_request_test.go | 223 +++++++++++++++++++++++++
7 files changed, 510 insertions(+), 28 deletions(-)
create mode 100644 internal/llm/reasoning_request.go
create mode 100644 internal/llm/reasoning_request_test.go
diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go
index f112fde..92e5202 100644
--- a/internal/llm/anthropic.go
+++ b/internal/llm/anthropic.go
@@ -133,6 +133,39 @@ func toAnthropic(req Request) []antMessage {
return out
}
+// anthropicSupportsAdaptiveThinking reports whether model belongs to the
+// adaptive-thinking generation (Claude Sonnet 5 and later dated snapshots),
+// which uses "thinking":{"type":"adaptive"} plus output_config.effort instead
+// of a fixed token budget.
+func anthropicSupportsAdaptiveThinking(model string) bool {
+ return exactModelOrDatedSnapshot("claude-sonnet-5", model)
+}
+
+// anthropicLegacyThinkingBudgets lists pre-adaptive extended-thinking model
+// families and the token budgets published for their effort ladder.
+// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking
+var anthropicLegacyThinkingBudgets = []struct {
+ family string
+ budgets map[string]int
+}{
+ {family: "claude-3-7-sonnet", budgets: map[string]int{"low": 2048, "medium": 8192, "high": 16384}},
+}
+
+// anthropicLegacyBudget resolves the fixed token budget for a catalogued
+// legacy model and effort value. It never guesses a budget for an
+// unrecognised model or an effort value outside that model's documented
+// ladder.
+func anthropicLegacyBudget(model, effort string) (int, bool) {
+ for _, entry := range anthropicLegacyThinkingBudgets {
+ if !exactModelOrDatedSnapshot(entry.family, model) {
+ continue
+ }
+ budget, ok := entry.budgets[effort]
+ return budget, ok
+ }
+ return 0, false
+}
+
func (c *anthropicClient) buildBody(req Request, stream bool) map[string]any {
maxTokens := req.MaxTokens
if maxTokens <= 0 {
@@ -194,21 +227,34 @@ func (c *anthropicClient) buildBody(req Request, stream bool) map[string]any {
}
}
}
- switch strings.ToLower(req.ReasoningEffort) {
- case "low":
- body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 2048}
- case "medium":
- body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 8192}
- case "high":
- body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 16384}
- }
- if _, ok := body["thinking"]; ok {
- // Thinking requires headroom beyond the budget.
- if maxTokens < 16384 {
- body["max_tokens"] = 16384
+ if value, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err == nil && value != "" {
+ capability := resolvedReasoningCapability(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL)
+ switch {
+ case anthropicSupportsAdaptiveThinking(req.Model):
+ if disable := reasoningDisableValue(capability); disable != "" && value == disable {
+ body["thinking"] = map[string]any{"type": "disabled"}
+ } else {
+ body["thinking"] = map[string]any{"type": "adaptive"}
+ body["output_config"] = map[string]any{"effort": value}
+ }
+ default:
+ // Pre-adaptive models only understand fixed token budgets, and
+ // only for the catalogued legacy families; an unrecognised model
+ // gets no thinking override rather than a guessed budget.
+ if budget, ok := anthropicLegacyBudget(req.Model, value); ok {
+ body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
+ }
}
+ }
+ if th, ok := body["thinking"].(map[string]any); ok {
+ // Thinking (adaptive or fixed-budget) is incompatible with
+ // temperature/top_p sampling controls.
delete(body, "top_p")
delete(body, "temperature")
+ if _, hasBudget := th["budget_tokens"]; hasBudget && maxTokens < 16384 {
+ // Fixed-budget thinking requires headroom beyond the budget.
+ body["max_tokens"] = 16384
+ }
}
for k, v := range req.Extra {
body[k] = v
@@ -233,6 +279,9 @@ type antResponse struct {
}
func (c *anthropicClient) Chat(ctx context.Context, req Request) (*Response, error) {
+ if _, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err != nil {
+ return nil, err
+ }
var raw antResponse
if err := c.opts.doJSON(ctx, "POST", c.opts.BaseURL+"/messages", c.buildBody(req, false), c.headers(), &raw); err != nil {
return nil, err
@@ -275,6 +324,9 @@ func (c *anthropicClient) fromResponse(raw *antResponse) (*Response, error) {
}
func (c *anthropicClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) {
+ if _, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err != nil {
+ return nil, err
+ }
httpResp, err := c.opts.doStream(ctx, "POST", c.opts.BaseURL+"/messages", c.buildBody(req, true), c.headers())
if err != nil {
return nil, err
diff --git a/internal/llm/codex.go b/internal/llm/codex.go
index ba07727..9ba317d 100644
--- a/internal/llm/codex.go
+++ b/internal/llm/codex.go
@@ -42,8 +42,8 @@ func (c *codexClient) buildBody(req Request, stream bool) map[string]any {
if req.Temperature > 0 {
body["temperature"] = req.Temperature
}
- if e := strings.ToLower(req.ReasoningEffort); e != "" && e != "none" {
- body["reasoning"] = map[string]any{"effort": e}
+ if value, err := reasoningValue(req, "codex", c.opts.ProviderID, c.opts.BaseURL); err == nil && value != "" {
+ body["reasoning"] = map[string]any{"effort": value}
}
if len(req.Tools) > 0 {
tools := make([]map[string]any, 0, len(req.Tools))
@@ -143,6 +143,9 @@ type responsesReply struct {
}
func (c *codexClient) Chat(ctx context.Context, req Request) (*Response, error) {
+ if _, err := reasoningValue(req, "codex", c.opts.ProviderID, c.opts.BaseURL); err != nil {
+ return nil, err
+ }
var raw responsesReply
if err := c.opts.doJSON(ctx, "POST", c.opts.BaseURL+"/responses", c.buildBody(req, false), c.headers(), &raw); err != nil {
return nil, err
diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go
index 14ccb18..1a27283 100644
--- a/internal/llm/gemini.go
+++ b/internal/llm/gemini.go
@@ -306,8 +306,10 @@ func (c *geminiClient) buildBody(req Request) map[string]any {
if len(req.StopSequences) > 0 {
gen["stopSequences"] = req.StopSequences
}
- if tc := geminiThinkingConfig(req.Model, req.ReasoningEffort); tc != nil {
- gen["thinkingConfig"] = tc
+ if value, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err == nil {
+ if tc := geminiThinkingConfig(req.Model, value); tc != nil {
+ gen["thinkingConfig"] = tc
+ }
}
if len(gen) > 0 {
body["generationConfig"] = gen
@@ -374,6 +376,10 @@ func (c *geminiClient) endpoint(model, method string, stream bool) string {
// Gemini 3 series prefer thinkingLevel (MINIMAL/LOW/MEDIUM/HIGH); 2.5 series
// use thinkingBudget token counts. includeThoughts requests thought summaries
// when the endpoint exposes them (not all reverse proxies return thought text).
+//
+// Minimal is a real, distinct thinking level for Gemini 3 — not an Off
+// synonym. Gemini 3 has no true Off; its static capability never advertises
+// "none", so the "none" case below only guards a stray legacy value.
func geminiThinkingConfig(model, effort string) map[string]any {
e := strings.ToLower(strings.TrimSpace(effort))
if e == "" {
@@ -383,9 +389,14 @@ func geminiThinkingConfig(model, effort string) map[string]any {
switch e {
case "none":
if useLevel {
- return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": false}
+ return nil
}
return map[string]any{"thinkingBudget": 0}
+ case "minimal":
+ if !useLevel {
+ return nil
+ }
+ return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true}
case "low":
if useLevel {
return map[string]any{"thinkingLevel": "LOW", "includeThoughts": true}
@@ -452,6 +463,9 @@ func parseGeminiParts(parts []gemPart) (content, reasoning string, calls []ToolC
}
func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error) {
+ if _, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err != nil {
+ return nil, err
+ }
// Prefer stream collection: some Gemini-compatible reverse proxies aggregate
// non-stream generateContent by keeping only the final STOP chunk, which is
// often empty text after a functionCall chunk. streamGenerateContent preserves
@@ -485,6 +499,9 @@ func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error)
}
func (c *geminiClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) {
+ if _, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err != nil {
+ return nil, err
+ }
httpResp, err := c.opts.doStream(ctx, "POST", c.endpoint(req.Model, "streamGenerateContent", true), c.buildBody(req), c.headers())
if err != nil {
return nil, err
diff --git a/internal/llm/gemini_test.go b/internal/llm/gemini_test.go
index cbd122b..cda0fbc 100644
--- a/internal/llm/gemini_test.go
+++ b/internal/llm/gemini_test.go
@@ -1,7 +1,10 @@
package llm
import (
+ "context"
"encoding/json"
+ "errors"
+ "reflect"
"strings"
"testing"
)
@@ -35,6 +38,64 @@ func TestGeminiThinkingConfigUsesBudgetFor25(t *testing.T) {
}
}
+func TestGemini3MinimalIsNotDisable(t *testing.T) {
+ got := geminiThinkingConfig("gemini-3.6-flash", "minimal")
+ want := map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %#v, want %#v", got, want)
+ }
+}
+
+func TestGeminiThinkingConfigGemini3HasNoTrueOff(t *testing.T) {
+ if got := geminiThinkingConfig("gemini-3.6-flash", "none"); got != nil {
+ t.Fatalf("got %#v, want nil (Gemini 3 has no true Off, only Minimal)", got)
+ }
+}
+
+func TestGeminiLegacyBudgetZeroWhenOffSupported(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{
+ {Value: "none", Label: "Off", Kind: ReasoningValueDisable},
+ {Value: "low", Label: "Low"},
+ {Value: "medium", Label: "Medium"},
+ {Value: "high", Label: "High"},
+ },
+ "medium", false, ReasoningCapabilityStatic,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := &geminiClient{}
+ body := c.buildBody(Request{
+ Model: "gemini-2.5-flash", ReasoningEffort: "none", ReasoningCapability: cap,
+ Messages: []Message{{Role: RoleUser, Content: "hi"}},
+ })
+ gen, _ := body["generationConfig"].(map[string]any)
+ tc, _ := gen["thinkingConfig"].(map[string]any)
+ want := map[string]any{"thinkingBudget": 0}
+ if !reflect.DeepEqual(tc, want) {
+ t.Fatalf("thinkingConfig = %#v, want %#v", tc, want)
+ }
+}
+
+func TestGeminiRejectsUnsupportedOffBeforeRequest(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "high", Label: "High"}},
+ "low", false, ReasoningCapabilityStatic,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := &geminiClient{}
+ _, err = c.Chat(context.Background(), Request{
+ Model: "gemini-2.5-flash", ReasoningEffort: "none", ReasoningCapability: cap,
+ })
+ var unsupported *UnsupportedReasoningEffortError
+ if !errors.As(err, &unsupported) {
+ t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err)
+ }
+}
+
func TestToGeminiPreservesThoughtSignatureOnFunctionCall(t *testing.T) {
req := Request{
Messages: []Message{{
@@ -108,11 +169,19 @@ func TestParseGeminiPartsCapturesSignature(t *testing.T) {
}
func TestBuildBodyGemini3HighThinking(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "high", Label: "High"}},
+ "low", false, ReasoningCapabilityStatic,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
c := &geminiClient{}
body := c.buildBody(Request{
- Model: "gemini-3.6-flash-high",
- ReasoningEffort: "high",
- Messages: []Message{{Role: RoleUser, Content: "hi"}},
+ Model: "gemini-3.6-flash-high",
+ ReasoningEffort: "high",
+ ReasoningCapability: cap,
+ Messages: []Message{{Role: RoleUser, Content: "hi"}},
})
gen, _ := body["generationConfig"].(map[string]any)
tc, _ := gen["thinkingConfig"].(map[string]any)
@@ -124,11 +193,11 @@ func TestBuildBodyGemini3HighThinking(t *testing.T) {
func TestNormalizeGeminiBaseURLMatchesCLI(t *testing.T) {
cases := map[string]string{
- "http://127.0.0.1:8080/antigravity": "http://127.0.0.1:8080/antigravity/v1beta",
- "http://127.0.0.1:8080/antigravity/": "http://127.0.0.1:8080/antigravity/v1beta",
- "http://127.0.0.1:8080/antigravity/v1beta": "http://127.0.0.1:8080/antigravity/v1beta",
+ "http://127.0.0.1:8080/antigravity": "http://127.0.0.1:8080/antigravity/v1beta",
+ "http://127.0.0.1:8080/antigravity/": "http://127.0.0.1:8080/antigravity/v1beta",
+ "http://127.0.0.1:8080/antigravity/v1beta": "http://127.0.0.1:8080/antigravity/v1beta",
"https://generativelanguage.googleapis.com/v1beta": "https://generativelanguage.googleapis.com/v1beta",
- "http://localhost:8080/v1": "http://localhost:8080/v1",
+ "http://localhost:8080/v1": "http://localhost:8080/v1",
}
for in, want := range cases {
if got := normalizeGeminiBaseURL(in); got != want {
diff --git a/internal/llm/openai.go b/internal/llm/openai.go
index 410e329..0819ebd 100644
--- a/internal/llm/openai.go
+++ b/internal/llm/openai.go
@@ -19,6 +19,17 @@ type openAIClient struct {
func (c *openAIClient) Kind() string { return "openai" }
+// reasoningKind reports the static-catalogue key for this vendor. Only the
+// direct OpenAI API has a documented per-model effort ladder; every other
+// vendor (compat, Azure, Copilot, OpenRouter) is Auto-only unless the caller
+// attaches a resolved capability explicitly.
+func (c *openAIClient) reasoningKind() string {
+ if c.vendor == "openai" {
+ return "openai"
+ }
+ return "openai-compatible"
+}
+
func (c *openAIClient) headers() map[string]string {
if c.vendor == "copilot" {
// Copilot needs a freshly-exchanged token plus editor headers.
@@ -189,12 +200,15 @@ func (c *openAIClient) buildBody(req Request, stream bool) map[string]any {
body["parallel_tool_calls"] = false
}
}
- if e := strings.ToLower(req.ReasoningEffort); e != "" && e != "none" {
+ if value, err := reasoningValue(req, c.reasoningKind(), c.opts.ProviderID, c.opts.BaseURL); err == nil && value != "" {
// OpenAI uses reasoning_effort; OpenRouter accepts a reasoning object.
- body["reasoning_effort"] = e
+ // Every validated value is sent as-is, including a marked disable
+ // value: omitting it would leave reasoning enabled at the model's
+ // default instead of honoring the user's explicit Off choice.
if strings.Contains(c.opts.BaseURL, "openrouter.ai") {
- delete(body, "reasoning_effort")
- body["reasoning"] = map[string]any{"effort": e}
+ body["reasoning"] = map[string]any{"effort": value}
+ } else {
+ body["reasoning_effort"] = value
}
}
for k, v := range req.Extra {
@@ -254,6 +268,9 @@ func (u *oaUsage) normalise() Usage {
}
func (c *openAIClient) Chat(ctx context.Context, req Request) (*Response, error) {
+ if _, err := reasoningValue(req, c.reasoningKind(), c.opts.ProviderID, c.opts.BaseURL); err != nil {
+ return nil, err
+ }
var raw oaResponse
if err := c.opts.doJSON(ctx, "POST", c.endpoint("/chat/completions", req.Model), c.buildBody(req, false), c.headers(), &raw); err != nil {
return nil, err
@@ -283,6 +300,9 @@ func (c *openAIClient) Chat(ctx context.Context, req Request) (*Response, error)
}
func (c *openAIClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) {
+ if _, err := reasoningValue(req, c.reasoningKind(), c.opts.ProviderID, c.opts.BaseURL); err != nil {
+ return nil, err
+ }
httpResp, err := c.opts.doStream(ctx, "POST", c.endpoint("/chat/completions", req.Model), c.buildBody(req, true), c.headers())
if err != nil {
return nil, err
@@ -418,6 +438,7 @@ func (c *openAIClient) Models(ctx context.Context) ([]ModelInfo, error) {
Architecture *struct {
InputModalities []string `json:"input_modalities"`
} `json:"architecture"`
+ Reasoning *openRouterReasoningMetadata `json:"reasoning"`
} `json:"data"`
}
if err := c.opts.doJSON(ctx, "GET", c.opts.BaseURL+"/models", nil, c.headers(), &raw); err != nil {
@@ -443,6 +464,9 @@ func (c *openAIClient) Models(ctx context.Context) ([]ModelInfo, error) {
}
}
}
+ if capability := openRouterReasoningCapability(m.Reasoning); capability != nil {
+ info = info.WithReasoningCapability(capability)
+ }
out = append(out, info)
}
return out, nil
diff --git a/internal/llm/reasoning_request.go b/internal/llm/reasoning_request.go
new file mode 100644
index 0000000..594f4e9
--- /dev/null
+++ b/internal/llm/reasoning_request.go
@@ -0,0 +1,94 @@
+package llm
+
+import "strings"
+
+// reasoningValue resolves and validates the effort value to send upstream.
+// Auto (an empty ReasoningEffort) is always valid and produces no override. A
+// non-empty value is validated against the request's attached capability,
+// falling back to the static catalogue when the caller did not attach one.
+// Callers wired through the agent layer normally attach an already-resolved
+// capability; the fallback is defense in depth for direct/test callers.
+func reasoningValue(req Request, kind, providerID, baseURL string) (string, error) {
+ value := req.ReasoningEffort
+ if value == "" {
+ return "", nil
+ }
+ capability := req.ReasoningCapability
+ if capability == nil {
+ capability = StaticReasoningCapability(kind, providerID, baseURL, req.Model)
+ }
+ if err := ValidateReasoningEffort(req.Model, capability, value); err != nil {
+ return "", err
+ }
+ return value, nil
+}
+
+// resolvedReasoningCapability mirrors reasoningValue's capability resolution
+// so adapters can inspect capability metadata (such as the marked disable
+// value) once a value has already been validated.
+func resolvedReasoningCapability(req Request, kind, providerID, baseURL string) *ReasoningCapability {
+ if req.ReasoningCapability != nil {
+ return req.ReasoningCapability
+ }
+ return StaticReasoningCapability(kind, providerID, baseURL, req.Model)
+}
+
+// reasoningDisableValue returns the capability's marked disable value, or ""
+// when the capability cannot disable reasoning.
+func reasoningDisableValue(capability *ReasoningCapability) string {
+ if capability == nil {
+ return ""
+ }
+ for _, v := range capability.Values {
+ if v.Kind == ReasoningValueDisable {
+ return v.Value
+ }
+ }
+ return ""
+}
+
+// openRouterReasoningMetadata is OpenRouter's per-model reasoning metadata
+// returned from GET /models.
+// https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
+type openRouterReasoningMetadata struct {
+ SupportedEfforts []string `json:"supported_efforts"`
+ DefaultEffort string `json:"default_effort"`
+ DefaultEnabled *bool `json:"default_enabled"`
+ Mandatory bool `json:"mandatory"`
+ SupportsMaxTokens bool `json:"supports_max_tokens"`
+}
+
+// openRouterReasoningCapability builds a live capability from OpenRouter's
+// documented reasoning metadata. Only the documented "none" value is marked
+// as a disable choice. Contradictory metadata — a mandatory model that also
+// lists "none", or a shape NewReasoningCapability otherwise rejects — yields
+// no capability rather than being silently repaired.
+func openRouterReasoningCapability(meta *openRouterReasoningMetadata) *ReasoningCapability {
+ if meta == nil || len(meta.SupportedEfforts) == 0 {
+ return nil
+ }
+ if meta.Mandatory {
+ for _, effort := range meta.SupportedEfforts {
+ if effort == "none" {
+ return nil
+ }
+ }
+ }
+ values := make([]ReasoningValue, 0, len(meta.SupportedEfforts))
+ for _, effort := range meta.SupportedEfforts {
+ value := ReasoningValue{Value: effort, Label: strings.ToUpper(effort)}
+ if effort == "none" {
+ value.Kind = ReasoningValueDisable
+ }
+ values = append(values, value)
+ }
+ def := meta.DefaultEffort
+ if def == "" {
+ def = meta.SupportedEfforts[0]
+ }
+ capability, err := NewReasoningCapability(values, def, meta.Mandatory, ReasoningCapabilityLive)
+ if err != nil {
+ return nil
+ }
+ return capability
+}
diff --git a/internal/llm/reasoning_request_test.go b/internal/llm/reasoning_request_test.go
new file mode 100644
index 0000000..631718c
--- /dev/null
+++ b/internal/llm/reasoning_request_test.go
@@ -0,0 +1,223 @@
+package llm
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "sync/atomic"
+ "testing"
+)
+
+func TestOpenRouterReasoningBodySendsExplicitDisable(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{
+ {Value: "none", Label: "Off", Kind: ReasoningValueDisable},
+ {Value: "high", Label: "High"},
+ },
+ "high", false, ReasoningCapabilityLive,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := &openAIClient{opts: Options{BaseURL: "https://openrouter.ai/api/v1"}}
+ body := c.buildBody(Request{
+ Model: "vendor/model", ReasoningEffort: "none", ReasoningCapability: cap,
+ }, false)
+ reasoning, ok := body["reasoning"].(map[string]any)
+ if !ok || reasoning["effort"] != "none" {
+ t.Fatalf("reasoning = %#v", body["reasoning"])
+ }
+ if _, ok := body["reasoning_effort"]; ok {
+ t.Fatalf("unexpected reasoning_effort alongside reasoning: %#v", body["reasoning_effort"])
+ }
+}
+
+func TestReasoningAutoOmitsProviderFields(t *testing.T) {
+ req := Request{Model: "gpt-5", ReasoningEffort: ""}
+ if body := (&openAIClient{}).buildBody(req, false); body["reasoning_effort"] != nil {
+ t.Fatalf("OpenAI body = %#v", body)
+ }
+ if body := (&codexClient{}).buildBody(req, false); body["reasoning"] != nil {
+ t.Fatalf("Codex body = %#v", body)
+ }
+}
+
+func TestOpenAIReasoningEffortBody(t *testing.T) {
+ cap := StaticReasoningCapability("openai", "openai", "https://api.openai.com/v1", "gpt-5")
+ if cap == nil {
+ t.Fatal("expected a static capability for gpt-5")
+ }
+ c := &openAIClient{opts: Options{BaseURL: "https://api.openai.com/v1", ProviderID: "openai"}, vendor: "openai"}
+ body := c.buildBody(Request{Model: "gpt-5", ReasoningEffort: "high", ReasoningCapability: cap}, false)
+ if body["reasoning_effort"] != "high" {
+ t.Fatalf("reasoning_effort = %#v", body["reasoning_effort"])
+ }
+ if _, ok := body["reasoning"]; ok {
+ t.Fatalf("unexpected reasoning field: %#v", body["reasoning"])
+ }
+}
+
+func TestOpenAIReasoningEffortFallsBackToStaticCapability(t *testing.T) {
+ c := &openAIClient{opts: Options{BaseURL: "https://api.openai.com/v1", ProviderID: "openai"}, vendor: "openai"}
+ body := c.buildBody(Request{Model: "gpt-5", ReasoningEffort: "minimal"}, false)
+ if body["reasoning_effort"] != "minimal" {
+ t.Fatalf("reasoning_effort = %#v, want fallback to static capability to validate it", body["reasoning_effort"])
+ }
+}
+
+func TestCodexReasoningEffortBody(t *testing.T) {
+ c := &codexClient{opts: Options{BaseURL: "https://api.openai.com/v1", ProviderID: "openai"}}
+ body := c.buildBody(Request{Model: "gpt-5.3-codex", ReasoningEffort: "xhigh"}, false)
+ want := map[string]any{"effort": "xhigh"}
+ if got := body["reasoning"]; !reflect.DeepEqual(got, want) {
+ t.Fatalf("reasoning = %#v, want %#v", got, want)
+ }
+}
+
+func TestAnthropicAdaptiveThinkingBody(t *testing.T) {
+ // The upstream base URL is corrected to the exact static-catalogue key
+ // (https://api.anthropic.com) so this capability actually resolves;
+ // see task-2-report.md for why the brief's literal "" argument is a
+ // vacuous match against the Task 1 catalogue.
+ cap := StaticReasoningCapability("anthropic", "anthropic", "https://api.anthropic.com", "claude-sonnet-5")
+ if cap == nil {
+ t.Fatal("expected a static capability for claude-sonnet-5")
+ }
+ body := (&anthropicClient{}).buildBody(Request{
+ Model: "claude-sonnet-5", ReasoningEffort: "xhigh", ReasoningCapability: cap,
+ }, false)
+ if got, want := body["thinking"], map[string]any{"type": "adaptive"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("thinking = %#v, want %#v", got, want)
+ }
+ if got, want := body["output_config"], map[string]any{"effort": "xhigh"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("output_config = %#v, want %#v", got, want)
+ }
+}
+
+func TestAnthropicLegacyThinkingBudgetBody(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "medium", Label: "Medium"}, {Value: "high", Label: "High"}},
+ "low", false, ReasoningCapabilityStatic,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := (&anthropicClient{}).buildBody(Request{
+ Model: "claude-3-7-sonnet", ReasoningEffort: "medium", ReasoningCapability: cap,
+ }, false)
+ if got, want := body["thinking"], map[string]any{"type": "enabled", "budget_tokens": 8192}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("thinking = %#v, want %#v", got, want)
+ }
+ if _, ok := body["output_config"]; ok {
+ t.Fatalf("unexpected output_config on a legacy model: %#v", body["output_config"])
+ }
+}
+
+func TestAnthropicAdaptiveDisableBody(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{
+ {Value: "off", Label: "Off", Kind: ReasoningValueDisable},
+ {Value: "low", Label: "Low"},
+ },
+ "low", false, ReasoningCapabilityLive,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := (&anthropicClient{}).buildBody(Request{
+ Model: "claude-sonnet-5", ReasoningEffort: "off", ReasoningCapability: cap,
+ }, false)
+ if got, want := body["thinking"], map[string]any{"type": "disabled"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("thinking = %#v, want %#v", got, want)
+ }
+ if _, ok := body["output_config"]; ok {
+ t.Fatalf("unexpected output_config for a disabled turn: %#v", body["output_config"])
+ }
+}
+
+func TestReasoningValidationBlocksRequestBeforeNetworkIO(t *testing.T) {
+ var count int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&count, 1)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ cap, err := NewReasoningCapability([]ReasoningValue{{Value: "low", Label: "Low"}}, "low", false, ReasoningCapabilityStatic)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := Request{Model: "test-model", ReasoningEffort: "max", ReasoningCapability: cap}
+
+ clients := map[string]Client{
+ "openai": &openAIClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}, vendor: "openai"},
+ "codex": &codexClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}},
+ "anthropic": &anthropicClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}},
+ "gemini": &geminiClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}},
+ }
+ for name, c := range clients {
+ _, err := c.Chat(context.Background(), req)
+ var unsupported *UnsupportedReasoningEffortError
+ if !errors.As(err, &unsupported) {
+ t.Fatalf("%s: err = %v, want UnsupportedReasoningEffortError", name, err)
+ }
+ }
+ if got := atomic.LoadInt32(&count); got != 0 {
+ t.Fatalf("requests sent = %d, want 0", got)
+ }
+}
+
+func TestOpenRouterModelsBuildsLiveReasoningCapability(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"data":[{"id":"vendor/model","reasoning":{"supported_efforts":["none","low","high"],"default_effort":"high","mandatory":false,"supports_max_tokens":false}}]}`))
+ }))
+ defer srv.Close()
+ c := &openAIClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}}
+ models, err := c.Models(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(models) != 1 {
+ t.Fatalf("models = %+v", models)
+ }
+ cap := models[0].ReasoningCapability
+ if cap == nil {
+ t.Fatal("expected a live reasoning capability")
+ }
+ if cap.Source != ReasoningCapabilityLive || cap.Default != "high" {
+ t.Fatalf("capability = %#v", cap)
+ }
+ if !models[0].Reasoning {
+ t.Fatal("expected legacy Reasoning boolean to be set")
+ }
+ var disableFound bool
+ for _, v := range cap.Values {
+ if v.Value == "none" {
+ disableFound = v.Kind == ReasoningValueDisable
+ }
+ }
+ if !disableFound {
+ t.Fatal(`expected "none" marked as disable`)
+ }
+}
+
+func TestOpenRouterModelsRejectsContradictoryReasoningMetadata(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"data":[{"id":"vendor/model","reasoning":{"supported_efforts":["none","low"],"default_effort":"low","mandatory":true}}]}`))
+ }))
+ defer srv.Close()
+ c := &openAIClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}}
+ models, err := c.Models(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(models) != 1 {
+ t.Fatalf("models = %+v", models)
+ }
+ if got := models[0].ReasoningCapability; got != nil {
+ t.Fatalf("expected no capability for contradictory metadata, got %#v", got)
+ }
+}
From 0bf0314031d9a2c55acf8c2a5d9932d47c37faaa Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 01:10:49 +0700
Subject: [PATCH 04/41] Fix pre-request reasoning validation gaps found in Task
2 review
Three blocking issues from review, fixed without weakening validation
for newly-explicit invalid values:
1. Default OpenRouter/direct-Anthropic chat failed pre-request. The
config default carried a non-empty "medium" reasoning effort with no
attached capability, and openai-compatible's static resolution is
deliberately nil, so every default chat errored before the network.
Direct Anthropic also failed because the runtime default base URL
("https://api.anthropic.com/v1") didn't match the static catalog's
bare-host check. Fixed by defaulting reasoning effort to "" (Auto,
valid everywhere) and widening the catalog's Anthropic base URL
match to an exact two-value allowlist.
2. Bedrock bypassed reasoning validation by calling anthropic buildBody
directly, silently dropping invalid explicit values and sending
signed requests upstream. bedrockClient.Chat now runs the same
validateReasoning gate before signing/sending.
3. A legacy Anthropic model could pass attached-capability validation
for a value absent from the hardcoded budget table, silently
vanishing from the request. Factored the body-shape decision into
anthropicThinkingBody, shared by buildBody and a new
validateReasoning check so an unmappable value now fails before
any request.
Co-authored-by: Cursor
---
internal/agent/reasoning_defaults_test.go | 85 +++++++++++++++++++++++
internal/config/defaults.go | 21 +++---
internal/config/defaults_test.go | 20 ++++++
internal/llm/anthropic.go | 75 +++++++++++++++-----
internal/llm/bedrock.go | 8 +++
internal/llm/bedrock_test.go | 41 +++++++++++
internal/llm/reasoning_catalog.go | 11 ++-
internal/llm/reasoning_request_test.go | 46 ++++++++++++
internal/llm/reasoning_test.go | 12 ++++
9 files changed, 294 insertions(+), 25 deletions(-)
create mode 100644 internal/agent/reasoning_defaults_test.go
create mode 100644 internal/config/defaults_test.go
diff --git a/internal/agent/reasoning_defaults_test.go b/internal/agent/reasoning_defaults_test.go
new file mode 100644
index 0000000..537b72c
--- /dev/null
+++ b/internal/agent/reasoning_defaults_test.go
@@ -0,0 +1,85 @@
+package agent
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/enowdev/antares/internal/config"
+ "github.com/enowdev/antares/internal/llm"
+)
+
+// TestDefaultConfigReasoningEffortReachesOpenRouterCompatibleProvider guards
+// the exact regression a review found in Task 2's provider-adapter validation:
+// with the old "medium" default and no attached llm.ReasoningCapability, every
+// default chat through an openai-compatible provider (OpenRouter's kind) was
+// rejected before any network call, because the static catalog deliberately
+// returns nil for unknown compatible endpoints. It reproduces the precedence
+// agent.go's Run loop uses (firstNonEmpty(explicit, agent, model)) with the
+// real default config against a real openAIClient.
+func TestDefaultConfigReasoningEffortReachesOpenRouterCompatibleProvider(t *testing.T) {
+ cfg := config.Default()
+ effort := firstNonEmpty("", cfg.Agent.ReasoningEffort, cfg.Model.ReasoningEffort)
+
+ var hits int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&hits, 1)
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
+ }))
+ defer srv.Close()
+
+ client, err := llm.New(llm.Options{Kind: "openai-compatible", BaseURL: srv.URL, HTTPClient: srv.Client(), ProviderID: "openrouter"})
+ if err != nil {
+ t.Fatalf("llm.New: %v", err)
+ }
+
+ a := &Agent{}
+ _, err = a.callModel(context.Background(), client, llm.Request{
+ Model: "vendor/model",
+ Messages: []llm.Message{{Role: llm.RoleUser, Content: "hi"}},
+ ReasoningEffort: effort,
+ }, false, func(Event) error { return nil })
+ if err != nil {
+ t.Fatalf("default config reasoning effort blocked an OpenRouter-shaped chat before it reached the network: %v", err)
+ }
+ if got := atomic.LoadInt32(&hits); got != 1 {
+ t.Fatalf("requests reaching the provider = %d, want 1", got)
+ }
+}
+
+// TestDefaultConfigReasoningEffortReachesDirectAnthropicProvider is the direct
+// Anthropic half of the same finding: even a model the static catalog knows
+// (claude-sonnet-5) was previously rejected pre-request because the runtime
+// default base URL ("https://api.anthropic.com/v1") didn't match the
+// catalog's bare-host check.
+func TestDefaultConfigReasoningEffortReachesDirectAnthropicProvider(t *testing.T) {
+ cfg := config.Default()
+ effort := firstNonEmpty("", cfg.Agent.ReasoningEffort, cfg.Model.ReasoningEffort)
+
+ var hits int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&hits, 1)
+ _, _ = w.Write([]byte(`{"content":[{"type":"text","text":"ok"}]}`))
+ }))
+ defer srv.Close()
+
+ client, err := llm.New(llm.Options{Kind: "anthropic", BaseURL: srv.URL, HTTPClient: srv.Client(), ProviderID: "anthropic"})
+ if err != nil {
+ t.Fatalf("llm.New: %v", err)
+ }
+
+ a := &Agent{}
+ _, err = a.callModel(context.Background(), client, llm.Request{
+ Model: "claude-sonnet-5",
+ Messages: []llm.Message{{Role: llm.RoleUser, Content: "hi"}},
+ ReasoningEffort: effort,
+ }, false, func(Event) error { return nil })
+ if err != nil {
+ t.Fatalf("default config reasoning effort blocked a direct-Anthropic chat before it reached the network: %v", err)
+ }
+ if got := atomic.LoadInt32(&hits); got != 1 {
+ t.Fatalf("requests reaching the provider = %d, want 1", got)
+ }
+}
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index a4c6f34..633aa74 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -7,13 +7,18 @@ import "path/filepath"
func Default() *Config {
return &Config{
Model: Model{
- Default: "anthropic/claude-sonnet-4.5",
- Provider: "openrouter",
- Temperature: 0.7,
- TopP: 1.0,
- MaxTokens: 8192,
- ContextWindow: 200000,
- ReasoningEffort: "medium",
+ Default: "anthropic/claude-sonnet-4.5",
+ Provider: "openrouter",
+ Temperature: 0.7,
+ TopP: 1.0,
+ MaxTokens: 8192,
+ ContextWindow: 200000,
+ // Auto ("") by default: reasoning effort is a provider/model-specific
+ // opaque value now, and OpenRouter can route to thousands of backing
+ // models with different (or no) reasoning ladders. A non-empty
+ // default here would fail pre-request validation for any model
+ // that doesn't advertise it.
+ ReasoningEffort: "",
ParallelToolCall: true,
},
Providers: map[string]Provider{
@@ -60,7 +65,7 @@ func Default() *Config {
CORSOrigins: []string{},
},
Agent: Agent{
- MaxTurns: 200, MaxToolCalls: 32, ReasoningEffort: "medium",
+ MaxTurns: 200, MaxToolCalls: 32, ReasoningEffort: "",
Personality: "default", Workspace: "~/antares-workspace",
Timezone: "Local", Language: "auto", IdleTimeoutSecs: 900,
RepeatLimit: 3, VerifyReplies: false, VerifyMax: 2, GoalMaxIterations: 10,
diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go
new file mode 100644
index 0000000..165605c
--- /dev/null
+++ b/internal/config/defaults_test.go
@@ -0,0 +1,20 @@
+package config
+
+import "testing"
+
+// TestDefaultReasoningEffortIsAuto guards against a regression where the
+// fresh-install default carried a non-empty reasoning effort ("medium").
+// Reasoning effort is now a provider/model-specific opaque value validated
+// pre-request; OpenRouter alone can route to thousands of backing models
+// with unknown or absent reasoning ladders, so any non-empty default here
+// would fail validation before every default chat reached the network.
+// Auto ("") is the only value guaranteed to be valid everywhere.
+func TestDefaultReasoningEffortIsAuto(t *testing.T) {
+ cfg := Default()
+ if cfg.Agent.ReasoningEffort != "" {
+ t.Fatalf("Agent.ReasoningEffort = %q, want empty (Auto)", cfg.Agent.ReasoningEffort)
+ }
+ if cfg.Model.ReasoningEffort != "" {
+ t.Fatalf("Model.ReasoningEffort = %q, want empty (Auto)", cfg.Model.ReasoningEffort)
+ }
+}
diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go
index 92e5202..c4a4dcd 100644
--- a/internal/llm/anthropic.go
+++ b/internal/llm/anthropic.go
@@ -166,6 +166,55 @@ func anthropicLegacyBudget(model, effort string) (int, bool) {
return 0, false
}
+// anthropicThinkingBody resolves the "thinking"/"output_config" fields for a
+// validated, non-empty reasoning effort. ok is false when the model cannot
+// actually honor this specific value: either it is a pre-adaptive model whose
+// hardcoded budget table has no entry for effort (e.g. an attached capability
+// advertised a value this local catalogue does not know how to map). Callers
+// must treat ok == false as a hard failure rather than silently sending the
+// request without the override — see anthropicClient.validateReasoning.
+func anthropicThinkingBody(model, effort string, capability *ReasoningCapability) (thinking map[string]any, outputConfig map[string]any, ok bool) {
+ if anthropicSupportsAdaptiveThinking(model) {
+ if disable := reasoningDisableValue(capability); disable != "" && effort == disable {
+ return map[string]any{"type": "disabled"}, nil, true
+ }
+ return map[string]any{"type": "adaptive"}, map[string]any{"effort": effort}, true
+ }
+ // Pre-adaptive models only understand fixed token budgets, and only for
+ // the catalogued legacy families and their documented effort ladders.
+ if budget, ok := anthropicLegacyBudget(model, effort); ok {
+ return map[string]any{"type": "enabled", "budget_tokens": budget}, nil, true
+ }
+ return nil, nil, false
+}
+
+// validateReasoning fails before any network call when the request's
+// reasoning effort cannot be honored: either the value itself is not
+// advertised by the model's capability (delegated to reasoningValue), or —
+// for pre-adaptive Anthropic models — the value is validated by an attached
+// capability but has no entry in the local fixed-budget table, which would
+// otherwise silently vanish from the outgoing request.
+func (c *anthropicClient) validateReasoning(req Request) error {
+ value, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL)
+ if err != nil {
+ return err
+ }
+ if value == "" {
+ return nil
+ }
+ capability := resolvedReasoningCapability(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL)
+ if _, _, ok := anthropicThinkingBody(req.Model, value, capability); !ok {
+ var allowed []string
+ if capability != nil {
+ for _, v := range capability.Values {
+ allowed = append(allowed, v.Value)
+ }
+ }
+ return &UnsupportedReasoningEffortError{Model: req.Model, Allowed: allowed}
+ }
+ return nil
+}
+
func (c *anthropicClient) buildBody(req Request, stream bool) map[string]any {
maxTokens := req.MaxTokens
if maxTokens <= 0 {
@@ -229,20 +278,14 @@ func (c *anthropicClient) buildBody(req Request, stream bool) map[string]any {
}
if value, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err == nil && value != "" {
capability := resolvedReasoningCapability(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL)
- switch {
- case anthropicSupportsAdaptiveThinking(req.Model):
- if disable := reasoningDisableValue(capability); disable != "" && value == disable {
- body["thinking"] = map[string]any{"type": "disabled"}
- } else {
- body["thinking"] = map[string]any{"type": "adaptive"}
- body["output_config"] = map[string]any{"effort": value}
- }
- default:
- // Pre-adaptive models only understand fixed token budgets, and
- // only for the catalogued legacy families; an unrecognised model
- // gets no thinking override rather than a guessed budget.
- if budget, ok := anthropicLegacyBudget(req.Model, value); ok {
- body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
+ // A value that cannot be mapped (ok == false) is left out of the body
+ // here; callers reach this only via Chat/Stream, which fail the
+ // request first via validateReasoning so that case is unreachable in
+ // practice. buildBody stays a pure, non-erroring body builder.
+ if thinking, outputConfig, ok := anthropicThinkingBody(req.Model, value, capability); ok {
+ body["thinking"] = thinking
+ if outputConfig != nil {
+ body["output_config"] = outputConfig
}
}
}
@@ -279,7 +322,7 @@ type antResponse struct {
}
func (c *anthropicClient) Chat(ctx context.Context, req Request) (*Response, error) {
- if _, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err != nil {
+ if err := c.validateReasoning(req); err != nil {
return nil, err
}
var raw antResponse
@@ -324,7 +367,7 @@ func (c *anthropicClient) fromResponse(raw *antResponse) (*Response, error) {
}
func (c *anthropicClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) {
- if _, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err != nil {
+ if err := c.validateReasoning(req); err != nil {
return nil, err
}
httpResp, err := c.opts.doStream(ctx, "POST", c.opts.BaseURL+"/messages", c.buildBody(req, true), c.headers())
diff --git a/internal/llm/bedrock.go b/internal/llm/bedrock.go
index 683e04e..389f069 100644
--- a/internal/llm/bedrock.go
+++ b/internal/llm/bedrock.go
@@ -71,6 +71,14 @@ func (c *bedrockClient) Chat(ctx context.Context, req Request) (*Response, error
if req.Model == "" {
return nil, errors.New("bedrock needs a model id, e.g. anthropic.claude-3-5-sonnet-20241022-v2:0")
}
+ // Bedrock reuses anthropicClient.buildBody directly rather than going
+ // through anthropicClient.Chat, so it must run the same pre-request
+ // reasoning validation itself. Otherwise an invalid explicit effort is
+ // silently dropped by buildBody and a signed, billable request still goes
+ // upstream.
+ if err := c.inner.validateReasoning(req); err != nil {
+ return nil, err
+ }
payload, err := json.Marshal(c.bedrockBody(req))
if err != nil {
return nil, err
diff --git a/internal/llm/bedrock_test.go b/internal/llm/bedrock_test.go
index cb4c208..ac84f14 100644
--- a/internal/llm/bedrock_test.go
+++ b/internal/llm/bedrock_test.go
@@ -1,9 +1,13 @@
package llm
import (
+ "context"
"encoding/hex"
+ "errors"
"net/http"
+ "net/http/httptest"
"strings"
+ "sync/atomic"
"testing"
"time"
)
@@ -54,3 +58,40 @@ func TestNewBedrockNeedsRegion(t *testing.T) {
t.Fatalf("expected a bedrock client, got %v %v", c, err)
}
}
+
+// TestBedrockReasoningValidationBlocksRequestBeforeSigning guards a bypass:
+// bedrockClient.Chat builds its body via anthropicClient.buildBody directly
+// rather than anthropicClient.Chat, so it must run the same pre-request
+// reasoning validation itself. Without it, an invalid explicit reasoning
+// value was silently dropped by buildBody and a signed, billable request
+// still went upstream.
+func TestBedrockReasoningValidationBlocksRequestBeforeSigning(t *testing.T) {
+ var hits int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&hits, 1)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ cap, err := NewReasoningCapability([]ReasoningValue{{Value: "low", Label: "Low"}}, "low", false, ReasoningCapabilityStatic)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ opts := Options{BaseURL: srv.URL, HTTPClient: srv.Client()}
+ c := &bedrockClient{opts: opts, region: "us-east-1", inner: &anthropicClient{opts: opts}, endpoint: srv.URL}
+
+ _, err = c.Chat(context.Background(), Request{
+ Model: "anthropic.claude-3-5-sonnet-20241022-v2:0",
+ ReasoningEffort: "max",
+ ReasoningCapability: cap,
+ })
+ var unsupported *UnsupportedReasoningEffortError
+ if !errors.As(err, &unsupported) {
+ t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err)
+ }
+ if got := atomic.LoadInt32(&hits); got != 0 {
+ t.Fatalf("requests sent = %d, want 0 (must fail before signing/sending)", got)
+ }
+}
diff --git a/internal/llm/reasoning_catalog.go b/internal/llm/reasoning_catalog.go
index abee854..7c8c8e2 100644
--- a/internal/llm/reasoning_catalog.go
+++ b/internal/llm/reasoning_catalog.go
@@ -49,7 +49,7 @@ func StaticReasoningCapability(kind, provider, baseURL, model string) *Reasoning
entries = openAIReasoningCapabilities
case kind == "codex" && provider == "openai" && baseURL == "https://api.openai.com/v1":
entries = codexReasoningCapabilities
- case kind == "anthropic" && provider == "anthropic" && baseURL == "https://api.anthropic.com":
+ case kind == "anthropic" && provider == "anthropic" && isAnthropicDirectBaseURL(baseURL):
entries = anthropicReasoningCapabilities
case kind == "gemini" && provider == "google" && baseURL == "https://generativelanguage.googleapis.com/v1beta":
entries = geminiReasoningCapabilities
@@ -69,6 +69,15 @@ func StaticReasoningCapability(kind, provider, baseURL, model string) *Reasoning
return nil
}
+// isAnthropicDirectBaseURL recognises both forms of Anthropic's own base URL
+// seen at runtime: the bare host (as documented) and the "/v1" form that this
+// codebase's provider defaults actually configure. Both are exact, canonical
+// Anthropic hosts — this is not a broad prefix match, so a custom or
+// Anthropic-compatible endpoint still falls back to Auto-only.
+func isAnthropicDirectBaseURL(baseURL string) bool {
+ return baseURL == "https://api.anthropic.com" || baseURL == "https://api.anthropic.com/v1"
+}
+
func exactModelOrDatedSnapshot(family, model string) bool {
if model == family {
return true
diff --git a/internal/llm/reasoning_request_test.go b/internal/llm/reasoning_request_test.go
index 631718c..cfa2fa5 100644
--- a/internal/llm/reasoning_request_test.go
+++ b/internal/llm/reasoning_request_test.go
@@ -115,6 +115,52 @@ func TestAnthropicLegacyThinkingBudgetBody(t *testing.T) {
}
}
+// TestAnthropicLegacyUnmappableEffortFailsBeforeRequest guards a silent-drop
+// bug: a value can pass validation against an *attached* capability (e.g. a
+// hypothetical live capability advertising more values than this codebase's
+// hardcoded legacy budget table knows) yet have no entry in
+// anthropicLegacyThinkingBudgets for that model family. buildBody previously
+// just omitted "thinking" in that case, silently downgrading the turn to
+// Auto. Chat/Stream must now fail before any request is sent.
+func TestAnthropicLegacyUnmappableEffortFailsBeforeRequest(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "xhigh", Label: "Extra High"}},
+ "low", false, ReasoningCapabilityStatic,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var hits int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&hits, 1)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ c := &anthropicClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}}
+ _, err = c.Chat(context.Background(), Request{
+ Model: "claude-3-7-sonnet", ReasoningEffort: "xhigh", ReasoningCapability: cap,
+ })
+ var unsupported *UnsupportedReasoningEffortError
+ if !errors.As(err, &unsupported) {
+ t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err)
+ }
+ if got := atomic.LoadInt32(&hits); got != 0 {
+ t.Fatalf("requests sent = %d, want 0", got)
+ }
+
+ // buildBody alone (bypassing Chat) must also stay silent-safe: it must not
+ // fabricate a "thinking" override it cannot actually honor.
+ body := (&anthropicClient{}).buildBody(Request{
+ Model: "claude-3-7-sonnet", ReasoningEffort: "xhigh", ReasoningCapability: cap,
+ }, false)
+ if _, ok := body["thinking"]; ok {
+ t.Fatalf("buildBody emitted a thinking override for an unmappable legacy effort: %#v", body["thinking"])
+ }
+}
+
func TestAnthropicAdaptiveDisableBody(t *testing.T) {
cap, err := NewReasoningCapability(
[]ReasoningValue{
diff --git a/internal/llm/reasoning_test.go b/internal/llm/reasoning_test.go
index 67fd76e..5162826 100644
--- a/internal/llm/reasoning_test.go
+++ b/internal/llm/reasoning_test.go
@@ -151,6 +151,18 @@ func TestStaticReasoningCapabilityRepresentativeFamilies(t *testing.T) {
}
}
+// TestStaticReasoningCapabilityAcceptsRuntimeAnthropicBaseURL guards against a
+// regression where every default-config, direct-Anthropic chat request failed
+// pre-request validation: the provider default base URL configured in
+// internal/config/defaults.go is "https://api.anthropic.com/v1", but the
+// catalog originally matched only the bare "https://api.anthropic.com" host.
+func TestStaticReasoningCapabilityAcceptsRuntimeAnthropicBaseURL(t *testing.T) {
+ cap := StaticReasoningCapability("anthropic", "anthropic", "https://api.anthropic.com/v1", "claude-sonnet-5")
+ if cap == nil {
+ t.Fatal("got nil capability for the runtime default Anthropic base URL (with /v1)")
+ }
+}
+
func TestStaticReasoningCapabilityDoesNotGuessUnknownCompatibleModels(t *testing.T) {
if got := StaticReasoningCapability("openai-compatible", "custom", "https://example.test/v1", "gpt-5"); got != nil {
t.Fatalf("got %#v, want Auto-only", got)
From d8778635508a6e2d1bf58f0643d0abe2eb94d619 Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 01:15:34 +0700
Subject: [PATCH 05/41] Close two correctness gaps in Gemini reasoning mapping
1. Direct Gemini's static catalogue required provider id "google", but
the shipped provider default (internal/config/defaults.go) uses
"gemini" as its provider map key, so the static fallback never fired
for the real default Gemini provider. Widened the match to an exact
two-value allowlist (isGeminiDirectProvider): "google" or "gemini",
still rejecting arbitrary compatible/custom provider ids.
2. geminiThinkingConfig returned nil for both "no override" and "cannot
map this effort", so a value that passed an attached
ReasoningCapability but had no real mapping (unrecognised keyword, or
"minimal" on a legacy budget model) silently vanished from the
request instead of failing. geminiThinkingConfig now returns an
explicit ok bool distinguishing the two; a new
geminiClient.validateReasoning fails Chat/Stream before any network
call when ok is false, while buildBody stays a pure, non-erroring
body builder. Known low/medium/high/minimal/none mappings for both
Gemini 3 (thinkingLevel) and 2.5 (thinkingBudget) models are
unchanged.
Co-authored-by: Cursor
---
internal/llm/gemini.go | 69 +++++++++++++++++-----
internal/llm/gemini_test.go | 97 ++++++++++++++++++++++++++++---
internal/llm/reasoning_catalog.go | 12 +++-
internal/llm/reasoning_test.go | 26 +++++++++
4 files changed, 179 insertions(+), 25 deletions(-)
diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go
index 1a27283..fe7eafe 100644
--- a/internal/llm/gemini.go
+++ b/internal/llm/gemini.go
@@ -307,7 +307,11 @@ func (c *geminiClient) buildBody(req Request) map[string]any {
gen["stopSequences"] = req.StopSequences
}
if value, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err == nil {
- if tc := geminiThinkingConfig(req.Model, value); tc != nil {
+ // An unmappable value (ok == false) is left out of the body here;
+ // callers reach this only via Chat/Stream, which fail the request
+ // first via validateReasoning, so that case is unreachable in
+ // practice. buildBody stays a pure, non-erroring body builder.
+ if tc, ok := geminiThinkingConfig(req.Model, value); ok && tc != nil {
gen["thinkingConfig"] = tc
}
}
@@ -380,41 +384,76 @@ func (c *geminiClient) endpoint(model, method string, stream bool) string {
// Minimal is a real, distinct thinking level for Gemini 3 — not an Off
// synonym. Gemini 3 has no true Off; its static capability never advertises
// "none", so the "none" case below only guards a stray legacy value.
-func geminiThinkingConfig(model, effort string) map[string]any {
+//
+// ok is false when effort cannot actually be honored on model: an
+// unrecognised keyword, or "minimal" requested against a legacy
+// thinkingBudget model that has no such level. config == nil with ok == true
+// is a real, intentional mapping (Auto, or Gemini 3's documented lack of a
+// true Off) — callers must not conflate the two. See
+// geminiClient.validateReasoning, which fails the request before any network
+// call when ok is false rather than silently omitting the override.
+func geminiThinkingConfig(model, effort string) (config map[string]any, ok bool) {
e := strings.ToLower(strings.TrimSpace(effort))
if e == "" {
- return nil
+ return nil, true
}
useLevel := geminiModelUsesThinkingLevel(model)
switch e {
case "none":
if useLevel {
- return nil
+ return nil, true
}
- return map[string]any{"thinkingBudget": 0}
+ return map[string]any{"thinkingBudget": 0}, true
case "minimal":
if !useLevel {
- return nil
+ return nil, false
}
- return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true}
+ return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true}, true
case "low":
if useLevel {
- return map[string]any{"thinkingLevel": "LOW", "includeThoughts": true}
+ return map[string]any{"thinkingLevel": "LOW", "includeThoughts": true}, true
}
- return map[string]any{"thinkingBudget": 2048, "includeThoughts": true}
+ return map[string]any{"thinkingBudget": 2048, "includeThoughts": true}, true
case "medium":
if useLevel {
- return map[string]any{"thinkingLevel": "MEDIUM", "includeThoughts": true}
+ return map[string]any{"thinkingLevel": "MEDIUM", "includeThoughts": true}, true
}
- return map[string]any{"thinkingBudget": 8192, "includeThoughts": true}
+ return map[string]any{"thinkingBudget": 8192, "includeThoughts": true}, true
case "high":
if useLevel {
- return map[string]any{"thinkingLevel": "HIGH", "includeThoughts": true}
+ return map[string]any{"thinkingLevel": "HIGH", "includeThoughts": true}, true
}
- return map[string]any{"thinkingBudget": 24576, "includeThoughts": true}
+ return map[string]any{"thinkingBudget": 24576, "includeThoughts": true}, true
default:
+ return nil, false
+ }
+}
+
+// validateReasoning fails before any network call when the request's
+// reasoning effort cannot be honored: either the value itself is not
+// advertised by the model's capability (delegated to reasoningValue), or the
+// value is validated by an attached capability but geminiThinkingConfig has
+// no mapping for it on this model — which would otherwise silently vanish
+// from the outgoing request (buildBody just omits thinkingConfig).
+func (c *geminiClient) validateReasoning(req Request) error {
+ value, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL)
+ if err != nil {
+ return err
+ }
+ if value == "" {
return nil
}
+ if _, ok := geminiThinkingConfig(req.Model, value); !ok {
+ capability := resolvedReasoningCapability(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL)
+ var allowed []string
+ if capability != nil {
+ for _, v := range capability.Values {
+ allowed = append(allowed, v.Value)
+ }
+ }
+ return &UnsupportedReasoningEffortError{Model: req.Model, Allowed: allowed}
+ }
+ return nil
}
func geminiModelUsesThinkingLevel(model string) bool {
@@ -463,7 +502,7 @@ func parseGeminiParts(parts []gemPart) (content, reasoning string, calls []ToolC
}
func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error) {
- if _, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err != nil {
+ if err := c.validateReasoning(req); err != nil {
return nil, err
}
// Prefer stream collection: some Gemini-compatible reverse proxies aggregate
@@ -499,7 +538,7 @@ func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error)
}
func (c *geminiClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) {
- if _, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err != nil {
+ if err := c.validateReasoning(req); err != nil {
return nil, err
}
httpResp, err := c.opts.doStream(ctx, "POST", c.endpoint(req.Model, "streamGenerateContent", true), c.buildBody(req), c.headers())
diff --git a/internal/llm/gemini_test.go b/internal/llm/gemini_test.go
index cda0fbc..7468688 100644
--- a/internal/llm/gemini_test.go
+++ b/internal/llm/gemini_test.go
@@ -4,14 +4,17 @@ import (
"context"
"encoding/json"
"errors"
+ "net/http"
+ "net/http/httptest"
"reflect"
"strings"
+ "sync/atomic"
"testing"
)
func TestGeminiThinkingConfigUsesLevelForGemini3(t *testing.T) {
- tc := geminiThinkingConfig("gemini-3.6-flash-high", "high")
- if tc == nil {
+ tc, ok := geminiThinkingConfig("gemini-3.6-flash-high", "high")
+ if !ok || tc == nil {
t.Fatal("expected thinkingConfig")
}
if tc["thinkingLevel"] != "HIGH" {
@@ -26,8 +29,8 @@ func TestGeminiThinkingConfigUsesLevelForGemini3(t *testing.T) {
}
func TestGeminiThinkingConfigUsesBudgetFor25(t *testing.T) {
- tc := geminiThinkingConfig("gemini-2.5-flash", "medium")
- if tc == nil {
+ tc, ok := geminiThinkingConfig("gemini-2.5-flash", "medium")
+ if !ok || tc == nil {
t.Fatal("expected thinkingConfig")
}
if tc["thinkingBudget"] != 8192 {
@@ -39,16 +42,42 @@ func TestGeminiThinkingConfigUsesBudgetFor25(t *testing.T) {
}
func TestGemini3MinimalIsNotDisable(t *testing.T) {
- got := geminiThinkingConfig("gemini-3.6-flash", "minimal")
+ got, ok := geminiThinkingConfig("gemini-3.6-flash", "minimal")
want := map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true}
- if !reflect.DeepEqual(got, want) {
- t.Fatalf("got %#v, want %#v", got, want)
+ if !ok || !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %#v, ok=%v, want %#v, ok=true", got, ok, want)
}
}
func TestGeminiThinkingConfigGemini3HasNoTrueOff(t *testing.T) {
- if got := geminiThinkingConfig("gemini-3.6-flash", "none"); got != nil {
- t.Fatalf("got %#v, want nil (Gemini 3 has no true Off, only Minimal)", got)
+ got, ok := geminiThinkingConfig("gemini-3.6-flash", "none")
+ if !ok || got != nil {
+ t.Fatalf("got %#v, ok=%v, want nil, ok=true (Gemini 3 has no true Off, only Minimal — that's a valid mapping, not a failure)", got, ok)
+ }
+}
+
+// TestGeminiThinkingConfigMinimalUnmappableForLegacyBudgetModel guards the
+// second half of geminiThinkingConfig's ok contract: "minimal" has no
+// documented meaning for a pre-Gemini-3 thinkingBudget model, so it must be
+// reported as unmappable (ok == false) rather than silently treated as a
+// no-op, matching how an entirely unrecognised keyword is handled.
+func TestGeminiThinkingConfigMinimalUnmappableForLegacyBudgetModel(t *testing.T) {
+ if _, ok := geminiThinkingConfig("gemini-2.5-flash", "minimal"); ok {
+ t.Fatal("got ok=true, want ok=false: legacy budget models have no minimal thinking level")
+ }
+}
+
+// TestGeminiThinkingConfigUnrecognisedEffortIsUnmappable guards the general
+// case behind the review finding: an effort value this switch has never
+// heard of (as could be attached via a live/static ReasoningCapability that
+// advertises more values than this local mapping knows) must report
+// ok == false rather than silently mapping to no override.
+func TestGeminiThinkingConfigUnrecognisedEffortIsUnmappable(t *testing.T) {
+ if _, ok := geminiThinkingConfig("gemini-2.5-flash", "xhigh"); ok {
+ t.Fatal("got ok=true, want ok=false for an unrecognised effort keyword")
+ }
+ if _, ok := geminiThinkingConfig("gemini-3.6-flash", "xhigh"); ok {
+ t.Fatal("got ok=true, want ok=false for an unrecognised effort keyword on a Gemini 3 model")
}
}
@@ -96,6 +125,56 @@ func TestGeminiRejectsUnsupportedOffBeforeRequest(t *testing.T) {
}
}
+// TestGeminiUnmappableLegacyEffortFailsBeforeRequest guards a silent-drop
+// bug: an effort can pass validation against an *attached* capability (e.g. a
+// live capability advertising a value this local geminiThinkingConfig switch
+// does not recognise for the model) yet have no mapping at all.
+// geminiThinkingConfig previously returned nil in that case and buildBody
+// just omitted thinkingConfig, silently downgrading the turn to Auto. Chat
+// must now fail before any request is sent.
+func TestGeminiUnmappableLegacyEffortFailsBeforeRequest(t *testing.T) {
+ cap, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "xhigh", Label: "Extra High"}},
+ "low", false, ReasoningCapabilityStatic,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var hits int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&hits, 1)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ c := &geminiClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}}
+ _, err = c.Chat(context.Background(), Request{
+ Model: "gemini-2.5-flash", ReasoningEffort: "xhigh", ReasoningCapability: cap,
+ })
+ var unsupported *UnsupportedReasoningEffortError
+ if !errors.As(err, &unsupported) {
+ t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err)
+ }
+ if got := atomic.LoadInt32(&hits); got != 0 {
+ t.Fatalf("requests sent = %d, want 0", got)
+ }
+
+ // buildBody alone (bypassing Chat) must also stay silent-safe: it must
+ // not fabricate a thinkingConfig it cannot actually honor.
+ body := (&geminiClient{}).buildBody(Request{
+ Model: "gemini-2.5-flash", ReasoningEffort: "xhigh", ReasoningCapability: cap,
+ Messages: []Message{{Role: RoleUser, Content: "hi"}},
+ })
+ gen, _ := body["generationConfig"].(map[string]any)
+ if gen != nil {
+ if _, ok := gen["thinkingConfig"]; ok {
+ t.Fatalf("buildBody emitted a thinkingConfig for an unmappable effort: %#v", gen["thinkingConfig"])
+ }
+ }
+}
+
func TestToGeminiPreservesThoughtSignatureOnFunctionCall(t *testing.T) {
req := Request{
Messages: []Message{{
diff --git a/internal/llm/reasoning_catalog.go b/internal/llm/reasoning_catalog.go
index 7c8c8e2..dc185c4 100644
--- a/internal/llm/reasoning_catalog.go
+++ b/internal/llm/reasoning_catalog.go
@@ -51,7 +51,7 @@ func StaticReasoningCapability(kind, provider, baseURL, model string) *Reasoning
entries = codexReasoningCapabilities
case kind == "anthropic" && provider == "anthropic" && isAnthropicDirectBaseURL(baseURL):
entries = anthropicReasoningCapabilities
- case kind == "gemini" && provider == "google" && baseURL == "https://generativelanguage.googleapis.com/v1beta":
+ case kind == "gemini" && isGeminiDirectProvider(provider) && baseURL == "https://generativelanguage.googleapis.com/v1beta":
entries = geminiReasoningCapabilities
default:
return nil
@@ -78,6 +78,16 @@ func isAnthropicDirectBaseURL(baseURL string) bool {
return baseURL == "https://api.anthropic.com" || baseURL == "https://api.anthropic.com/v1"
}
+// isGeminiDirectProvider recognises both the documented canonical provider id
+// ("google") and the shipped provider id this codebase actually configures
+// for direct Gemini ("gemini" — internal/config/defaults.go's providers map
+// key). This is an exact two-value allowlist, not a broad match: any other
+// provider id (a custom or Gemini-compatible reverse proxy) still falls back
+// to Auto-only, matching direct-request behavior for other kinds.
+func isGeminiDirectProvider(provider string) bool {
+ return provider == "google" || provider == "gemini"
+}
+
func exactModelOrDatedSnapshot(family, model string) bool {
if model == family {
return true
diff --git a/internal/llm/reasoning_test.go b/internal/llm/reasoning_test.go
index 5162826..ca9eac0 100644
--- a/internal/llm/reasoning_test.go
+++ b/internal/llm/reasoning_test.go
@@ -169,6 +169,32 @@ func TestStaticReasoningCapabilityDoesNotGuessUnknownCompatibleModels(t *testing
}
}
+// TestStaticReasoningCapabilityAcceptsShippedGeminiProviderID guards against a
+// regression where direct Gemini's static catalog only matched the documented
+// canonical provider id "google", but internal/config/defaults.go's shipped
+// provider map key (and therefore the real runtime llm.Options.ProviderID) is
+// "gemini". Without this, the static fallback never fired for the actual
+// default Gemini provider.
+func TestStaticReasoningCapabilityAcceptsShippedGeminiProviderID(t *testing.T) {
+ for _, provider := range []string{"google", "gemini"} {
+ cap := StaticReasoningCapability("gemini", provider, "https://generativelanguage.googleapis.com/v1beta", "gemini-3.6-flash")
+ if cap == nil {
+ t.Errorf("provider %q: got nil capability, want a match", provider)
+ }
+ }
+}
+
+// TestStaticReasoningCapabilityRejectsUnknownGeminiCompatibleProvider ensures
+// widening the Gemini provider match to also accept "gemini" stayed an exact
+// two-value allowlist rather than a broad match: an arbitrary custom or
+// Gemini-compatible reverse-proxy provider id must still resolve to nil
+// (Auto-only), exactly like every other kind's unknown-provider case.
+func TestStaticReasoningCapabilityRejectsUnknownGeminiCompatibleProvider(t *testing.T) {
+ if got := StaticReasoningCapability("gemini", "my-gemini-proxy", "https://generativelanguage.googleapis.com/v1beta", "gemini-3.6-flash"); got != nil {
+ t.Fatalf("got %#v, want Auto-only for an unrecognised Gemini-compatible provider id", got)
+ }
+}
+
func reasoningValues(cap *ReasoningCapability) []string {
if cap == nil {
return nil
From 48af6fcdc98f48a11d49748100f18cce2f9b60de Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 01:50:28 +0700
Subject: [PATCH 06/41] Resolve reasoning against the effective model
Co-authored-by: Cursor
---
cmd/antares/main.go | 6 +-
cmd/antares/reasoning_test.go | 67 +++++
internal/agent/agent.go | 48 +++-
internal/agent/client.go | 105 ++++++--
internal/agent/harness.go | 14 +-
internal/agent/reasoning.go | 165 ++++++++++++
internal/agent/reasoning_test.go | 423 +++++++++++++++++++++++++++++++
internal/llm/fallback.go | 22 +-
internal/llm/fallback_test.go | 56 +++-
internal/llm/reasoning.go | 8 +
10 files changed, 864 insertions(+), 50 deletions(-)
create mode 100644 cmd/antares/reasoning_test.go
create mode 100644 internal/agent/reasoning.go
create mode 100644 internal/agent/reasoning_test.go
diff --git a/cmd/antares/main.go b/cmd/antares/main.go
index 2a9bc0b..c23b9e6 100644
--- a/cmd/antares/main.go
+++ b/cmd/antares/main.go
@@ -422,6 +422,10 @@ func (rt *runtimeServices) messageIsRelevant(ctx context.Context, b *config.Bind
"\n\nMessage:\n" + strings.TrimSpace(text) +
"\n\nDoes this message fit the criteria and deserve a reply? Answer with exactly one word: YES or NO."
+ reasoningEffort := ""
+ if err := rt.agent.ValidateReasoningEffort(ctx, b.Model, "low"); err == nil {
+ reasoningEffort = "low"
+ }
var out strings.Builder
_, err := rt.agent.Run(ctx, agent.Request{
Message: prompt,
@@ -429,7 +433,7 @@ func (rt *runtimeServices) messageIsRelevant(ctx context.Context, b *config.Bind
Toolset: "minimal",
Quiet: true,
MaxTurns: 1,
- ReasoningEffort: "low",
+ ReasoningEffort: reasoningEffort,
}, func(e agent.Event) error {
if e.Type == agent.EventText {
out.WriteString(e.Delta)
diff --git a/cmd/antares/reasoning_test.go b/cmd/antares/reasoning_test.go
new file mode 100644
index 0000000..3672912
--- /dev/null
+++ b/cmd/antares/reasoning_test.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/enowdev/antares/internal/agent"
+ "github.com/enowdev/antares/internal/config"
+ "github.com/enowdev/antares/internal/store"
+ "github.com/enowdev/antares/internal/tools"
+)
+
+func TestMessageIsRelevantUsesAutoWhenLowUnsupported(t *testing.T) {
+ var chatCalls atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"):
+ _, _ = w.Write([]byte(`{"data":[{"id":"plain-model","name":"Plain"}]}`))
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
+ chatCalls.Add(1)
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"NO"}}]}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ cfg := config.Default()
+ cfg.Model.Provider = "router"
+ cfg.Model.Default = "plain-model"
+ cfg.Model.MaxRetries = -1
+ cfg.Model.ReasoningEffort = ""
+ cfg.Agent.ReasoningEffort = ""
+ cfg.Streaming.Enabled = false
+ cfg.Providers = map[string]config.Provider{
+ "router": {
+ Kind: "openai-compatible",
+ BaseURL: srv.URL,
+ Enabled: true,
+ },
+ }
+ db, err := store.Open(context.Background(), "memory", "", 1, 5000, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ rt := &runtimeServices{
+ cfg: cfg,
+ db: db,
+ agent: agent.New(cfg, db, tools.NewRegistry(), nil, nil),
+ }
+
+ if got := rt.messageIsRelevant(context.Background(), &config.Binding{
+ Model: "plain-model",
+ RelevanceFilter: "Only answer release announcements.",
+ }, "How is everyone?"); got {
+ t.Fatal("messageIsRelevant = true, want classifier's NO response")
+ }
+ if got := chatCalls.Load(); got != 1 {
+ t.Fatalf("classifier chat calls = %d, want one", got)
+ }
+}
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 1f43da8..120e367 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -309,6 +309,7 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error
req.Role = stored
}
}
+ roleReasoningEffort := a.roleReasoningEffort(req.Role)
a.applyRole(&req)
if !req.Quiet {
if err := emit(Event{Type: EventSession, ID: sess.ID, Title: sess.Title}); err != nil {
@@ -327,12 +328,34 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error
a.mu.Unlock()
}()
- client, modelName, providerName, err := a.newClient(req.Model, sess.ID)
+ client, modelName, providerName, err := a.newClientContext(runCtx, req.Model, sess.ID)
if err != nil {
_ = emit(Event{Type: EventError, Err: err.Error()})
_ = emit(Event{Type: EventDone})
return nil, err
}
+ reasoning, err := a.resolveReasoning(runCtx, reasoningInput{
+ ModelRef: providerName + "/" + modelName,
+ Explicit: req.ReasoningEffort,
+ Role: roleReasoningEffort,
+ Agent: cfg.Agent.ReasoningEffort,
+ Model: cfg.Model.ReasoningEffort,
+ })
+ if err != nil {
+ _ = emit(Event{Type: EventError, Err: err.Error()})
+ _ = emit(Event{Type: EventDone})
+ return nil, err
+ }
+ if reasoning.DiscardedLegacy != "" {
+ _ = emit(Event{
+ Type: EventNotice,
+ Message: fmt.Sprintf(
+ "configured reasoning effort %q is unsupported by %s and was ignored",
+ reasoning.DiscardedLegacy,
+ modelName,
+ ),
+ })
+ }
history, err := a.loadHistory(ctx, sess, req)
if err != nil {
@@ -441,17 +464,18 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error
history = a.maybeCompact(runCtx, history, systemPrompt, modelName, toolSpecs, emit, sess)
llmReq := llm.Request{
- Model: modelName,
- System: systemPrompt,
- Messages: ensureToolResults(history),
- Tools: toolSpecs,
- Temperature: cfg.Model.Temperature,
- TopP: cfg.Model.TopP,
- MaxTokens: cfg.Model.MaxTokens,
- StopSequences: cfg.Agent.StopSequences,
- ReasoningEffort: firstNonEmpty(req.ReasoningEffort, cfg.Agent.ReasoningEffort, cfg.Model.ReasoningEffort),
- ParallelToolCalls: cfg.Model.ParallelToolCall,
- PromptCache: cfg.PromptCaching.Enabled,
+ Model: modelName,
+ System: systemPrompt,
+ Messages: ensureToolResults(history),
+ Tools: toolSpecs,
+ Temperature: cfg.Model.Temperature,
+ TopP: cfg.Model.TopP,
+ MaxTokens: cfg.Model.MaxTokens,
+ StopSequences: cfg.Agent.StopSequences,
+ ReasoningEffort: reasoning.Value,
+ ReasoningCapability: reasoning.Capability,
+ ParallelToolCalls: cfg.Model.ParallelToolCall,
+ PromptCache: cfg.PromptCaching.Enabled,
}
resp, err := a.callModel(runCtx, client, llmReq, cfg.Streaming.Enabled, emit)
diff --git a/internal/agent/client.go b/internal/agent/client.go
index c29e222..6f10655 100644
--- a/internal/agent/client.go
+++ b/internal/agent/client.go
@@ -8,6 +8,7 @@ import (
"log/slog"
+ "github.com/enowdev/antares/internal/config"
"github.com/enowdev/antares/internal/llm"
)
@@ -16,6 +17,14 @@ import (
// models are configured, the returned client tries each in turn on a hard
// failure. sessionID pins gateway sticky routing (Gemini CLI–compatible) when set.
func (a *Agent) newClient(modelOverride, sessionID string) (client llm.Client, model, provider string, err error) {
+ return a.buildClient(context.Background(), modelOverride, sessionID, false)
+}
+
+func (a *Agent) newClientContext(ctx context.Context, modelOverride, sessionID string) (client llm.Client, model, provider string, err error) {
+ return a.buildClient(ctx, modelOverride, sessionID, true)
+}
+
+func (a *Agent) buildClient(ctx context.Context, modelOverride, sessionID string, withReasoningCapabilities bool) (client llm.Client, model, provider string, err error) {
primary, model, provider, err := a.resolveClient(modelOverride, sessionID)
if err != nil {
return nil, "", "", err
@@ -23,24 +32,43 @@ func (a *Agent) newClient(modelOverride, sessionID string) (client llm.Client, m
// Only the default path (no explicit override) uses the fallback chain, so
// a deliberately chosen model is honoured exactly.
- entries := []llm.FallbackEntry{{Client: primary, Model: model}}
+ entries := []llm.FallbackEntry{{
+ Client: primary,
+ Model: model,
+ }}
if modelOverride == "" {
for _, spec := range a.config().Model.Fallback {
spec = strings.TrimSpace(spec)
if spec == "" {
continue
}
- fc, fm, _, ferr := a.resolveClient(spec, sessionID)
+ fc, fm, fp, ferr := a.resolveClient(spec, sessionID)
if ferr != nil {
slog.Debug("fallback model unavailable", "spec", spec, "error", ferr)
continue
}
- entries = append(entries, llm.FallbackEntry{Client: fc, Model: fm})
+ if withReasoningCapabilities && len(entries) == 1 {
+ entries[0].ReasoningCapability = a.reasoningCapabilityForResolved(ctx, provider, model)
+ }
+ entry := llm.FallbackEntry{Client: fc, Model: fm}
+ if withReasoningCapabilities {
+ entry.ReasoningCapability = a.reasoningCapabilityForResolved(ctx, fp, fm)
+ }
+ entries = append(entries, entry)
}
}
return llm.NewFallback(entries), model, provider, nil
}
+func (a *Agent) reasoningCapabilityForResolved(ctx context.Context, provider, model string) *llm.ReasoningCapability {
+ capability, err := a.ReasoningCapability(ctx, provider+"/"+model)
+ if err != nil {
+ slog.Debug("reasoning metadata unavailable", "provider", provider, "model", model, "error", err)
+ return nil
+ }
+ return capability
+}
+
// resolveClient builds one provider adapter for a model spec.
func (a *Agent) resolveClient(modelOverride, sessionID string) (client llm.Client, model, provider string, err error) {
cfg := a.config()
@@ -132,48 +160,73 @@ func (a *Agent) Probe(ctx context.Context) (bool, string) {
// Models lists the models a provider offers.
//
-// If providers..models is non-empty it is treated as a whitelist: only
-// those ids are returned (no live /models merge). This keeps curated local
-// gateways (e.g. Sub2API antigravity) from flooding the UI with broken or
-// deprecated upstream catalog entries.
+// If providers..models is non-empty it is treated as a whitelist: live
+// metadata may enrich those ids, but unlisted live models are never appended.
+// This keeps curated local gateways (e.g. Sub2API antigravity) from flooding
+// the UI with broken or deprecated upstream catalog entries.
//
// If the list is empty, the provider's /models endpoint is queried live.
// A live fetch that fails still yields any manual list rather than nothing.
func (a *Agent) Models(ctx context.Context, providerID string) ([]llm.ModelInfo, error) {
id, p := a.config().ResolveProvider(providerID)
- // Curated whitelist: skip live catalog entirely.
- if len(p.Models) > 0 {
- out := make([]llm.ModelInfo, 0, len(p.Models))
- seen := make(map[string]bool, len(p.Models))
- for _, mid := range p.Models {
- if mid == "" || seen[mid] {
- continue
- }
- seen[mid] = true
- out = append(out, llm.ModelInfo{
- ID: mid,
- Name: mid,
- Provider: id,
- ContextWindow: p.ModelMeta[mid].ContextWindow,
- })
- }
- return out, nil
- }
-
client, err := llm.New(llm.Options{
Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey,
Headers: p.Headers, ProviderID: id, Timeout: 60 * time.Second, APIVersion: p.APIVersion, Region: p.Region,
})
if err != nil {
+ if len(p.Models) > 0 {
+ return curatedModelsWithReasoning(id, p, nil, p.Kind), nil
+ }
return nil, err
}
fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
live, ferr := client.Models(fetchCtx)
+ if len(p.Models) > 0 {
+ return curatedModelsWithReasoning(id, p, live, client.Kind()), nil
+ }
if ferr != nil && len(live) == 0 {
return nil, ferr
}
+ for i := range live {
+ if live[i].ReasoningCapability != nil {
+ continue
+ }
+ target := reasoningTarget{providerID: id, model: live[i].ID, provider: p}
+ target.provider.Kind = client.Kind()
+ live[i] = live[i].WithReasoningCapability(staticReasoningCapability(target))
+ }
return live, nil
}
+
+func curatedModelsWithReasoning(id string, p config.Provider, live []llm.ModelInfo, kind string) []llm.ModelInfo {
+ liveByID := make(map[string]llm.ModelInfo, len(live))
+ for _, model := range live {
+ liveByID[model.ID] = model
+ }
+
+ out := make([]llm.ModelInfo, 0, len(p.Models))
+ seen := make(map[string]bool, len(p.Models))
+ for _, mid := range p.Models {
+ if mid == "" || seen[mid] {
+ continue
+ }
+ seen[mid] = true
+ info := llm.ModelInfo{
+ ID: mid,
+ Name: mid,
+ Provider: id,
+ ContextWindow: p.ModelMeta[mid].ContextWindow,
+ }
+ capability := liveByID[mid].ReasoningCapability
+ if capability == nil {
+ target := reasoningTarget{providerID: id, model: mid, provider: p}
+ target.provider.Kind = kind
+ capability = staticReasoningCapability(target)
+ }
+ out = append(out, info.WithReasoningCapability(capability))
+ }
+ return out
+}
diff --git a/internal/agent/harness.go b/internal/agent/harness.go
index 94b4803..a430431 100644
--- a/internal/agent/harness.go
+++ b/internal/agent/harness.go
@@ -783,14 +783,22 @@ func (a *Agent) applyRole(req *Request) {
if req.Model == "" && role.Model != "" {
req.Model = role.Model
}
- if req.ReasoningEffort == "" && role.Effort != "" {
- req.ReasoningEffort = role.Effort
- }
if req.MaxTurns == 0 && role.MaxTurns > 0 {
req.MaxTurns = role.MaxTurns
}
}
+func (a *Agent) roleReasoningEffort(name string) string {
+ if a.roles == nil || strings.TrimSpace(name) == "" {
+ return ""
+ }
+ role, ok := a.roles.Get(name)
+ if !ok {
+ return ""
+ }
+ return role.Effort
+}
+
// roleInfos exposes the roles to the tools layer.
func (a *Agent) roleInfos() []tools.RoleInfo {
if a.roles == nil {
diff --git a/internal/agent/reasoning.go b/internal/agent/reasoning.go
new file mode 100644
index 0000000..3c94c61
--- /dev/null
+++ b/internal/agent/reasoning.go
@@ -0,0 +1,165 @@
+package agent
+
+import (
+ "context"
+ "strings"
+
+ "github.com/enowdev/antares/internal/config"
+ "github.com/enowdev/antares/internal/llm"
+)
+
+type reasoningInput struct {
+ ModelRef string
+ Explicit string
+ Role string
+ Agent string
+ Model string
+}
+
+type reasoningResolution struct {
+ Value string
+ Capability *llm.ReasoningCapability
+ DiscardedLegacy string
+}
+
+type reasoningTarget struct {
+ providerID string
+ model string
+ provider config.Provider
+}
+
+// ReasoningCapability returns the best model-specific reasoning metadata the
+// configured provider can supply. Documented direct-provider metadata avoids a
+// network dependency; dynamic providers are queried through Agent.Models.
+func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm.ReasoningCapability, error) {
+ target := a.reasoningTarget(modelRef)
+ if capability := staticReasoningCapability(target); capability != nil {
+ return capability, nil
+ }
+
+ models, err := a.Models(ctx, target.providerID)
+ if err != nil {
+ // Model catalogues are optional. Unknown metadata means Auto-only; it
+ // must not make an otherwise valid chat depend on a /models endpoint.
+ return nil, nil
+ }
+ for _, model := range models {
+ if model.ID == target.model && model.ReasoningCapability != nil {
+ return model.ReasoningCapability, nil
+ }
+ }
+ return nil, nil
+}
+
+// ValidateReasoningEffort validates an explicit value without including the
+// submitted value in any error.
+func (a *Agent) ValidateReasoningEffort(ctx context.Context, modelRef, effort string) error {
+ capability, err := a.ReasoningCapability(ctx, modelRef)
+ if err != nil {
+ return err
+ }
+ return llm.ValidateReasoningEffort(a.reasoningTarget(modelRef).model, capability, effort)
+}
+
+// resolveReasoning distinguishes a new explicit override from stored legacy
+// values. An invalid explicit override is an error; invalid stored values are
+// skipped in role, agent, model order so old configuration degrades to Auto.
+func (a *Agent) resolveReasoning(ctx context.Context, in reasoningInput) (reasoningResolution, error) {
+ capability, err := a.ReasoningCapability(ctx, in.ModelRef)
+ if err != nil {
+ return reasoningResolution{}, err
+ }
+ resolution := reasoningResolution{Capability: capability}
+ model := a.reasoningTarget(in.ModelRef).model
+
+ if in.Explicit != "" {
+ if err := llm.ValidateReasoningEffort(model, capability, in.Explicit); err != nil {
+ return reasoningResolution{}, err
+ }
+ resolution.Value = in.Explicit
+ return resolution, nil
+ }
+
+ agentValue := in.Agent
+ if agentValue == "" {
+ agentValue = a.config().Agent.ReasoningEffort
+ }
+ modelValue := in.Model
+ if modelValue == "" {
+ modelValue = a.config().Model.ReasoningEffort
+ }
+ for _, stored := range []string{in.Role, agentValue, modelValue} {
+ if stored == "" {
+ continue
+ }
+ if err := llm.ValidateReasoningEffort(model, capability, stored); err == nil {
+ resolution.Value = stored
+ return resolution, nil
+ }
+ if resolution.DiscardedLegacy == "" {
+ resolution.DiscardedLegacy = stored
+ }
+ }
+ return resolution, nil
+}
+
+func (a *Agent) reasoningTarget(modelRef string) reasoningTarget {
+ cfg := a.config()
+ providerID := cfg.Model.Provider
+ model := modelRef
+ if model == "" {
+ model = cfg.Model.Default
+ }
+ if modelRef != "" {
+ if candidate, rest, ok := strings.Cut(modelRef, "/"); ok && rest != "" {
+ if _, configured := cfg.Providers[candidate]; configured {
+ providerID, model = candidate, rest
+ } else if candidate == cfg.Model.Provider || candidate == "google" {
+ // "google/model" is the canonical direct-Gemini reference even
+ // though the shipped provider map uses the key "gemini".
+ providerID, model = candidate, rest
+ }
+ }
+ }
+
+ id, provider := cfg.ResolveProvider(providerID)
+ if id == "google" {
+ if _, configured := cfg.Providers[id]; !configured {
+ provider = config.Provider{
+ Kind: "gemini",
+ BaseURL: "https://generativelanguage.googleapis.com/v1beta",
+ Enabled: true,
+ }
+ }
+ }
+ return reasoningTarget{providerID: id, model: model, provider: provider}
+}
+
+func staticReasoningCapability(target reasoningTarget) *llm.ReasoningCapability {
+ kind := strings.ToLower(strings.TrimSpace(target.provider.Kind))
+ switch kind {
+ case "google":
+ kind = "gemini"
+ case "claude":
+ kind = "anthropic"
+ case "responses", "openai-responses":
+ kind = "codex"
+ }
+ baseURL := strings.TrimRight(strings.TrimSpace(target.provider.BaseURL), "/")
+ if baseURL == "" {
+ switch kind {
+ case "openai", "codex":
+ baseURL = "https://api.openai.com/v1"
+ case "anthropic":
+ baseURL = "https://api.anthropic.com/v1"
+ case "gemini":
+ baseURL = "https://generativelanguage.googleapis.com/v1beta"
+ }
+ }
+ return llm.StaticReasoningCapability(
+ kind,
+ target.providerID,
+ baseURL,
+ target.model,
+ )
+}
diff --git a/internal/agent/reasoning_test.go b/internal/agent/reasoning_test.go
new file mode 100644
index 0000000..cd1ee25
--- /dev/null
+++ b/internal/agent/reasoning_test.go
@@ -0,0 +1,423 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "github.com/enowdev/antares/internal/config"
+ "github.com/enowdev/antares/internal/llm"
+ "github.com/enowdev/antares/internal/store"
+ "github.com/enowdev/antares/internal/tools"
+)
+
+func TestResolveReasoningExplicitUnsupportedReturnsError(t *testing.T) {
+ a := agentWithConfig(config.Default())
+ _, err := a.resolveReasoning(context.Background(), reasoningInput{
+ ModelRef: "google/gemini-3.6-flash",
+ Explicit: "max",
+ })
+ if err == nil || !llm.IsUnsupportedReasoningEffort(err) {
+ t.Fatalf("err = %v", err)
+ }
+}
+
+func TestResolveReasoningUnsupportedStoredValueFallsBackToAuto(t *testing.T) {
+ cfg := config.Default()
+ cfg.Model.Provider = "google"
+ cfg.Model.Default = "gemini-3.6-flash"
+ cfg.Agent.ReasoningEffort = "max"
+ a := agentWithConfig(cfg)
+ got, err := a.resolveReasoning(context.Background(), reasoningInput{ModelRef: cfg.Model.Default})
+ if err != nil || got.Value != "" || got.DiscardedLegacy != "max" {
+ t.Fatalf("got=%+v err=%v", got, err)
+ }
+}
+
+func TestResolveReasoningUsesRoleAgentModelPrecedence(t *testing.T) {
+ cfg := config.Default()
+ cfg.Model.Provider = "gemini"
+ cfg.Model.Default = "gemini-3.6-flash"
+ a := agentWithConfig(cfg)
+
+ got, err := a.resolveReasoning(context.Background(), reasoningInput{
+ ModelRef: "gemini/gemini-3.6-flash",
+ Role: "high",
+ Agent: "medium",
+ Model: "low",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Value != "high" || got.DiscardedLegacy != "" {
+ t.Fatalf("got = %+v, want role value high", got)
+ }
+}
+
+func TestResolveReasoningSkipsUnsupportedStoredValuesInPrecedenceOrder(t *testing.T) {
+ cfg := config.Default()
+ cfg.Model.Provider = "gemini"
+ cfg.Model.Default = "gemini-3.6-flash"
+ a := agentWithConfig(cfg)
+
+ got, err := a.resolveReasoning(context.Background(), reasoningInput{
+ ModelRef: "gemini/gemini-3.6-flash",
+ Role: "MAX",
+ Agent: "medium",
+ Model: "low",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Value != "medium" || got.DiscardedLegacy != "MAX" {
+ t.Fatalf("got = %+v, want agent value medium after discarding exact role value MAX", got)
+ }
+}
+
+func TestResolveReasoningPreservesOpaqueLiveValueAndCase(t *testing.T) {
+ srv := newReasoningModelsServer(t, `{
+ "data": [
+ {"id": "model-a", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}},
+ {"id": "model-b", "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"}}
+ ]
+ }`)
+ cfg := reasoningTestConfig(srv.URL, nil)
+ a := agentWithConfig(cfg)
+
+ got, err := a.resolveReasoning(context.Background(), reasoningInput{
+ ModelRef: "router/model-b",
+ Explicit: "MiXeD",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Value != "MiXeD" || got.Capability == nil || got.Capability.Source != llm.ReasoningCapabilityLive {
+ t.Fatalf("got = %+v", got)
+ }
+
+ _, err = a.resolveReasoning(context.Background(), reasoningInput{
+ ModelRef: "router/model-b",
+ Explicit: "mixed",
+ })
+ if err == nil || !llm.IsUnsupportedReasoningEffort(err) {
+ t.Fatalf("case-changed value err = %v", err)
+ }
+}
+
+func TestValidateReasoningEffortDoesNotExposeSubmittedValue(t *testing.T) {
+ cfg := config.Default()
+ cfg.Model.Provider = "gemini"
+ a := agentWithConfig(cfg)
+ const submitted = "secret-invalid-effort"
+
+ err := a.ValidateReasoningEffort(context.Background(), "gemini/gemini-3.6-flash", submitted)
+ if err == nil || !llm.IsUnsupportedReasoningEffort(err) {
+ t.Fatalf("err = %v", err)
+ }
+ if strings.Contains(err.Error(), submitted) {
+ t.Fatalf("error exposes submitted value: %v", err)
+ }
+}
+
+func TestReasoningCapabilityUsesMatchingModelLiveMetadata(t *testing.T) {
+ srv := newReasoningModelsServer(t, `{
+ "data": [
+ {"id": "model-a", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}},
+ {"id": "model-b", "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"}}
+ ]
+ }`)
+ a := agentWithConfig(reasoningTestConfig(srv.URL, nil))
+
+ capability, err := a.ReasoningCapability(context.Background(), "router/model-b")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if capability == nil || capability.Source != llm.ReasoningCapabilityLive {
+ t.Fatalf("capability = %#v", capability)
+ }
+ if len(capability.Values) != 1 || capability.Values[0].Value != "MiXeD" {
+ t.Fatalf("values = %#v, want exact model-b metadata", capability.Values)
+ }
+}
+
+func TestReasoningCapabilityResolvesInlineActiveProviderModelRef(t *testing.T) {
+ srv := newReasoningModelsServer(t, `{
+ "data": [
+ {"id": "model-a", "reasoning": {"supported_efforts": ["Exact"], "default_effort": "Exact"}}
+ ]
+ }`)
+ cfg := config.Default()
+ cfg.Providers = map[string]config.Provider{}
+ cfg.Model.Provider = "inline"
+ cfg.Model.Default = "model-a"
+ cfg.Model.BaseURL = srv.URL
+ a := agentWithConfig(cfg)
+
+ capability, err := a.ReasoningCapability(context.Background(), "inline/model-a")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if capability == nil || len(capability.Values) != 1 || capability.Values[0].Value != "Exact" {
+ t.Fatalf("capability = %#v", capability)
+ }
+}
+
+func TestModelsKeepsCuratedWhitelistWhileUsingMatchingLiveCapability(t *testing.T) {
+ srv := newReasoningModelsServer(t, `{
+ "data": [
+ {"id": "listed", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}},
+ {"id": "unlisted", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}}
+ ]
+ }`)
+ cfg := reasoningTestConfig(srv.URL, []string{"listed"})
+ a := agentWithConfig(cfg)
+
+ models, err := a.Models(context.Background(), "router")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(models) != 1 || models[0].ID != "listed" {
+ t.Fatalf("models = %#v, want only curated model", models)
+ }
+ capability := models[0].ReasoningCapability
+ if capability == nil || capability.Source != llm.ReasoningCapabilityLive ||
+ len(capability.Values) != 1 || capability.Values[0].Value != "HIGH" {
+ t.Fatalf("capability = %#v", capability)
+ }
+}
+
+func TestModelsFallsBackToStaticCapabilityForCuratedModel(t *testing.T) {
+ cfg := config.Default()
+ cfg.Providers["gemini"] = config.Provider{
+ Kind: "gemini",
+ Enabled: true,
+ Models: []string{"gemini-3.6-flash"},
+ }
+ a := agentWithConfig(cfg)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ models, err := a.Models(ctx, "gemini")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(models) != 1 {
+ t.Fatalf("models = %#v", models)
+ }
+ capability := models[0].ReasoningCapability
+ if capability == nil || capability.Source != llm.ReasoningCapabilityStatic {
+ t.Fatalf("capability = %#v, want static fallback", capability)
+ }
+}
+
+func TestRunResolvesStoredRoleReasoningOnceBeforeTurnLoop(t *testing.T) {
+ var (
+ mu sync.Mutex
+ chatBodies []map[string]any
+ chatCalls int
+ )
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"):
+ _, _ = w.Write([]byte(`{
+ "data": [
+ {"id": "model-a", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}}
+ ]
+ }`))
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
+ var body map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Errorf("decode chat body: %v", err)
+ }
+ mu.Lock()
+ chatBodies = append(chatBodies, body)
+ chatCalls++
+ call := chatCalls
+ mu.Unlock()
+ if call == 1 {
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
+ return
+ }
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ cfg := reasoningTestConfig(srv.URL, nil)
+ cfg.Agent.ReasoningEffort = "low"
+ cfg.Model.ReasoningEffort = "low"
+ cfg.Streaming.Enabled = false
+ a := newReasoningRunAgent(t, cfg)
+
+ var discardedNotices int
+ result, err := a.Run(context.Background(), Request{
+ Message: "test",
+ Role: "reviewer",
+ Quiet: true,
+ MaxTurns: 2,
+ }, func(event Event) error {
+ if event.Type == EventNotice && strings.Contains(event.Message, "high") {
+ discardedNotices++
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Reply != "ok" {
+ t.Fatalf("reply = %q", result.Reply)
+ }
+ if discardedNotices != 1 {
+ t.Fatalf("discarded-role notices = %d, want one", discardedNotices)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if len(chatBodies) != 2 {
+ t.Fatalf("chat calls = %d, want two", len(chatBodies))
+ }
+ for i, body := range chatBodies {
+ if body["reasoning_effort"] != "low" {
+ t.Fatalf("chat body %d reasoning_effort = %#v, want low", i+1, body["reasoning_effort"])
+ }
+ }
+}
+
+func TestRunCarriesMatchingReasoningCapabilityThroughFallbackEntries(t *testing.T) {
+ var primaryChats atomic.Int32
+ primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"):
+ _, _ = w.Write([]byte(`{
+ "data": [
+ {"id": "primary-model", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}}
+ ]
+ }`))
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
+ primaryChats.Add(1)
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"error":{"message":"primary unavailable"}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer primary.Close()
+
+ var (
+ mu sync.Mutex
+ fallbackEffort any
+ )
+ fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"):
+ _, _ = w.Write([]byte(`{
+ "data": [
+ {"id": "fallback-model", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}}
+ ]
+ }`))
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
+ var body map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Errorf("decode fallback body: %v", err)
+ }
+ mu.Lock()
+ fallbackEffort = body["reasoning_effort"]
+ mu.Unlock()
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"fallback ok"}}]}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer fallback.Close()
+
+ cfg := config.Default()
+ cfg.Model.Provider = "primary"
+ cfg.Model.Default = "primary-model"
+ cfg.Model.Fallback = []string{"backup/fallback-model"}
+ cfg.Model.MaxRetries = -1
+ cfg.Model.ReasoningEffort = ""
+ cfg.Agent.ReasoningEffort = "HIGH"
+ cfg.Streaming.Enabled = false
+ cfg.Providers = map[string]config.Provider{
+ "primary": {
+ Kind: "openai-compatible",
+ BaseURL: primary.URL,
+ Enabled: true,
+ },
+ "backup": {
+ Kind: "openai-compatible",
+ BaseURL: fallback.URL,
+ Enabled: true,
+ },
+ }
+ a := newReasoningRunAgent(t, cfg)
+
+ result, err := a.Run(context.Background(), Request{
+ Message: "test fallback",
+ Quiet: true,
+ MaxTurns: 1,
+ }, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Reply != "fallback ok" {
+ t.Fatalf("reply = %q", result.Reply)
+ }
+ if got := primaryChats.Load(); got != 1 {
+ t.Fatalf("primary chat calls = %d, want one", got)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if fallbackEffort != "HIGH" {
+ t.Fatalf("fallback reasoning_effort = %#v, want exact live value HIGH", fallbackEffort)
+ }
+}
+
+func reasoningTestConfig(baseURL string, curated []string) *config.Config {
+ cfg := config.Default()
+ cfg.Model.Provider = "router"
+ cfg.Model.Default = "model-a"
+ cfg.Model.MaxRetries = -1
+ cfg.Model.ReasoningEffort = ""
+ cfg.Agent.ReasoningEffort = ""
+ cfg.Providers = map[string]config.Provider{
+ "router": {
+ Kind: "openai-compatible",
+ BaseURL: baseURL,
+ Enabled: true,
+ Models: curated,
+ },
+ }
+ return cfg
+}
+
+func newReasoningModelsServer(t *testing.T, response string) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(response))
+ }))
+}
+
+func newReasoningRunAgent(t *testing.T, cfg *config.Config) *Agent {
+ t.Helper()
+ db, err := store.Open(context.Background(), "memory", "", 1, 5000, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ return New(cfg, db, tools.NewRegistry(), nil, nil)
+}
diff --git a/internal/llm/fallback.go b/internal/llm/fallback.go
index 7f0e219..8a1f627 100644
--- a/internal/llm/fallback.go
+++ b/internal/llm/fallback.go
@@ -7,8 +7,9 @@ import (
// FallbackEntry is one client and the model to ask it for.
type FallbackEntry struct {
- Client Client
- Model string
+ Client Client
+ Model string
+ ReasoningCapability *ReasoningCapability
}
// fallbackClient tries each entry in order, moving to the next when one fails
@@ -37,9 +38,8 @@ func (c *fallbackClient) Kind() string {
func (c *fallbackClient) Chat(ctx context.Context, req Request) (*Response, error) {
var lastErr error
- for _, e := range c.entries {
- r := req
- r.Model = e.Model
+ for i, e := range c.entries {
+ r := fallbackRequest(req, e, i > 0)
resp, err := e.Client.Chat(ctx, r)
if err == nil {
return resp, nil
@@ -62,8 +62,7 @@ func (c *fallbackClient) Stream(ctx context.Context, req Request, emit func(Even
emitted = true
return emit(ev)
}
- r := req
- r.Model = e.Model
+ r := fallbackRequest(req, e, i > 0)
resp, err := e.Client.Stream(ctx, r, wrapped)
if err == nil {
return resp, nil
@@ -76,6 +75,15 @@ func (c *fallbackClient) Stream(ctx context.Context, req Request, emit func(Even
return nil, lastErr
}
+func fallbackRequest(req Request, entry FallbackEntry, isFallback bool) Request {
+ req.Model = entry.Model
+ req.ReasoningCapability = entry.ReasoningCapability
+ if isFallback && ValidateReasoningEffort(entry.Model, entry.ReasoningCapability, req.ReasoningEffort) != nil {
+ req.ReasoningEffort = ""
+ }
+ return req
+}
+
// Models and Embed use the primary only — a fallback for enumeration or
// embeddings would silently change the vector space.
func (c *fallbackClient) Models(ctx context.Context) ([]ModelInfo, error) {
diff --git a/internal/llm/fallback_test.go b/internal/llm/fallback_test.go
index b8c399c..209b355 100644
--- a/internal/llm/fallback_test.go
+++ b/internal/llm/fallback_test.go
@@ -41,17 +41,71 @@ func TestFallbackOverridesModel(t *testing.T) {
}
}
+func TestFallbackReplacesPrimaryReasoningCapability(t *testing.T) {
+ primaryCapability, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "high", Label: "HIGH"}},
+ "high", false, ReasoningCapabilityLive,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ fallbackCapability, err := NewReasoningCapability(
+ []ReasoningValue{{Value: "low", Label: "LOW"}},
+ "low", false, ReasoningCapabilityLive,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ rec := &modelRecorder{}
+ c := NewFallback([]FallbackEntry{
+ {
+ Client: &fakeClient{failN: 10, failErr: &apiError{Status: 500}},
+ Model: "primary",
+ ReasoningCapability: primaryCapability,
+ },
+ {
+ Client: rec,
+ Model: "fallback",
+ ReasoningCapability: fallbackCapability,
+ },
+ })
+ _, err = c.Chat(context.Background(), Request{
+ Model: "original",
+ ReasoningEffort: "high",
+ ReasoningCapability: primaryCapability,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.gotModel != "fallback" {
+ t.Fatalf("model = %q, want fallback", rec.gotModel)
+ }
+ if rec.gotCapability != fallbackCapability {
+ t.Fatalf("capability = %#v, want fallback entry capability %#v", rec.gotCapability, fallbackCapability)
+ }
+ if rec.gotEffort != "" {
+ t.Fatalf("reasoning effort = %q, want Auto for unsupported legacy value", rec.gotEffort)
+ }
+}
+
type modelRecorder struct {
- gotModel string
+ gotModel string
+ gotEffort string
+ gotCapability *ReasoningCapability
}
func (m *modelRecorder) Kind() string { return "rec" }
func (m *modelRecorder) Chat(ctx context.Context, req Request) (*Response, error) {
m.gotModel = req.Model
+ m.gotEffort = req.ReasoningEffort
+ m.gotCapability = req.ReasoningCapability
return &Response{Content: "ok"}, nil
}
func (m *modelRecorder) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) {
m.gotModel = req.Model
+ m.gotEffort = req.ReasoningEffort
+ m.gotCapability = req.ReasoningCapability
return &Response{Content: "ok"}, nil
}
func (m *modelRecorder) Models(context.Context) ([]ModelInfo, error) { return nil, nil }
diff --git a/internal/llm/reasoning.go b/internal/llm/reasoning.go
index add7031..8b62da2 100644
--- a/internal/llm/reasoning.go
+++ b/internal/llm/reasoning.go
@@ -1,6 +1,7 @@
package llm
import (
+ "errors"
"fmt"
)
@@ -87,6 +88,13 @@ func (e *UnsupportedReasoningEffortError) Error() string {
return fmt.Sprintf("unsupported reasoning override for model %q (allowed: %v)", e.Model, e.Allowed)
}
+// IsUnsupportedReasoningEffort reports whether err is an unsupported
+// reasoning override without requiring callers to inspect or expose its value.
+func IsUnsupportedReasoningEffort(err error) bool {
+ var unsupported *UnsupportedReasoningEffortError
+ return errors.As(err, &unsupported)
+}
+
// ValidateReasoningEffort accepts Auto (an empty effort) for every model and
// otherwise requires an exact, advertised opaque value.
func ValidateReasoningEffort(model string, capability *ReasoningCapability, effort string) error {
From 46e11b79cc649b71ba349a40565353b43ca007f2 Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 02:05:57 +0700
Subject: [PATCH 07/41] Fix reasoning metadata caching and outages
Reuse scoped catalogues and preserve stale capabilities so interactive reasoning remains deterministic across repeated lookups and transient provider failures.
Co-authored-by: Cursor
---
internal/agent/agent.go | 24 +-
internal/agent/client.go | 35 ++-
internal/agent/model_cache.go | 209 +++++++++++++
internal/agent/model_cache_test.go | 468 +++++++++++++++++++++++++++++
internal/agent/reasoning.go | 62 +++-
internal/agent/reasoning_test.go | 14 +-
6 files changed, 773 insertions(+), 39 deletions(-)
create mode 100644 internal/agent/model_cache.go
create mode 100644 internal/agent/model_cache_test.go
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 120e367..845a7b3 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -194,21 +194,27 @@ type Agent struct {
mu sync.Mutex
active map[string]context.CancelFunc
+
+ catalogMu sync.Mutex
+ catalogCache map[providerCatalogScope]*providerCatalogEntry
+ catalogNow func() time.Time
}
// New builds an agent.
func New(cfg *config.Config, db store.Store, reg *tools.Registry, shell *tools.ShellManager, ragProvider tools.RAGProvider) *Agent {
a := &Agent{
db: db, reg: reg, shell: shell, rag: ragProvider,
- checks: checkpoint.NewStore(config.Path("checkpoints")),
- roles: roles.NewRegistry(nil),
- findings: findings.NewStore(config.Path("findings")),
- intel: engagement.NewStore(config.Path("intel")),
- roleperf: roleperf.NewTracker(config.Path("role-performance.json")),
- board: board.New(config.Path("boards")),
- bg: newBGManager(),
- bgAct: newBgActivity(),
- active: map[string]context.CancelFunc{},
+ checks: checkpoint.NewStore(config.Path("checkpoints")),
+ roles: roles.NewRegistry(nil),
+ findings: findings.NewStore(config.Path("findings")),
+ intel: engagement.NewStore(config.Path("intel")),
+ roleperf: roleperf.NewTracker(config.Path("role-performance.json")),
+ board: board.New(config.Path("boards")),
+ bg: newBGManager(),
+ bgAct: newBgActivity(),
+ active: map[string]context.CancelFunc{},
+ catalogCache: make(map[providerCatalogScope]*providerCatalogEntry),
+ catalogNow: time.Now,
}
a.cfg.Store(cfg)
return a
diff --git a/internal/agent/client.go b/internal/agent/client.go
index 6f10655..251fd74 100644
--- a/internal/agent/client.go
+++ b/internal/agent/client.go
@@ -169,33 +169,38 @@ func (a *Agent) Probe(ctx context.Context) (bool, string) {
// A live fetch that fails still yields any manual list rather than nothing.
func (a *Agent) Models(ctx context.Context, providerID string) ([]llm.ModelInfo, error) {
id, p := a.config().ResolveProvider(providerID)
-
- client, err := llm.New(llm.Options{
- Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey,
- Headers: p.Headers, ProviderID: id, Timeout: 60 * time.Second, APIVersion: p.APIVersion, Region: p.Region,
- })
- if err != nil {
- if len(p.Models) > 0 {
- return curatedModelsWithReasoning(id, p, nil, p.Kind), nil
- }
- return nil, err
+ models, err := a.modelsForProvider(ctx, id, p)
+ if err != nil && len(p.Models) > 0 {
+ return models, nil
}
- fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
- defer cancel()
+ return models, err
+}
- live, ferr := client.Models(fetchCtx)
+func (a *Agent) modelsForProvider(ctx context.Context, id string, p config.Provider) ([]llm.ModelInfo, error) {
+ live, ferr := a.cachedProviderCatalog(ctx, id, p, func(fetchCtx context.Context) ([]llm.ModelInfo, error) {
+ client, err := llm.New(llm.Options{
+ Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey,
+ Headers: p.Headers, ProviderID: id, Timeout: 60 * time.Second, APIVersion: p.APIVersion, Region: p.Region,
+ })
+ if err != nil {
+ return nil, err
+ }
+ fetchCtx, cancel := context.WithTimeout(fetchCtx, 45*time.Second)
+ defer cancel()
+ return client.Models(fetchCtx)
+ })
if len(p.Models) > 0 {
- return curatedModelsWithReasoning(id, p, live, client.Kind()), nil
+ return curatedModelsWithReasoning(id, p, live, p.Kind), ferr
}
if ferr != nil && len(live) == 0 {
return nil, ferr
}
for i := range live {
+ live[i].Provider = id
if live[i].ReasoningCapability != nil {
continue
}
target := reasoningTarget{providerID: id, model: live[i].ID, provider: p}
- target.provider.Kind = client.Kind()
live[i] = live[i].WithReasoningCapability(staticReasoningCapability(target))
}
return live, nil
diff --git a/internal/agent/model_cache.go b/internal/agent/model_cache.go
new file mode 100644
index 0000000..9fbd5ce
--- /dev/null
+++ b/internal/agent/model_cache.go
@@ -0,0 +1,209 @@
+package agent
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/binary"
+ "hash"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/enowdev/antares/internal/config"
+ "github.com/enowdev/antares/internal/llm"
+)
+
+type providerCatalogScope struct {
+ providerID string
+ kind string
+ baseURLFingerprint [sha256.Size]byte
+ credentialFingerprint [sha256.Size]byte
+ apiVersion string
+ region string
+}
+
+type providerCatalogEntry struct {
+ done chan struct{}
+ ready bool
+ hasSuccess bool
+ expiresAt time.Time
+ models []llm.ModelInfo
+ err error
+}
+
+const providerCatalogTTL = 5 * time.Minute
+
+func (a *Agent) cachedProviderCatalog(
+ ctx context.Context,
+ providerID string,
+ provider config.Provider,
+ fetch func(context.Context) ([]llm.ModelInfo, error),
+) ([]llm.ModelInfo, error) {
+ scope := providerCatalogScopeFor(providerID, provider)
+ for {
+ a.catalogMu.Lock()
+ if a.catalogCache == nil {
+ a.catalogCache = make(map[providerCatalogScope]*providerCatalogEntry)
+ }
+ if entry, ok := a.catalogCache[scope]; ok {
+ if entry.ready {
+ if a.providerCatalogTime().Before(entry.expiresAt) {
+ models, err := cloneModelInfo(entry.models), entry.err
+ a.catalogMu.Unlock()
+ return models, err
+ }
+ entry.ready = false
+ entry.done = make(chan struct{})
+ a.catalogMu.Unlock()
+ return a.refreshProviderCatalog(ctx, entry, fetch)
+ }
+ done := entry.done
+ a.catalogMu.Unlock()
+ select {
+ case <-done:
+ continue
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+
+ entry := &providerCatalogEntry{done: make(chan struct{})}
+ a.catalogCache[scope] = entry
+ a.catalogMu.Unlock()
+ return a.refreshProviderCatalog(ctx, entry, fetch)
+ }
+}
+
+func (a *Agent) refreshProviderCatalog(
+ ctx context.Context,
+ entry *providerCatalogEntry,
+ fetch func(context.Context) ([]llm.ModelInfo, error),
+) ([]llm.ModelInfo, error) {
+ models, err := fetch(ctx)
+
+ a.catalogMu.Lock()
+ if err == nil {
+ entry.models = cloneModelInfo(models)
+ entry.err = nil
+ entry.hasSuccess = true
+ } else if entry.hasSuccess {
+ models = cloneModelInfo(entry.models)
+ err = nil
+ entry.err = nil
+ } else if len(models) > 0 {
+ entry.models = cloneModelInfo(models)
+ entry.err = nil
+ entry.hasSuccess = true
+ err = nil
+ } else {
+ entry.models = nil
+ entry.err = err
+ }
+ entry.expiresAt = a.providerCatalogTime().Add(providerCatalogTTL)
+ entry.ready = true
+ close(entry.done)
+ a.catalogMu.Unlock()
+ return cloneModelInfo(models), err
+}
+
+func providerCatalogScopeFor(providerID string, provider config.Provider) providerCatalogScope {
+ kind := normalizedProviderKind(provider.Kind)
+ baseURL := normalizedProviderBaseURL(kind, provider.BaseURL)
+ return providerCatalogScope{
+ providerID: strings.ToLower(strings.TrimSpace(providerID)),
+ kind: kind,
+ baseURLFingerprint: sha256.Sum256([]byte(baseURL)),
+ credentialFingerprint: providerCredentialFingerprint(provider),
+ apiVersion: strings.TrimSpace(provider.APIVersion),
+ region: strings.ToLower(strings.TrimSpace(provider.Region)),
+ }
+}
+
+func normalizedProviderKind(kind string) string {
+ switch strings.ToLower(strings.TrimSpace(kind)) {
+ case "google":
+ return "gemini"
+ case "claude":
+ return "anthropic"
+ case "responses", "openai-responses":
+ return "codex"
+ default:
+ return strings.ToLower(strings.TrimSpace(kind))
+ }
+}
+
+func normalizedProviderBaseURL(kind, baseURL string) string {
+ baseURL = strings.TrimSpace(baseURL)
+ if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" {
+ parsed.Scheme = strings.ToLower(parsed.Scheme)
+ parsed.Host = strings.ToLower(parsed.Host)
+ parsed.Path = strings.TrimRight(parsed.Path, "/")
+ baseURL = parsed.String()
+ } else {
+ baseURL = strings.TrimRight(baseURL, "/")
+ }
+ if baseURL != "" {
+ if kind == "gemini" {
+ lower := strings.ToLower(baseURL)
+ if (strings.HasSuffix(lower, "/antigravity") || strings.Contains(lower, "/antigravity/")) &&
+ !strings.Contains(lower, "/v1beta") {
+ return baseURL + "/v1beta"
+ }
+ }
+ return baseURL
+ }
+ switch kind {
+ case "openai", "codex":
+ return "https://api.openai.com/v1"
+ case "anthropic":
+ return "https://api.anthropic.com/v1"
+ case "gemini":
+ return "https://generativelanguage.googleapis.com/v1beta"
+ default:
+ return ""
+ }
+}
+
+func providerCredentialFingerprint(provider config.Provider) [sha256.Size]byte {
+ h := sha256.New()
+ writeFingerprintValue(h, provider.APIKey)
+
+ keys := make([]string, 0, len(provider.Headers))
+ for key := range provider.Headers {
+ keys = append(keys, key)
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ left, right := strings.ToLower(keys[i]), strings.ToLower(keys[j])
+ if left == right {
+ return keys[i] < keys[j]
+ }
+ return left < right
+ })
+ for _, key := range keys {
+ writeFingerprintValue(h, strings.ToLower(strings.TrimSpace(key)))
+ writeFingerprintValue(h, provider.Headers[key])
+ }
+
+ var fingerprint [sha256.Size]byte
+ copy(fingerprint[:], h.Sum(nil))
+ return fingerprint
+}
+
+func writeFingerprintValue(h hash.Hash, value string) {
+ var size [8]byte
+ binary.BigEndian.PutUint64(size[:], uint64(len(value)))
+ _, _ = h.Write(size[:])
+ _, _ = h.Write([]byte(value))
+}
+
+func cloneModelInfo(models []llm.ModelInfo) []llm.ModelInfo {
+ return append([]llm.ModelInfo(nil), models...)
+}
+
+func (a *Agent) providerCatalogTime() time.Time {
+ if a.catalogNow != nil {
+ return a.catalogNow()
+ }
+ return time.Now()
+}
diff --git a/internal/agent/model_cache_test.go b/internal/agent/model_cache_test.go
new file mode 100644
index 0000000..df20409
--- /dev/null
+++ b/internal/agent/model_cache_test.go
@@ -0,0 +1,468 @@
+package agent
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/enowdev/antares/internal/config"
+ "github.com/enowdev/antares/internal/llm"
+)
+
+func TestReasoningCapabilityAndModelsShareProviderCatalogueFetch(t *testing.T) {
+ var fetches atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ fetches.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "data": [
+ {"id": "model-a", "reasoning": {"supported_efforts": ["Exact"], "default_effort": "Exact"}}
+ ]
+ }`))
+ }))
+ defer srv.Close()
+
+ a := agentWithConfig(reasoningTestConfig(srv.URL, nil))
+ if _, err := a.ReasoningCapability(context.Background(), "router/model-a"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.Models(context.Background(), "router"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.ReasoningCapability(context.Background(), "router/model-a"); err != nil {
+ t.Fatal(err)
+ }
+ if got := fetches.Load(); got != 1 {
+ t.Fatalf("provider catalogue fetches = %d, want one shared fetch", got)
+ }
+}
+
+func TestModelsCachesCuratedProviderWithoutBroadeningWhitelist(t *testing.T) {
+ var fetches atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ fetches.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "data": [
+ {"id": "listed", "reasoning": {"supported_efforts": ["LOW"], "default_effort": "LOW"}},
+ {"id": "unlisted", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}}
+ ]
+ }`))
+ }))
+ defer srv.Close()
+
+ cfg := reasoningTestConfig(srv.URL, []string{"listed"})
+ a := agentWithConfig(cfg)
+ for i := 0; i < 2; i++ {
+ models, err := a.Models(context.Background(), "router")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(models) != 1 || models[0].ID != "listed" {
+ t.Fatalf("models = %#v, want only listed", models)
+ }
+ }
+ if got := fetches.Load(); got != 1 {
+ t.Fatalf("curated provider catalogue fetches = %d, want one", got)
+ }
+}
+
+func TestModelsConcurrentMissesUseSingleProviderFetch(t *testing.T) {
+ var fetches atomic.Int32
+ arrived := make(chan struct{}, 16)
+ release := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ fetches.Add(1)
+ arrived <- struct{}{}
+ <-release
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`))
+ }))
+ defer srv.Close()
+
+ a := agentWithConfig(reasoningTestConfig(srv.URL, nil))
+ const callers = 16
+ start := make(chan struct{})
+ errs := make(chan error, callers)
+ var wg sync.WaitGroup
+ for i := 0; i < callers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ _, err := a.Models(context.Background(), "router")
+ errs <- err
+ }()
+ }
+ close(start)
+ select {
+ case <-arrived:
+ case <-time.After(2 * time.Second):
+ t.Fatal("provider catalogue fetch did not start")
+ }
+ time.Sleep(50 * time.Millisecond)
+ close(release)
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got := fetches.Load(); got != 1 {
+ t.Fatalf("concurrent provider catalogue fetches = %d, want one", got)
+ }
+}
+
+func TestProviderCatalogueCacheDoesNotShareAcrossCredentials(t *testing.T) {
+ var fetches atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ fetches.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`))
+ }))
+ defer srv.Close()
+
+ cfg := reasoningTestConfig(srv.URL, nil)
+ provider := cfg.Providers["router"]
+ provider.APIKey = "credential-one"
+ cfg.Providers["router"] = provider
+ a := agentWithConfig(cfg)
+ if _, err := a.Models(context.Background(), "router"); err != nil {
+ t.Fatal(err)
+ }
+
+ changed := *cfg
+ changed.Providers = make(map[string]config.Provider, len(cfg.Providers))
+ for id, configured := range cfg.Providers {
+ changed.Providers[id] = configured
+ }
+ provider = changed.Providers["router"]
+ provider.APIKey = "credential-two"
+ changed.Providers["router"] = provider
+ a.SetConfig(&changed)
+ if _, err := a.Models(context.Background(), "router"); err != nil {
+ t.Fatal(err)
+ }
+
+ if got := fetches.Load(); got != 2 {
+ t.Fatalf("provider catalogue fetches after credential change = %d, want two", got)
+ }
+}
+
+func TestProviderCatalogueCacheExpiresAfterFiveMinutes(t *testing.T) {
+ var fetches atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ fetches.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`))
+ }))
+ defer srv.Close()
+
+ now := time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC)
+ a := agentWithConfig(reasoningTestConfig(srv.URL, nil))
+ a.catalogNow = func() time.Time { return now }
+
+ if _, err := a.Models(context.Background(), "router"); err != nil {
+ t.Fatal(err)
+ }
+ now = now.Add(4*time.Minute + 59*time.Second)
+ if _, err := a.Models(context.Background(), "router"); err != nil {
+ t.Fatal(err)
+ }
+ if got := fetches.Load(); got != 1 {
+ t.Fatalf("provider catalogue fetches before TTL = %d, want one", got)
+ }
+
+ now = now.Add(2 * time.Second)
+ if _, err := a.Models(context.Background(), "router"); err != nil {
+ t.Fatal(err)
+ }
+ if got := fetches.Load(); got != 2 {
+ t.Fatalf("provider catalogue fetches after TTL = %d, want two", got)
+ }
+}
+
+func TestProviderCatalogueCacheScopesNormalizedBaseURLAndProviderIdentity(t *testing.T) {
+ var firstFetches, secondFetches atomic.Int32
+ newServer := func(fetches *atomic.Int32) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ fetches.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`))
+ }))
+ }
+ first := newServer(&firstFetches)
+ defer first.Close()
+ second := newServer(&secondFetches)
+ defer second.Close()
+
+ cfg := reasoningTestConfig(first.URL, nil)
+ a := agentWithConfig(cfg)
+ if _, err := a.Models(context.Background(), "router"); err != nil {
+ t.Fatal(err)
+ }
+
+ withTrailingSlash := *cfg
+ withTrailingSlash.Providers = map[string]config.Provider{}
+ provider := cfg.Providers["router"]
+ provider.BaseURL = first.URL + "/"
+ withTrailingSlash.Providers["router"] = provider
+ a.SetConfig(&withTrailingSlash)
+ if _, err := a.Models(context.Background(), "router"); err != nil {
+ t.Fatal(err)
+ }
+ if got := firstFetches.Load(); got != 1 {
+ t.Fatalf("normalized equivalent base URL fetched %d times, want one", got)
+ }
+
+ changedBase := withTrailingSlash
+ changedBase.Providers = map[string]config.Provider{}
+ provider.BaseURL = second.URL
+ changedBase.Providers["router"] = provider
+ a.SetConfig(&changedBase)
+ if _, err := a.Models(context.Background(), "router"); err != nil {
+ t.Fatal(err)
+ }
+
+ changedIdentity := changedBase
+ changedIdentity.Providers = map[string]config.Provider{
+ "alternate": provider,
+ }
+ a.SetConfig(&changedIdentity)
+ if _, err := a.Models(context.Background(), "alternate"); err != nil {
+ t.Fatal(err)
+ }
+ if got := secondFetches.Load(); got != 2 {
+ t.Fatalf("changed base/identity fetches = %d, want one per distinct scope", got)
+ }
+}
+
+func TestProviderCatalogueScopeDoesNotRetainRawCredentials(t *testing.T) {
+ const (
+ apiSecret = "RAW-API-SECRET"
+ headerSecret = "RAW-HEADER-SECRET"
+ urlSecret = "RAW-URL-SECRET"
+ )
+ scope := providerCatalogScopeFor("router", config.Provider{
+ Kind: "openai-compatible",
+ BaseURL: "https://example.test/v1?token=" + urlSecret,
+ APIKey: apiSecret,
+ Headers: map[string]string{"Authorization": "Bearer " + headerSecret},
+ })
+ rendered := fmt.Sprintf("%#v", scope)
+ for _, secret := range []string{apiSecret, headerSecret, urlSecret} {
+ if strings.Contains(rendered, secret) {
+ t.Fatalf("cache scope retained raw credential %q", secret)
+ }
+ }
+}
+
+func TestReasoningCapabilityUsesStaleCatalogueWhenRefreshFails(t *testing.T) {
+ var (
+ fetches atomic.Int32
+ outage atomic.Bool
+ )
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ fetches.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ if outage.Load() {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte(`{"error":{"message":"temporary catalogue outage"}}`))
+ return
+ }
+ _, _ = w.Write([]byte(`{
+ "data": [
+ {"id": "model-a", "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"}}
+ ]
+ }`))
+ }))
+ defer srv.Close()
+
+ now := time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC)
+ a := agentWithConfig(reasoningTestConfig(srv.URL, nil))
+ a.catalogNow = func() time.Time { return now }
+
+ if err := a.ValidateReasoningEffort(context.Background(), "router/model-a", "MiXeD"); err != nil {
+ t.Fatal(err)
+ }
+ now = now.Add(5*time.Minute + time.Second)
+ outage.Store(true)
+ if err := a.ValidateReasoningEffort(context.Background(), "router/model-a", "MiXeD"); err != nil {
+ t.Fatalf("stale live value rejected after refresh outage: %v", err)
+ }
+ if err := a.ValidateReasoningEffort(context.Background(), "router/model-a", "MiXeD"); err != nil {
+ t.Fatalf("cached stale live value rejected: %v", err)
+ }
+ if got := fetches.Load(); got != 2 {
+ t.Fatalf("provider catalogue fetches = %d, want initial load plus one failed refresh", got)
+ }
+}
+
+type metadataUnavailableMarker interface {
+ ReasoningMetadataUnavailable() bool
+}
+
+func TestExplicitReasoningReturnsDistinctBoundedErrorOnFirstCatalogueOutage(t *testing.T) {
+ for _, curated := range []bool{false, true} {
+ t.Run(map[bool]string{false: "live", true: "curated"}[curated], func(t *testing.T) {
+ var fetches atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ fetches.Add(1)
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte(`{"error":{"message":"SECRET-UPSTREAM-DIAGNOSTIC"}}`))
+ }))
+ defer srv.Close()
+
+ var models []string
+ if curated {
+ models = []string{"model-a"}
+ }
+ cfg := reasoningTestConfig(srv.URL, models)
+ cfg.Agent.ReasoningEffort = "LEGACY-STORED"
+ a := agentWithConfig(cfg)
+
+ const submitted = "SECRET-SUBMITTED-EFFORT"
+ for i := 0; i < 2; i++ {
+ err := a.ValidateReasoningEffort(context.Background(), "router/model-a", submitted)
+ if err == nil {
+ t.Fatal("expected metadata-unavailable error")
+ }
+ if llm.IsUnsupportedReasoningEffort(err) {
+ t.Fatalf("first catalogue outage misreported as unsupported: %v", err)
+ }
+ var unavailable metadataUnavailableMarker
+ if !errors.As(err, &unavailable) || !unavailable.ReasoningMetadataUnavailable() {
+ t.Fatalf("error = %T %v, want distinct metadata-unavailable error", err, err)
+ }
+ if len(err.Error()) > 200 {
+ t.Fatalf("metadata error is unbounded (%d bytes)", len(err.Error()))
+ }
+ if strings.Contains(err.Error(), submitted) || strings.Contains(err.Error(), "SECRET-UPSTREAM-DIAGNOSTIC") {
+ t.Fatalf("metadata error exposes submitted or upstream value: %v", err)
+ }
+ }
+ if got := fetches.Load(); got != 1 {
+ t.Fatalf("first-outage provider catalogue fetches = %d, want one cached failure", got)
+ }
+
+ got, err := a.resolveReasoning(context.Background(), reasoningInput{ModelRef: "router/model-a"})
+ if err != nil {
+ t.Fatalf("stored legacy value returned metadata error: %v", err)
+ }
+ if got.Value != "" || got.DiscardedLegacy != "LEGACY-STORED" {
+ t.Fatalf("stored resolution = %+v, want Auto with legacy notice", got)
+ }
+ })
+ }
+}
+
+func TestRunFirstCatalogueOutageRejectsExplicitButAllowsStoredAutoFallback(t *testing.T) {
+ var (
+ catalogFetches atomic.Int32
+ chatCalls atomic.Int32
+ )
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"):
+ catalogFetches.Add(1)
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte(`{"error":{"message":"SECRET-UPSTREAM-DIAGNOSTIC"}}`))
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
+ chatCalls.Add(1)
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ cfg := reasoningTestConfig(srv.URL, nil)
+ cfg.Agent.ReasoningEffort = "LEGACY-STORED"
+ cfg.Streaming.Enabled = false
+ a := newReasoningRunAgent(t, cfg)
+
+ const submitted = "SECRET-SUBMITTED-EFFORT"
+ _, err := a.Run(context.Background(), Request{
+ Message: "explicit",
+ Quiet: true,
+ MaxTurns: 1,
+ ReasoningEffort: submitted,
+ }, nil)
+ if err == nil || !IsReasoningMetadataUnavailable(err) {
+ t.Fatalf("explicit run error = %T %v, want metadata unavailable", err, err)
+ }
+ if strings.Contains(err.Error(), submitted) || strings.Contains(err.Error(), "SECRET-UPSTREAM-DIAGNOSTIC") {
+ t.Fatalf("explicit run error exposes submitted or upstream value: %v", err)
+ }
+ if got := chatCalls.Load(); got != 0 {
+ t.Fatalf("chat calls after explicit metadata outage = %d, want zero", got)
+ }
+
+ var discardedNotices int
+ result, err := a.Run(context.Background(), Request{
+ Message: "stored",
+ Quiet: true,
+ MaxTurns: 1,
+ }, func(event Event) error {
+ if event.Type == EventNotice && strings.Contains(event.Message, "LEGACY-STORED") {
+ discardedNotices++
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Reply != "ok" {
+ t.Fatalf("stored fallback reply = %q", result.Reply)
+ }
+ if discardedNotices != 1 {
+ t.Fatalf("stored fallback notices = %d, want one", discardedNotices)
+ }
+ if got := chatCalls.Load(); got != 1 {
+ t.Fatalf("chat calls after stored metadata outage = %d, want one Auto request", got)
+ }
+ if got := catalogFetches.Load(); got != 1 {
+ t.Fatalf("catalogue fetches across explicit and stored runs = %d, want one cached failure", got)
+ }
+}
diff --git a/internal/agent/reasoning.go b/internal/agent/reasoning.go
index 3c94c61..f43730e 100644
--- a/internal/agent/reasoning.go
+++ b/internal/agent/reasoning.go
@@ -2,6 +2,7 @@ package agent
import (
"context"
+ "errors"
"strings"
"github.com/enowdev/antares/internal/config"
@@ -22,6 +23,23 @@ type reasoningResolution struct {
DiscardedLegacy string
}
+// ReasoningMetadataUnavailableError distinguishes an unavailable provider
+// catalogue from a model that is known not to support a submitted value. Its
+// message is deliberately constant and bounded: upstream diagnostics and the
+// submitted value are never exposed.
+type ReasoningMetadataUnavailableError struct{}
+
+func (*ReasoningMetadataUnavailableError) Error() string {
+ return "reasoning metadata is temporarily unavailable; use Auto or retry"
+}
+
+func (*ReasoningMetadataUnavailableError) ReasoningMetadataUnavailable() bool { return true }
+
+func IsReasoningMetadataUnavailable(err error) bool {
+ var unavailable *ReasoningMetadataUnavailableError
+ return errors.As(err, &unavailable)
+}
+
type reasoningTarget struct {
providerID string
model string
@@ -30,18 +48,19 @@ type reasoningTarget struct {
// ReasoningCapability returns the best model-specific reasoning metadata the
// configured provider can supply. Documented direct-provider metadata avoids a
-// network dependency; dynamic providers are queried through Agent.Models.
+// network dependency; dynamic providers use the Agent-owned cached catalogue.
func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm.ReasoningCapability, error) {
target := a.reasoningTarget(modelRef)
if capability := staticReasoningCapability(target); capability != nil {
return capability, nil
}
- models, err := a.Models(ctx, target.providerID)
+ models, err := a.modelsForProvider(ctx, target.providerID, target.provider)
if err != nil {
- // Model catalogues are optional. Unknown metadata means Auto-only; it
- // must not make an otherwise valid chat depend on a /models endpoint.
- return nil, nil
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ return nil, &ReasoningMetadataUnavailableError{}
}
for _, model := range models {
if model.ID == target.model && model.ReasoningCapability != nil {
@@ -54,6 +73,9 @@ func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm.
// ValidateReasoningEffort validates an explicit value without including the
// submitted value in any error.
func (a *Agent) ValidateReasoningEffort(ctx context.Context, modelRef, effort string) error {
+ if effort == "" {
+ return nil
+ }
capability, err := a.ReasoningCapability(ctx, modelRef)
if err != nil {
return err
@@ -65,8 +87,28 @@ func (a *Agent) ValidateReasoningEffort(ctx context.Context, modelRef, effort st
// values. An invalid explicit override is an error; invalid stored values are
// skipped in role, agent, model order so old configuration degrades to Auto.
func (a *Agent) resolveReasoning(ctx context.Context, in reasoningInput) (reasoningResolution, error) {
+ agentValue := in.Agent
+ if agentValue == "" {
+ agentValue = a.config().Agent.ReasoningEffort
+ }
+ modelValue := in.Model
+ if modelValue == "" {
+ modelValue = a.config().Model.ReasoningEffort
+ }
+ storedValues := []string{in.Role, agentValue, modelValue}
+
capability, err := a.ReasoningCapability(ctx, in.ModelRef)
if err != nil {
+ if IsReasoningMetadataUnavailable(err) && in.Explicit == "" {
+ resolution := reasoningResolution{}
+ for _, stored := range storedValues {
+ if stored != "" {
+ resolution.DiscardedLegacy = stored
+ break
+ }
+ }
+ return resolution, nil
+ }
return reasoningResolution{}, err
}
resolution := reasoningResolution{Capability: capability}
@@ -80,15 +122,7 @@ func (a *Agent) resolveReasoning(ctx context.Context, in reasoningInput) (reason
return resolution, nil
}
- agentValue := in.Agent
- if agentValue == "" {
- agentValue = a.config().Agent.ReasoningEffort
- }
- modelValue := in.Model
- if modelValue == "" {
- modelValue = a.config().Model.ReasoningEffort
- }
- for _, stored := range []string{in.Role, agentValue, modelValue} {
+ for _, stored := range storedValues {
if stored == "" {
continue
}
diff --git a/internal/agent/reasoning_test.go b/internal/agent/reasoning_test.go
index cd1ee25..30b1272 100644
--- a/internal/agent/reasoning_test.go
+++ b/internal/agent/reasoning_test.go
@@ -291,11 +291,15 @@ func TestRunResolvesStoredRoleReasoningOnceBeforeTurnLoop(t *testing.T) {
}
func TestRunCarriesMatchingReasoningCapabilityThroughFallbackEntries(t *testing.T) {
- var primaryChats atomic.Int32
+ var (
+ primaryModels atomic.Int32
+ primaryChats atomic.Int32
+ )
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"):
+ primaryModels.Add(1)
_, _ = w.Write([]byte(`{
"data": [
{"id": "primary-model", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}}
@@ -314,11 +318,13 @@ func TestRunCarriesMatchingReasoningCapabilityThroughFallbackEntries(t *testing.
var (
mu sync.Mutex
fallbackEffort any
+ fallbackModels atomic.Int32
)
fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"):
+ fallbackModels.Add(1)
_, _ = w.Write([]byte(`{
"data": [
{"id": "fallback-model", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}}
@@ -375,6 +381,12 @@ func TestRunCarriesMatchingReasoningCapabilityThroughFallbackEntries(t *testing.
if got := primaryChats.Load(); got != 1 {
t.Fatalf("primary chat calls = %d, want one", got)
}
+ if got := primaryModels.Load(); got != 1 {
+ t.Fatalf("primary catalogue fetches = %d, want one shared by fallback setup and run resolution", got)
+ }
+ if got := fallbackModels.Load(); got != 1 {
+ t.Fatalf("fallback catalogue fetches = %d, want one", got)
+ }
mu.Lock()
defer mu.Unlock()
if fallbackEffort != "HIGH" {
From 516a12cf4614b16bb0394b3fcc5aa67f3319c24c Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 02:21:57 +0700
Subject: [PATCH 08/41] Fix provider cache cancellation and adapter families
Keep catalogue fetches independent of individual waiters and retain resolved adapter identity without widening explicit Codex capabilities.
Co-authored-by: Cursor
---
internal/agent/client.go | 26 +++--
internal/agent/model_cache.go | 65 ++++++++----
internal/agent/model_cache_test.go | 88 ++++++++++++++++
internal/agent/reasoning_test.go | 157 +++++++++++++++++++++++++++++
4 files changed, 310 insertions(+), 26 deletions(-)
diff --git a/internal/agent/client.go b/internal/agent/client.go
index 251fd74..b1de216 100644
--- a/internal/agent/client.go
+++ b/internal/agent/client.go
@@ -177,20 +177,20 @@ func (a *Agent) Models(ctx context.Context, providerID string) ([]llm.ModelInfo,
}
func (a *Agent) modelsForProvider(ctx context.Context, id string, p config.Provider) ([]llm.ModelInfo, error) {
- live, ferr := a.cachedProviderCatalog(ctx, id, p, func(fetchCtx context.Context) ([]llm.ModelInfo, error) {
+ live, adapterKind, ferr := a.cachedProviderCatalog(ctx, id, p, func(fetchCtx context.Context) ([]llm.ModelInfo, string, error) {
client, err := llm.New(llm.Options{
Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey,
Headers: p.Headers, ProviderID: id, Timeout: 60 * time.Second, APIVersion: p.APIVersion, Region: p.Region,
})
if err != nil {
- return nil, err
+ return nil, "", err
}
- fetchCtx, cancel := context.WithTimeout(fetchCtx, 45*time.Second)
- defer cancel()
- return client.Models(fetchCtx)
+ models, err := client.Models(fetchCtx)
+ return models, client.Kind(), err
})
+ reasoningKind := reasoningFamilyForAdapter(p.Kind, adapterKind)
if len(p.Models) > 0 {
- return curatedModelsWithReasoning(id, p, live, p.Kind), ferr
+ return curatedModelsWithReasoning(id, p, live, reasoningKind), ferr
}
if ferr != nil && len(live) == 0 {
return nil, ferr
@@ -201,11 +201,25 @@ func (a *Agent) modelsForProvider(ctx context.Context, id string, p config.Provi
continue
}
target := reasoningTarget{providerID: id, model: live[i].ID, provider: p}
+ target.provider.Kind = reasoningKind
live[i] = live[i].WithReasoningCapability(staticReasoningCapability(target))
}
return live, nil
}
+func reasoningFamilyForAdapter(configuredKind, adapterKind string) string {
+ // Codex/Responses intentionally owns a narrower static table even though
+ // codexClient.Kind reports "openai" for its shared transport family.
+ switch strings.ToLower(strings.TrimSpace(configuredKind)) {
+ case "codex", "responses", "openai-responses":
+ return "codex"
+ }
+ if adapterKind == "" {
+ return normalizedProviderKind(configuredKind)
+ }
+ return normalizedProviderKind(adapterKind)
+}
+
func curatedModelsWithReasoning(id string, p config.Provider, live []llm.ModelInfo, kind string) []llm.ModelInfo {
liveByID := make(map[string]llm.ModelInfo, len(live))
for _, model := range live {
diff --git a/internal/agent/model_cache.go b/internal/agent/model_cache.go
index 9fbd5ce..f25c8c2 100644
--- a/internal/agent/model_cache.go
+++ b/internal/agent/model_cache.go
@@ -24,24 +24,33 @@ type providerCatalogScope struct {
}
type providerCatalogEntry struct {
- done chan struct{}
- ready bool
- hasSuccess bool
- expiresAt time.Time
- models []llm.ModelInfo
- err error
+ done chan struct{}
+ ready bool
+ hasSuccess bool
+ expiresAt time.Time
+ models []llm.ModelInfo
+ adapterKind string
+ err error
}
-const providerCatalogTTL = 5 * time.Minute
+const (
+ providerCatalogTTL = 5 * time.Minute
+ providerCatalogFetchTimeout = 45 * time.Second
+)
func (a *Agent) cachedProviderCatalog(
ctx context.Context,
providerID string,
provider config.Provider,
- fetch func(context.Context) ([]llm.ModelInfo, error),
-) ([]llm.ModelInfo, error) {
+ fetch func(context.Context) ([]llm.ModelInfo, string, error),
+) ([]llm.ModelInfo, string, error) {
scope := providerCatalogScopeFor(providerID, provider)
for {
+ if err := ctx.Err(); err != nil {
+ return nil, "", err
+ }
+
+ var startFetch bool
a.catalogMu.Lock()
if a.catalogCache == nil {
a.catalogCache = make(map[providerCatalogScope]*providerCatalogEntry)
@@ -49,62 +58,78 @@ func (a *Agent) cachedProviderCatalog(
if entry, ok := a.catalogCache[scope]; ok {
if entry.ready {
if a.providerCatalogTime().Before(entry.expiresAt) {
- models, err := cloneModelInfo(entry.models), entry.err
+ models, adapterKind, err := cloneModelInfo(entry.models), entry.adapterKind, entry.err
a.catalogMu.Unlock()
- return models, err
+ return models, adapterKind, err
}
entry.ready = false
entry.done = make(chan struct{})
- a.catalogMu.Unlock()
- return a.refreshProviderCatalog(ctx, entry, fetch)
+ startFetch = true
}
done := entry.done
a.catalogMu.Unlock()
+ if startFetch {
+ go a.refreshProviderCatalog(entry, fetch)
+ }
select {
case <-done:
continue
case <-ctx.Done():
- return nil, ctx.Err()
+ return nil, "", ctx.Err()
}
}
entry := &providerCatalogEntry{done: make(chan struct{})}
a.catalogCache[scope] = entry
+ done := entry.done
a.catalogMu.Unlock()
- return a.refreshProviderCatalog(ctx, entry, fetch)
+ go a.refreshProviderCatalog(entry, fetch)
+ select {
+ case <-done:
+ continue
+ case <-ctx.Done():
+ return nil, "", ctx.Err()
+ }
}
}
func (a *Agent) refreshProviderCatalog(
- ctx context.Context,
entry *providerCatalogEntry,
- fetch func(context.Context) ([]llm.ModelInfo, error),
-) ([]llm.ModelInfo, error) {
- models, err := fetch(ctx)
+ fetch func(context.Context) ([]llm.ModelInfo, string, error),
+) {
+ // The shared fetch belongs to the cache entry, not to whichever caller won
+ // the miss race. Individual waiters may cancel without aborting or poisoning
+ // the provider scope; this independent context bounds orphaned work.
+ ctx, cancel := context.WithTimeout(context.Background(), providerCatalogFetchTimeout)
+ defer cancel()
+ models, adapterKind, err := fetch(ctx)
a.catalogMu.Lock()
if err == nil {
entry.models = cloneModelInfo(models)
+ entry.adapterKind = adapterKind
entry.err = nil
entry.hasSuccess = true
} else if entry.hasSuccess {
models = cloneModelInfo(entry.models)
+ adapterKind = entry.adapterKind
err = nil
entry.err = nil
} else if len(models) > 0 {
entry.models = cloneModelInfo(models)
+ entry.adapterKind = adapterKind
entry.err = nil
entry.hasSuccess = true
err = nil
} else {
entry.models = nil
+ entry.adapterKind = adapterKind
entry.err = err
}
entry.expiresAt = a.providerCatalogTime().Add(providerCatalogTTL)
entry.ready = true
close(entry.done)
a.catalogMu.Unlock()
- return cloneModelInfo(models), err
}
func providerCatalogScopeFor(providerID string, provider config.Provider) providerCatalogScope {
diff --git a/internal/agent/model_cache_test.go b/internal/agent/model_cache_test.go
index df20409..f470c3f 100644
--- a/internal/agent/model_cache_test.go
+++ b/internal/agent/model_cache_test.go
@@ -133,6 +133,94 @@ func TestModelsConcurrentMissesUseSingleProviderFetch(t *testing.T) {
}
}
+func TestProviderCatalogueLeaderCancellationDoesNotPoisonSharedFetch(t *testing.T) {
+ var (
+ fetches atomic.Int32
+ startedOnce sync.Once
+ releaseOnce sync.Once
+ )
+ started := make(chan struct{})
+ release := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ fetches.Add(1)
+ startedOnce.Do(func() { close(started) })
+ select {
+ case <-release:
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`))
+ case <-r.Context().Done():
+ }
+ }))
+ defer srv.Close()
+ defer releaseOnce.Do(func() { close(release) })
+
+ a := agentWithConfig(reasoningTestConfig(srv.URL, nil))
+ leaderCtx, cancelLeader := context.WithCancel(context.Background())
+ leaderResult := make(chan error, 1)
+ go func() {
+ _, err := a.Models(leaderCtx, "router")
+ leaderResult <- err
+ }()
+ select {
+ case <-started:
+ case <-time.After(2 * time.Second):
+ t.Fatal("leader provider catalogue fetch did not start")
+ }
+
+ waiterCalling := make(chan struct{})
+ waiterResult := make(chan struct {
+ models []llm.ModelInfo
+ err error
+ }, 1)
+ go func() {
+ close(waiterCalling)
+ models, err := a.Models(context.Background(), "router")
+ waiterResult <- struct {
+ models []llm.ModelInfo
+ err error
+ }{models: models, err: err}
+ }()
+ <-waiterCalling
+
+ cancelLeader()
+ select {
+ case err := <-leaderResult:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("leader error = %v, want context.Canceled", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("canceled leader did not return promptly")
+ }
+
+ releaseOnce.Do(func() { close(release) })
+ select {
+ case got := <-waiterResult:
+ if got.err != nil {
+ t.Fatalf("healthy waiter inherited leader cancellation: %v", got.err)
+ }
+ if len(got.models) != 1 || got.models[0].ID != "model-a" {
+ t.Fatalf("healthy waiter models = %#v, want model-a", got.models)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("healthy waiter did not receive shared catalogue")
+ }
+
+ models, err := a.Models(context.Background(), "router")
+ if err != nil {
+ t.Fatalf("healthy successor inherited leader cancellation: %v", err)
+ }
+ if len(models) != 1 || models[0].ID != "model-a" {
+ t.Fatalf("healthy successor models = %#v, want cached model-a", models)
+ }
+ if got := fetches.Load(); got != 1 {
+ t.Fatalf("provider catalogue fetches = %d, want one shared fetch", got)
+ }
+}
+
func TestProviderCatalogueCacheDoesNotShareAcrossCredentials(t *testing.T) {
var fetches atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/agent/reasoning_test.go b/internal/agent/reasoning_test.go
index 30b1272..370624d 100644
--- a/internal/agent/reasoning_test.go
+++ b/internal/agent/reasoning_test.go
@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
+ "io"
"net/http"
"net/http/httptest"
"strings"
@@ -215,6 +216,162 @@ func TestModelsFallsBackToStaticCapabilityForCuratedModel(t *testing.T) {
}
}
+type modelCatalogueRoundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f modelCatalogueRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
+
+func installStaticFamilyModelCatalogue(t *testing.T) {
+ t.Helper()
+ previous := http.DefaultTransport
+ http.DefaultTransport = modelCatalogueRoundTripFunc(func(req *http.Request) (*http.Response, error) {
+ status := http.StatusOK
+ body := `{"data":[{"id":"gpt-5"},{"id":"gpt-5.3-codex"}]}`
+ if req.Method != http.MethodGet || !strings.HasSuffix(req.URL.Path, "/models") {
+ status = http.StatusNotFound
+ body = `{"error":{"message":"not found"}}`
+ }
+ return &http.Response{
+ StatusCode: status,
+ Status: http.StatusText(status),
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(body)),
+ Request: req,
+ }, nil
+ })
+ t.Cleanup(func() {
+ http.DefaultTransport = previous
+ })
+}
+
+func assertReasoningValues(t *testing.T, capability *llm.ReasoningCapability, want ...string) {
+ t.Helper()
+ if capability == nil {
+ t.Fatalf("capability = nil, want values %v", want)
+ }
+ if len(capability.Values) != len(want) {
+ t.Fatalf("values = %#v, want %v", capability.Values, want)
+ }
+ for i, value := range capability.Values {
+ if value.Value != want[i] {
+ t.Fatalf("values = %#v, want %v", capability.Values, want)
+ }
+ }
+}
+
+func modelInfoByID(t *testing.T, models []llm.ModelInfo, id string) llm.ModelInfo {
+ t.Helper()
+ for _, model := range models {
+ if model.ID == id {
+ return model
+ }
+ }
+ t.Fatalf("models = %#v, want %q", models, id)
+ return llm.ModelInfo{}
+}
+
+func TestModelsUsesResolvedOpenAIAdapterFamilyForDirectOpenAI(t *testing.T) {
+ installStaticFamilyModelCatalogue(t)
+ for _, configuredKind := range []string{"openai", "openai-compatible"} {
+ t.Run(configuredKind, func(t *testing.T) {
+ cfg := config.Default()
+ cfg.Model.Provider = "openai"
+ cfg.Model.Default = "gpt-5"
+ cfg.Providers = map[string]config.Provider{
+ "openai": {
+ Kind: configuredKind,
+ BaseURL: "https://api.openai.com/v1",
+ Enabled: true,
+ },
+ }
+
+ a := agentWithConfig(cfg)
+ models, err := a.Models(context.Background(), "openai")
+ if err != nil {
+ t.Fatal(err)
+ }
+ model := modelInfoByID(t, models, "gpt-5")
+ assertReasoningValues(t, model.ReasoningCapability, "minimal", "low", "medium", "high")
+ capability, err := a.ReasoningCapability(context.Background(), "openai/gpt-5")
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertReasoningValues(t, capability, "minimal", "low", "medium", "high")
+ })
+ }
+}
+
+func TestModelsKeepsExplicitCodexFamilyForResponsesAlias(t *testing.T) {
+ installStaticFamilyModelCatalogue(t)
+ for _, configuredKind := range []string{"codex", "responses", "openai-responses"} {
+ t.Run(configuredKind, func(t *testing.T) {
+ cfg := config.Default()
+ cfg.Model.Provider = "openai"
+ cfg.Model.Default = "gpt-5.3-codex"
+ cfg.Providers = map[string]config.Provider{
+ "openai": {
+ Kind: configuredKind,
+ BaseURL: "https://api.openai.com/v1",
+ Enabled: true,
+ Models: []string{"gpt-5.3-codex", "gpt-5"},
+ },
+ }
+
+ a := agentWithConfig(cfg)
+ models, err := a.Models(context.Background(), "openai")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(models) != 2 {
+ t.Fatalf("models = %#v, want two curated models", models)
+ }
+ codex := modelInfoByID(t, models, "gpt-5.3-codex")
+ assertReasoningValues(t, codex.ReasoningCapability, "low", "medium", "high", "xhigh")
+ openAI := modelInfoByID(t, models, "gpt-5")
+ if openAI.ReasoningCapability != nil {
+ t.Fatalf("%s alias broadened gpt-5 to OpenAI capability: %#v", configuredKind, openAI.ReasoningCapability)
+ }
+ capability, err := a.ReasoningCapability(context.Background(), "openai/gpt-5.3-codex")
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertReasoningValues(t, capability, "low", "medium", "high", "xhigh")
+ })
+ }
+}
+
+func TestModelsKeepsUnknownCompatibleEndpointAutoOnly(t *testing.T) {
+ installStaticFamilyModelCatalogue(t)
+ cfg := config.Default()
+ cfg.Model.Provider = "custom"
+ cfg.Model.Default = "gpt-5"
+ cfg.Providers = map[string]config.Provider{
+ "custom": {
+ Kind: "custom",
+ BaseURL: "https://gateway.example.test/v1",
+ Enabled: true,
+ },
+ }
+
+ a := agentWithConfig(cfg)
+ models, err := a.Models(context.Background(), "custom")
+ if err != nil {
+ t.Fatal(err)
+ }
+ model := modelInfoByID(t, models, "gpt-5")
+ if model.ReasoningCapability != nil {
+ t.Fatalf("capability = %#v, want Auto-only", model.ReasoningCapability)
+ }
+ capability, err := a.ReasoningCapability(context.Background(), "custom/gpt-5")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if capability != nil {
+ t.Fatalf("resolved capability = %#v, want Auto-only", capability)
+ }
+}
+
func TestRunResolvesStoredRoleReasoningOnceBeforeTurnLoop(t *testing.T) {
var (
mu sync.Mutex
From e3e2869e415a57642de2160e94b6a23e778dd2bb Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 02:35:34 +0700
Subject: [PATCH 09/41] Validate reasoning at configuration boundaries
Co-authored-by: Cursor
---
internal/config/load.go | 15 +-
internal/config/schema.go | 148 +++++-----
internal/config/schema_reasoning_test.go | 58 ++++
internal/server/handlers_chat.go | 6 +
internal/server/handlers_config.go | 29 +-
internal/server/handlers_providers.go | 16 +-
internal/server/handlers_roles.go | 16 +-
internal/server/reasoning.go | 60 ++++
internal/server/reasoning_test.go | 347 +++++++++++++++++++++++
9 files changed, 614 insertions(+), 81 deletions(-)
create mode 100644 internal/config/schema_reasoning_test.go
create mode 100644 internal/server/reasoning.go
create mode 100644 internal/server/reasoning_test.go
diff --git a/internal/config/load.go b/internal/config/load.go
index 955be60..d50d91e 100644
--- a/internal/config/load.go
+++ b/internal/config/load.go
@@ -107,11 +107,20 @@ func Raw() (string, error) {
return string(b), err
}
-// SaveRaw validates then writes YAML text supplied by the dashboard editor.
-func SaveRaw(text string) error {
+// ParseRaw validates YAML text supplied by the dashboard editor without
+// changing either the active config file or the in-memory config cache.
+func ParseRaw(text string) (*Config, error) {
cfg := Default()
if err := yaml.Unmarshal([]byte(text), cfg); err != nil {
- return fmt.Errorf("invalid YAML: %w", err)
+ return nil, fmt.Errorf("invalid YAML: %w", err)
+ }
+ return cfg, nil
+}
+
+// SaveRaw validates then writes YAML text supplied by the dashboard editor.
+func SaveRaw(text string) error {
+ if _, err := ParseRaw(text); err != nil {
+ return err
}
if err := os.MkdirAll(filepath.Dir(ConfigFile()), 0o700); err != nil {
return err
diff --git a/internal/config/schema.go b/internal/config/schema.go
index 6a98964..7dc1b33 100644
--- a/internal/config/schema.go
+++ b/internal/config/schema.go
@@ -21,7 +21,11 @@ type Field struct {
Default any `json:"default"`
Secret bool `json:"secret"`
Enum []string `json:"enum,omitempty"`
- Help string `json:"help,omitempty"`
+ // OptionsSource names dynamic option metadata supplied outside the static
+ // schema. Reasoning values are model-specific, so the dashboard resolves
+ // them from the selected model's capability instead of a fixed enum.
+ OptionsSource string `json:"options_source,omitempty"`
+ Help string `json:"help,omitempty"`
}
// Tiers a field can belong to.
@@ -48,43 +52,43 @@ var essential = map[string]bool{
// common holds the settings people actually revisit. Everything not listed
// here or above is treated as advanced.
var common = map[string]bool{
- "model.temperature": true,
- "model.max_tokens": true,
- "model.context_window": true,
- "model.reasoning_effort": true,
- "model.auxiliary": true,
- "agent.max_turns": true,
- "agent.personality": true,
- "agent.system_prompt_extra": true,
- "agent.timezone": true,
- "tools.approval_mode": true,
- "tools.web_search.provider": true,
- "tools.web_search.api_key": true,
- "terminal.backend": true,
- "terminal.cwd": true,
- "terminal.timeout": true,
- "memory.memory_enabled": true,
- "memory.user_profile_enabled": true,
- "rag.embed_model": true,
- "rag.embed_provider": true,
- "rag.rerank_mode": true,
- "rag.per_user": true,
- "skills.enabled": true,
- "skills.auto_create": true,
- "cron.enabled": true,
- "cron.timezone": true,
- "gateway.enabled": true,
- "gateway.telegram.enabled": true,
- "gateway.discord.enabled": true,
- "mcp.enabled": true,
- "compression.enabled": true,
- "streaming.enabled": true,
- "delegation.enabled": true,
- "display.show_reasoning": true,
- "display.tool_progress": true,
- "display.max_live_reasoning_chars": true,
- "logging.level": true,
- "server.host": true,
+ "model.temperature": true,
+ "model.max_tokens": true,
+ "model.context_window": true,
+ "model.reasoning_effort": true,
+ "model.auxiliary": true,
+ "agent.max_turns": true,
+ "agent.personality": true,
+ "agent.system_prompt_extra": true,
+ "agent.timezone": true,
+ "tools.approval_mode": true,
+ "tools.web_search.provider": true,
+ "tools.web_search.api_key": true,
+ "terminal.backend": true,
+ "terminal.cwd": true,
+ "terminal.timeout": true,
+ "memory.memory_enabled": true,
+ "memory.user_profile_enabled": true,
+ "rag.embed_model": true,
+ "rag.embed_provider": true,
+ "rag.rerank_mode": true,
+ "rag.per_user": true,
+ "skills.enabled": true,
+ "skills.auto_create": true,
+ "cron.enabled": true,
+ "cron.timezone": true,
+ "gateway.enabled": true,
+ "gateway.telegram.enabled": true,
+ "gateway.discord.enabled": true,
+ "mcp.enabled": true,
+ "compression.enabled": true,
+ "streaming.enabled": true,
+ "delegation.enabled": true,
+ "display.show_reasoning": true,
+ "display.tool_progress": true,
+ "display.max_live_reasoning_chars": true,
+ "logging.level": true,
+ "server.host": true,
}
func tierFor(path string) string {
@@ -115,34 +119,37 @@ var enums = map[string][]string{
"session_reset.mode": {"never", "idle", "daily"},
"display.theme": {"system", "light", "dark"},
"logging.level": {"debug", "info", "warn", "error"},
- "agent.reasoning_effort": {"none", "low", "medium", "high"},
- "model.reasoning_effort": {"none", "low", "medium", "high"},
"tools.web_search.provider": {"browser", "brave", "tavily", "searxng", "none"},
}
+var optionsSources = map[string]string{
+ "agent.reasoning_effort": "reasoning_capability",
+ "model.reasoning_effort": "reasoning_capability",
+}
+
var help = map[string]string{
- "model.default": "Model id as your provider spells it, e.g. anthropic/claude-sonnet-4.5.",
- "model.provider": "Which entry under providers to call.",
- "model.auxiliary": "Cheaper model used for summarising and other background work.",
- "model.context_window": "Used to decide when to compact; set it to match your model.",
- "database.driver": "sqlite for a single node, postgres when you share state.",
- "database.dsn": "sqlite: a file path. postgres: postgres://user:pass@host:5432/db?sslmode=disable",
- "server.auth_token": "Leave empty to keep the dashboard open — sensible behind a private network.",
- "server.host": "0.0.0.0 exposes it on every interface; 127.0.0.1 keeps it local.",
- "agent.workspace": "The only directory file tools may read or write.",
- "agent.system_prompt_extra": "Appended to the system prompt on every turn.",
- "tools.toolset": "Preset deciding which tools reach the model.",
- "tools.approval_mode": "auto runs mutating tools directly; deny blocks them.",
- "rag.rerank_mode": "How to reorder results: llm (an auxiliary model scores them), api (an external reranker), or off.",
- "rag.embed_model": "The embedding model for indexing and search, e.g. text-embedding-3-small.",
- "rag.per_user": "Keep a separate memory per chat user (Discord/Telegram), so the agent can recall topics and facts about each specific person. Stores cross-conversation data about individuals; off by default.",
- "compression.threshold": "Fraction of the context window that triggers automatic compaction.",
- "terminal.backend": "local runs on this machine; docker and ssh sandbox it elsewhere.",
- "memory.memory_enabled": "Lets the agent store durable facts between sessions.",
- "skills.auto_create": "Allows the agent to write new skills on its own.",
- "osint.google_cookie": "Optional. A logged-in Google Cookie header enables osint_google to resolve an email to its public profile. ToS-sensitive; uses your own session. Leave empty to disable.",
- "display.show_reasoning": "Stream and show model reasoning/thinking in the dashboard (and TUI). Off skips emitting reasoning events so long thinking traces never hit the UI.",
- "display.tool_progress": "Show live tool progress lines while a tool runs.",
+ "model.default": "Model id as your provider spells it, e.g. anthropic/claude-sonnet-4.5.",
+ "model.provider": "Which entry under providers to call.",
+ "model.auxiliary": "Cheaper model used for summarising and other background work.",
+ "model.context_window": "Used to decide when to compact; set it to match your model.",
+ "database.driver": "sqlite for a single node, postgres when you share state.",
+ "database.dsn": "sqlite: a file path. postgres: postgres://user:pass@host:5432/db?sslmode=disable",
+ "server.auth_token": "Leave empty to keep the dashboard open — sensible behind a private network.",
+ "server.host": "0.0.0.0 exposes it on every interface; 127.0.0.1 keeps it local.",
+ "agent.workspace": "The only directory file tools may read or write.",
+ "agent.system_prompt_extra": "Appended to the system prompt on every turn.",
+ "tools.toolset": "Preset deciding which tools reach the model.",
+ "tools.approval_mode": "auto runs mutating tools directly; deny blocks them.",
+ "rag.rerank_mode": "How to reorder results: llm (an auxiliary model scores them), api (an external reranker), or off.",
+ "rag.embed_model": "The embedding model for indexing and search, e.g. text-embedding-3-small.",
+ "rag.per_user": "Keep a separate memory per chat user (Discord/Telegram), so the agent can recall topics and facts about each specific person. Stores cross-conversation data about individuals; off by default.",
+ "compression.threshold": "Fraction of the context window that triggers automatic compaction.",
+ "terminal.backend": "local runs on this machine; docker and ssh sandbox it elsewhere.",
+ "memory.memory_enabled": "Lets the agent store durable facts between sessions.",
+ "skills.auto_create": "Allows the agent to write new skills on its own.",
+ "osint.google_cookie": "Optional. A logged-in Google Cookie header enables osint_google to resolve an email to its public profile. ToS-sensitive; uses your own session. Leave empty to disable.",
+ "display.show_reasoning": "Stream and show model reasoning/thinking in the dashboard (and TUI). Off skips emitting reasoning events so long thinking traces never hit the UI.",
+ "display.tool_progress": "Show live tool progress lines while a tool runs.",
"display.max_live_reasoning_chars": "Max characters of reasoning kept in the browser while a turn streams (trailing window). Prevents tab freezes on long thinking. Default 48000. 0 = unlimited. Full text is still saved server-side and restored after the turn.",
}
@@ -220,14 +227,15 @@ func walk(v reflect.Value, prefix, group string, out *[]Field) {
}
f := Field{
- Path: path,
- Label: humanize(name),
- Group: grp,
- Tier: tierFor(path),
- Default: fv.Interface(),
- Secret: secretKey(path),
- Enum: enums[path],
- Help: help[path],
+ Path: path,
+ Label: humanize(name),
+ Group: grp,
+ Tier: tierFor(path),
+ Default: fv.Interface(),
+ Secret: secretKey(path),
+ Enum: enums[path],
+ OptionsSource: optionsSources[path],
+ Help: help[path],
}
switch fv.Kind() {
case reflect.Bool:
diff --git a/internal/config/schema_reasoning_test.go b/internal/config/schema_reasoning_test.go
new file mode 100644
index 0000000..cf72857
--- /dev/null
+++ b/internal/config/schema_reasoning_test.go
@@ -0,0 +1,58 @@
+package config
+
+import (
+ "os"
+ "testing"
+)
+
+func TestSchemaMarksReasoningFieldsModelAware(t *testing.T) {
+ schema := Schema()
+ for _, path := range []string{"agent.reasoning_effort", "model.reasoning_effort"} {
+ field := fieldByPath(t, schema, path)
+ if len(field.Enum) != 0 || field.OptionsSource != "reasoning_capability" {
+ t.Fatalf("%s = %+v", path, field)
+ }
+ }
+}
+
+func TestParseRawDoesNotWriteConfiguration(t *testing.T) {
+ t.Setenv("ANTARES_HOME", t.TempDir())
+ t.Setenv("ANTARES_CONFIG", "")
+ t.Setenv("ANTARES_PROFILE", "default")
+ if err := Save(Default()); err != nil {
+ t.Fatal(err)
+ }
+ before := mustReadConfigFile(t)
+ if _, err := ParseRaw("model:\n default: gpt-5\n"); err != nil {
+ t.Fatal(err)
+ }
+ if after := mustReadConfigFile(t); after != before {
+ t.Fatal("ParseRaw changed the config file")
+ }
+}
+
+func fieldByPath(t *testing.T, fields []Field, path string) Field {
+ t.Helper()
+ for _, field := range fields {
+ if field.Path == path {
+ return field
+ }
+ }
+ t.Fatalf("field %q not found", path)
+ return Field{}
+}
+
+func mustReadConfigFile(t *testing.T) string {
+ t.Helper()
+ path := ConfigFile()
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Errorf("restore config: %v", err)
+ }
+ })
+ return string(raw)
+}
diff --git a/internal/server/handlers_chat.go b/internal/server/handlers_chat.go
index d8649ad..5cc4033 100644
--- a/internal/server/handlers_chat.go
+++ b/internal/server/handlers_chat.go
@@ -120,6 +120,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, errors.New("message is required"))
return
}
+ if err := s.validateExplicitReasoning(
+ r.Context(), s.config(), req.Model, req.ReasoningEffort,
+ ); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
// Persist the picked role against the session so a reload reflects it. The
// session id is only known once the run assigns one, so a brand-new
diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go
index 70935bf..d1dcda8 100644
--- a/internal/server/handlers_config.go
+++ b/internal/server/handlers_config.go
@@ -61,23 +61,32 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
}
sort.Strings(paths)
+ next := *cfg
+ invalidateDashSessions := false
for _, path := range paths {
value := body.Updates[path]
// A redacted secret coming back unchanged means "leave it alone".
if str, ok := value.(string); ok && strings.Contains(str, "••••") {
continue
}
- if err := cfg.SetPath(path, value); err != nil {
+ if err := next.SetPath(path, value); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
// Changing (or clearing) the dashboard password must not leave old
// logins valid.
if path == "server.dashboard_password_hash" {
- s.invalidateDashSessions()
+ invalidateDashSessions = true
}
}
- if err := config.Save(cfg); err != nil {
+ if err := s.validateChangedReasoning(r.Context(), cfg, &next); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ if invalidateDashSessions {
+ s.invalidateDashSessions()
+ }
+ if err := config.Save(&next); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
@@ -108,6 +117,20 @@ func (s *Server) handleSaveRawConfig(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err)
return
}
+ next, err := config.ParseRaw(body.YAML)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ current, err := config.Reload()
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err)
+ return
+ }
+ if err := s.validateChangedReasoning(r.Context(), current, next); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
if err := config.SaveRaw(body.YAML); err != nil {
writeError(w, http.StatusBadRequest, err)
return
diff --git a/internal/server/handlers_providers.go b/internal/server/handlers_providers.go
index a289680..600c21b 100644
--- a/internal/server/handlers_providers.go
+++ b/internal/server/handlers_providers.go
@@ -19,7 +19,12 @@ import (
// found:false — the caller then asks the user for the value.
func (s *Server) handleProviderModelInfo(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
- modelID := strings.TrimSpace(r.URL.Query().Get("id"))
+ modelID := strings.TrimSpace(r.URL.Query().Get("model"))
+ if modelID == "" {
+ // Keep accepting the earlier dashboard parameter while model is rolled
+ // out; model is the canonical API name.
+ modelID = strings.TrimSpace(r.URL.Query().Get("id"))
+ }
if modelID == "" {
writeError(w, http.StatusBadRequest, errors.New("a model id is required"))
return
@@ -40,9 +45,12 @@ func (s *Server) handleProviderModelInfo(w http.ResponseWriter, r *http.Request)
for _, m := range models {
if m.ID == modelID {
writeJSON(w, http.StatusOK, map[string]any{
- "found": true,
- "context_window": m.ContextWindow,
- "name": m.Name,
+ "found": true,
+ "id": m.ID,
+ "context_window": m.ContextWindow,
+ "name": m.Name,
+ "reasoning": m.Reasoning,
+ "reasoning_capability": m.ReasoningCapability,
})
return
}
diff --git a/internal/server/handlers_roles.go b/internal/server/handlers_roles.go
index 466cfd0..1f9eb18 100644
--- a/internal/server/handlers_roles.go
+++ b/internal/server/handlers_roles.go
@@ -3,6 +3,7 @@ package server
import (
"errors"
"net/http"
+ "strings"
"github.com/enowdev/antares/internal/roles"
"github.com/enowdev/antares/internal/tools"
@@ -82,9 +83,22 @@ func (s *Server) handleSaveRole(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err)
return
}
+ effort := strings.TrimSpace(b.Effort)
+ previousEffort := ""
+ if previous, ok := reg.Get(b.Name); ok {
+ previousEffort = previous.Effort
+ }
+ if effort != previousEffort {
+ if err := s.validateExplicitReasoning(
+ r.Context(), s.config(), strings.TrimSpace(b.Model), effort,
+ ); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ }
saved, err := reg.Save(roles.Role{
Name: b.Name, Title: b.Title, Summary: b.Summary, Category: b.Category,
- Toolset: b.Toolset, Model: b.Model, Effort: b.Effort, MaxTurns: b.MaxTurns,
+ Toolset: b.Toolset, Model: b.Model, Effort: effort, MaxTurns: b.MaxTurns,
Tags: b.Tags, Danger: b.Danger, Subrole: b.Subrole, Parent: b.Parent,
Prompt: b.Body,
})
diff --git a/internal/server/reasoning.go b/internal/server/reasoning.go
new file mode 100644
index 0000000..b56f967
--- /dev/null
+++ b/internal/server/reasoning.go
@@ -0,0 +1,60 @@
+package server
+
+import (
+ "context"
+
+ "github.com/enowdev/antares/internal/config"
+)
+
+// validateExplicitReasoning rejects a newly submitted override before the
+// caller opens a stream or mutates persistent state. Empty means Auto and is
+// valid for every model.
+func (s *Server) validateExplicitReasoning(
+ ctx context.Context,
+ cfg *config.Config,
+ modelRef string,
+ effort string,
+) error {
+ if effort == "" {
+ return nil
+ }
+ if modelRef == "" {
+ modelRef = reasoningModelRef(cfg)
+ }
+ return s.agent.ValidateReasoningEffort(ctx, modelRef, effort)
+}
+
+// validateChangedReasoning validates only values changed by a mutation.
+// Persisted values from older releases remain loadable and are handled as
+// legacy values by the agent at runtime.
+func (s *Server) validateChangedReasoning(
+ ctx context.Context,
+ before *config.Config,
+ after *config.Config,
+) error {
+ modelRef := reasoningModelRef(after)
+ if before.Agent.ReasoningEffort != after.Agent.ReasoningEffort {
+ if err := s.validateExplicitReasoning(ctx, after, modelRef, after.Agent.ReasoningEffort); err != nil {
+ return err
+ }
+ }
+ if before.Model.ReasoningEffort != after.Model.ReasoningEffort {
+ if err := s.validateExplicitReasoning(ctx, after, modelRef, after.Model.ReasoningEffort); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func reasoningModelRef(cfg *config.Config) string {
+ if cfg == nil {
+ return ""
+ }
+ if cfg.Model.Provider == "" || cfg.Model.Default == "" {
+ return cfg.Model.Default
+ }
+ // Qualifying with the configured provider preserves aggregator model ids:
+ // openrouter + anthropic/claude becomes openrouter/anthropic/claude, which
+ // Agent resolves back to provider=openrouter and model=anthropic/claude.
+ return cfg.Model.Provider + "/" + cfg.Model.Default
+}
diff --git a/internal/server/reasoning_test.go b/internal/server/reasoning_test.go
new file mode 100644
index 0000000..901f0b4
--- /dev/null
+++ b/internal/server/reasoning_test.go
@@ -0,0 +1,347 @@
+package server
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/enowdev/antares/internal/agent"
+ "github.com/enowdev/antares/internal/config"
+ "github.com/enowdev/antares/internal/llm"
+ "github.com/enowdev/antares/internal/roles"
+ "github.com/enowdev/antares/internal/store"
+ "github.com/enowdev/antares/internal/tools"
+)
+
+func TestHandleModelListAllIncludesReasoningCapability(t *testing.T) {
+ catalog := newServerReasoningCatalog(t, nil)
+ s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) {
+ cfg.Model.Provider = "router"
+ cfg.Model.Default = "model-a"
+ cfg.Providers = map[string]config.Provider{
+ "router": {
+ Kind: "openai-compatible",
+ BaseURL: catalog.URL,
+ Enabled: true,
+ },
+ }
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/api/model/list-all", nil)
+ rec := httptest.NewRecorder()
+ s.handleModelListAll(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+
+ var body struct {
+ Models []llm.ModelInfo `json:"models"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if len(body.Models) != 1 || body.Models[0].ID != "model-a" {
+ t.Fatalf("models = %#v", body.Models)
+ }
+ assertServerReasoningCapability(t, body.Models[0])
+}
+
+func TestHandleProviderModelInfoReadsModelQueryAndIncludesCapability(t *testing.T) {
+ catalog := newServerReasoningCatalog(t, nil)
+ s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) {
+ cfg.Providers = map[string]config.Provider{
+ "router": {
+ Kind: "openai-compatible",
+ BaseURL: catalog.URL,
+ Enabled: true,
+ },
+ }
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/api/providers/router/model-info?model=model-a", nil)
+ req.SetPathValue("id", "router")
+ rec := httptest.NewRecorder()
+ s.handleProviderModelInfo(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+
+ var body struct {
+ Found bool `json:"found"`
+ ID string `json:"id"`
+ Reasoning bool `json:"reasoning"`
+ ReasoningCapability *llm.ReasoningCapability `json:"reasoning_capability"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if !body.Found || body.ID != "model-a" {
+ t.Fatalf("response = %#v", body)
+ }
+ assertServerReasoningCapability(t, llm.ModelInfo{
+ ID: body.ID,
+ Reasoning: body.Reasoning,
+ ReasoningCapability: body.ReasoningCapability,
+ })
+}
+
+func TestHandleChatRejectsUnsupportedReasoningBeforeChatRequest(t *testing.T) {
+ var chatRequests atomic.Int32
+ catalog := newServerReasoningCatalog(t, &chatRequests)
+ s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) {
+ cfg.Model.Provider = "router"
+ cfg.Model.Default = "model-a"
+ cfg.Providers = map[string]config.Provider{
+ "router": {
+ Kind: "openai-compatible",
+ BaseURL: catalog.URL,
+ Enabled: true,
+ },
+ }
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/api/chat",
+ strings.NewReader(`{"message":"hello","reasoning_effort":"unsupported"}`))
+ rec := httptest.NewRecorder()
+ s.handleChat(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ if strings.HasPrefix(rec.Header().Get("Content-Type"), "text/event-stream") {
+ t.Fatalf("unsupported request opened SSE: %q", rec.Header().Get("Content-Type"))
+ }
+ if got := chatRequests.Load(); got != 0 {
+ t.Fatalf("chat requests = %d, want 0", got)
+ }
+}
+
+func TestHandleUpdateConfigRejectsChangedUnsupportedReasoningWithoutSaving(t *testing.T) {
+ s, _, configPath := newReasoningBoundaryServer(t, nil)
+ before := mustReadServerFile(t, configPath)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/config",
+ strings.NewReader(`{"updates":{"model.reasoning_effort":"unsupported"}}`))
+ rec := httptest.NewRecorder()
+ s.handleUpdateConfig(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ if after := mustReadServerFile(t, configPath); !bytes.Equal(after, before) {
+ t.Fatal("rejected config update changed the config file")
+ }
+ if got := s.config().Model.ReasoningEffort; got != "" {
+ t.Fatalf("rejected config update mutated in-memory effort to %q", got)
+ }
+}
+
+func TestHandleUpdateConfigAllowsUnrelatedEditWithLegacyUnsupportedReasoning(t *testing.T) {
+ s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) {
+ cfg.Model.ReasoningEffort = "legacy-unsupported"
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/api/config",
+ strings.NewReader(`{"updates":{"display.theme":"dark"}}`))
+ rec := httptest.NewRecorder()
+ s.handleUpdateConfig(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ saved, err := config.Reload()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if saved.Model.ReasoningEffort != "legacy-unsupported" {
+ t.Fatalf("legacy reasoning effort = %q", saved.Model.ReasoningEffort)
+ }
+ if saved.Display.Theme != "dark" {
+ t.Fatalf("theme = %q, want dark", saved.Display.Theme)
+ }
+}
+
+func TestHandleSaveRawConfigRejectsNewUnsupportedReasoningWithoutSaving(t *testing.T) {
+ s, _, configPath := newReasoningBoundaryServer(t, nil)
+ before := mustReadServerFile(t, configPath)
+ raw := "model:\n provider: openai\n default: gpt-5\n reasoning_effort: unsupported\n"
+ body, err := json.Marshal(map[string]string{"yaml": raw})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body))
+ rec := httptest.NewRecorder()
+ s.handleSaveRawConfig(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ if after := mustReadServerFile(t, configPath); !bytes.Equal(after, before) {
+ t.Fatal("rejected raw config changed the config file")
+ }
+}
+
+func TestHandleSaveRoleRejectsExplicitUnsupportedReasoning(t *testing.T) {
+ var roleDir string
+ s, a, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) {
+ roleDir = filepath.Join(config.Home(), "roles")
+ cfg.Roles.Dirs = []string{roleDir}
+ })
+ reg := roles.NewRegistry([]string{roleDir})
+ if _, err := reg.Save(roles.Role{
+ Name: "custom-reviewer", Title: "Custom Reviewer", Model: "openai/gpt-5",
+ Effort: "low", Prompt: "before",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ a.SetRoles(reg)
+ rolePath := filepath.Join(roleDir, "custom-reviewer.md")
+ before := mustReadServerFile(t, rolePath)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/roles", strings.NewReader(
+ `{"name":"custom-reviewer","title":"Custom Reviewer","model":"openai/gpt-5",`+
+ `"effort":"unsupported","body":"after"}`,
+ ))
+ rec := httptest.NewRecorder()
+ s.handleSaveRole(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ if after := mustReadServerFile(t, rolePath); !bytes.Equal(after, before) {
+ t.Fatal("rejected role save changed the role file")
+ }
+ if role, ok := reg.Get("custom-reviewer"); !ok || role.Effort != "low" {
+ t.Fatalf("rejected role save mutated registry: %#v, found = %v", role, ok)
+ }
+}
+
+func newReasoningBoundaryServer(
+ t *testing.T,
+ seed func(*config.Config),
+) (*Server, *agent.Agent, string) {
+ t.Helper()
+ t.Setenv("ANTARES_HOME", t.TempDir())
+ t.Setenv("ANTARES_CONFIG", "")
+ t.Setenv("ANTARES_PROFILE", "default")
+ t.Setenv("ANTARES_MODEL", "")
+ t.Setenv("ANTARES_PROVIDER", "")
+ t.Setenv("ANTARES_BASE_URL", "")
+ t.Setenv("ANTARES_API_KEY", "")
+ for _, key := range []string{
+ "OPENROUTER_API_KEY",
+ "OPENAI_API_KEY",
+ "ANTHROPIC_API_KEY",
+ "GEMINI_API_KEY",
+ "CURSOR_API_KEY",
+ } {
+ t.Setenv(key, "")
+ }
+
+ cfg := config.Default()
+ cfg.Server.DashboardPasswordHash = "test-hash"
+ cfg.Model.Provider = "openai"
+ cfg.Model.Default = "gpt-5"
+ cfg.Providers = map[string]config.Provider{
+ "openai": {
+ Kind: "openai",
+ BaseURL: "https://api.openai.com/v1",
+ Enabled: true,
+ },
+ }
+ if seed != nil {
+ seed(cfg)
+ }
+ configPath := config.ConfigFile()
+ if err := config.SaveAt(configPath, cfg); err != nil {
+ t.Fatal(err)
+ }
+ reloaded, err := config.Reload()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Reload overlays YAML onto fresh defaults, including default provider map
+ // entries absent from the test fixture. Keep only the explicitly seeded
+ // providers so list-all can never probe real or developer-local endpoints.
+ reloaded.Providers = make(map[string]config.Provider, len(cfg.Providers))
+ for id, provider := range cfg.Providers {
+ reloaded.Providers[id] = provider
+ }
+ db, err := store.Open(context.Background(), "memory", "", 1, 5000, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ a := agent.New(reloaded, db, tools.NewRegistry(), nil, nil)
+ s := New(Options{
+ Config: reloaded,
+ Agent: a,
+ Store: db,
+ Reload: func() error {
+ next, err := config.Reload()
+ if err == nil {
+ a.SetConfig(next)
+ }
+ return err
+ },
+ })
+ return s, a, configPath
+}
+
+func newServerReasoningCatalog(t *testing.T, chatRequests *atomic.Int32) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"):
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "data": [{
+ "id": "model-a",
+ "name": "Model A",
+ "context_length": 128000,
+ "reasoning": {
+ "supported_efforts": ["low"],
+ "default_effort": "low"
+ }
+ }]
+ }`))
+ case r.Method == http.MethodPost:
+ if chatRequests != nil {
+ chatRequests.Add(1)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"unexpected"}}]}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func assertServerReasoningCapability(t *testing.T, model llm.ModelInfo) {
+ t.Helper()
+ if !model.Reasoning {
+ t.Fatal("legacy reasoning flag = false")
+ }
+ capability := model.ReasoningCapability
+ if capability == nil || capability.Source != llm.ReasoningCapabilityLive {
+ t.Fatalf("reasoning capability = %#v", capability)
+ }
+ if capability.Default != "low" || len(capability.Values) != 1 ||
+ capability.Values[0].Value != "low" {
+ t.Fatalf("reasoning capability = %#v", capability)
+ }
+}
+
+func mustReadServerFile(t *testing.T, path string) []byte {
+ t.Helper()
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+}
From 17bd879fb52b2f8445a9b1ee76894600c4d5e918 Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 02:47:45 +0700
Subject: [PATCH 10/41] Fix reasoning validation write boundaries
Validate raw edits against their candidate provider map without touching live config, and preserve repairability while distinguishing unavailable metadata from unsupported values.
Co-authored-by: Cursor
---
internal/agent/reasoning.go | 33 +++-
internal/server/handlers_chat.go | 2 +-
internal/server/handlers_config.go | 13 +-
internal/server/handlers_roles.go | 2 +-
internal/server/reasoning.go | 12 +-
internal/server/reasoning_test.go | 295 +++++++++++++++++++++++++++++
6 files changed, 344 insertions(+), 13 deletions(-)
diff --git a/internal/agent/reasoning.go b/internal/agent/reasoning.go
index f43730e..1c7f404 100644
--- a/internal/agent/reasoning.go
+++ b/internal/agent/reasoning.go
@@ -50,7 +50,15 @@ type reasoningTarget struct {
// configured provider can supply. Documented direct-provider metadata avoids a
// network dependency; dynamic providers use the Agent-owned cached catalogue.
func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm.ReasoningCapability, error) {
- target := a.reasoningTarget(modelRef)
+ return a.reasoningCapabilityForConfig(ctx, a.config(), modelRef)
+}
+
+func (a *Agent) reasoningCapabilityForConfig(
+ ctx context.Context,
+ cfg *config.Config,
+ modelRef string,
+) (*llm.ReasoningCapability, error) {
+ target := reasoningTargetForConfig(cfg, modelRef)
if capability := staticReasoningCapability(target); capability != nil {
return capability, nil
}
@@ -73,14 +81,26 @@ func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm.
// ValidateReasoningEffort validates an explicit value without including the
// submitted value in any error.
func (a *Agent) ValidateReasoningEffort(ctx context.Context, modelRef, effort string) error {
+ return a.ValidateReasoningEffortForConfig(ctx, a.config(), modelRef, effort)
+}
+
+// ValidateReasoningEffortForConfig validates against an immutable candidate
+// config while retaining the Agent-owned provider catalogue cache. It does not
+// publish the candidate as the live Agent configuration.
+func (a *Agent) ValidateReasoningEffortForConfig(
+ ctx context.Context,
+ cfg *config.Config,
+ modelRef string,
+ effort string,
+) error {
if effort == "" {
return nil
}
- capability, err := a.ReasoningCapability(ctx, modelRef)
+ capability, err := a.reasoningCapabilityForConfig(ctx, cfg, modelRef)
if err != nil {
return err
}
- return llm.ValidateReasoningEffort(a.reasoningTarget(modelRef).model, capability, effort)
+ return llm.ValidateReasoningEffort(reasoningTargetForConfig(cfg, modelRef).model, capability, effort)
}
// resolveReasoning distinguishes a new explicit override from stored legacy
@@ -139,6 +159,13 @@ func (a *Agent) resolveReasoning(ctx context.Context, in reasoningInput) (reason
func (a *Agent) reasoningTarget(modelRef string) reasoningTarget {
cfg := a.config()
+ return reasoningTargetForConfig(cfg, modelRef)
+}
+
+func reasoningTargetForConfig(cfg *config.Config, modelRef string) reasoningTarget {
+ if cfg == nil {
+ return reasoningTarget{model: modelRef}
+ }
providerID := cfg.Model.Provider
model := modelRef
if model == "" {
diff --git a/internal/server/handlers_chat.go b/internal/server/handlers_chat.go
index 5cc4033..4aab63a 100644
--- a/internal/server/handlers_chat.go
+++ b/internal/server/handlers_chat.go
@@ -123,7 +123,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if err := s.validateExplicitReasoning(
r.Context(), s.config(), req.Model, req.ReasoningEffort,
); err != nil {
- writeError(w, http.StatusBadRequest, err)
+ writeReasoningValidationError(w, err)
return
}
diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go
index d1dcda8..3caaeff 100644
--- a/internal/server/handlers_config.go
+++ b/internal/server/handlers_config.go
@@ -80,7 +80,7 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
}
}
if err := s.validateChangedReasoning(r.Context(), cfg, &next); err != nil {
- writeError(w, http.StatusBadRequest, err)
+ writeReasoningValidationError(w, err)
return
}
if invalidateDashSessions {
@@ -122,13 +122,12 @@ func (s *Server) handleSaveRawConfig(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err)
return
}
- current, err := config.Reload()
- if err != nil {
- writeError(w, http.StatusInternalServerError, err)
- return
- }
+ // Compare against the last valid in-memory snapshot. Re-reading here would
+ // prevent the raw editor from repairing malformed on-disk YAML, and Reload's
+ // first-run behavior could create a file before a rejected submission.
+ current := s.config()
if err := s.validateChangedReasoning(r.Context(), current, next); err != nil {
- writeError(w, http.StatusBadRequest, err)
+ writeReasoningValidationError(w, err)
return
}
if err := config.SaveRaw(body.YAML); err != nil {
diff --git a/internal/server/handlers_roles.go b/internal/server/handlers_roles.go
index 1f9eb18..428aac3 100644
--- a/internal/server/handlers_roles.go
+++ b/internal/server/handlers_roles.go
@@ -92,7 +92,7 @@ func (s *Server) handleSaveRole(w http.ResponseWriter, r *http.Request) {
if err := s.validateExplicitReasoning(
r.Context(), s.config(), strings.TrimSpace(b.Model), effort,
); err != nil {
- writeError(w, http.StatusBadRequest, err)
+ writeReasoningValidationError(w, err)
return
}
}
diff --git a/internal/server/reasoning.go b/internal/server/reasoning.go
index b56f967..d6c8630 100644
--- a/internal/server/reasoning.go
+++ b/internal/server/reasoning.go
@@ -2,7 +2,9 @@ package server
import (
"context"
+ "net/http"
+ "github.com/enowdev/antares/internal/agent"
"github.com/enowdev/antares/internal/config"
)
@@ -21,7 +23,7 @@ func (s *Server) validateExplicitReasoning(
if modelRef == "" {
modelRef = reasoningModelRef(cfg)
}
- return s.agent.ValidateReasoningEffort(ctx, modelRef, effort)
+ return s.agent.ValidateReasoningEffortForConfig(ctx, cfg, modelRef, effort)
}
// validateChangedReasoning validates only values changed by a mutation.
@@ -46,6 +48,14 @@ func (s *Server) validateChangedReasoning(
return nil
}
+func writeReasoningValidationError(w http.ResponseWriter, err error) {
+ status := http.StatusBadRequest
+ if agent.IsReasoningMetadataUnavailable(err) {
+ status = http.StatusServiceUnavailable
+ }
+ writeError(w, status, err)
+}
+
func reasoningModelRef(cfg *config.Config) string {
if cfg == nil {
return ""
diff --git a/internal/server/reasoning_test.go b/internal/server/reasoning_test.go
index 901f0b4..731d59b 100644
--- a/internal/server/reasoning_test.go
+++ b/internal/server/reasoning_test.go
@@ -185,6 +185,278 @@ func TestHandleSaveRawConfigRejectsNewUnsupportedReasoningWithoutSaving(t *testi
}
}
+func TestHandleSaveRawConfigRepairsMalformedExistingYAML(t *testing.T) {
+ s, _, configPath := newReasoningBoundaryServer(t, nil)
+ if err := os.WriteFile(configPath, []byte("model: [\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ raw := "model:\n provider: openai\n default: gpt-5\n"
+ body, err := json.Marshal(map[string]string{"yaml": raw})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body))
+ rec := httptest.NewRecorder()
+ s.handleSaveRawConfig(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ if after := mustReadServerFile(t, configPath); string(after) != raw {
+ t.Fatalf("saved config = %q, want submitted repair %q", after, raw)
+ }
+}
+
+func TestHandleSaveRawConfigRejectedSubmissionDoesNotCreateMissingFile(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ }{
+ {name: "malformed YAML", raw: "model: [\n"},
+ {
+ name: "unsupported reasoning",
+ raw: "model:\n provider: openai\n default: gpt-5\n reasoning_effort: unsupported\n",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s, a, configPath := newReasoningBoundaryServer(t, nil)
+ if err := os.Remove(configPath); err != nil {
+ t.Fatal(err)
+ }
+ serverBefore := s.config()
+ agentBefore := a.Config()
+ body, err := json.Marshal(map[string]string{"yaml": tt.raw})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body))
+ rec := httptest.NewRecorder()
+ s.handleSaveRawConfig(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ if _, err := os.Stat(configPath); !os.IsNotExist(err) {
+ t.Fatalf("rejected raw save created %s: %v", configPath, err)
+ }
+ if s.config() != serverBefore {
+ t.Fatal("rejected raw save replaced the live server config")
+ }
+ if a.Config() != agentBefore {
+ t.Fatal("rejected raw save replaced the live agent config")
+ }
+ })
+ }
+}
+
+func TestHandleSaveRawConfigValidatesNewProviderAgainstCandidateConfig(t *testing.T) {
+ var oldFetches, candidateFetches atomic.Int32
+ oldCatalog := newServerCatalogFixture(t, http.StatusOK, `{
+ "data": [{
+ "id": "model-old",
+ "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}
+ }]
+ }`, &oldFetches)
+ candidateCatalog := newServerCatalogFixture(t, http.StatusOK, `{
+ "data": [{
+ "id": "model-a",
+ "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"}
+ }]
+ }`, &candidateFetches)
+ s, a, configPath := newReasoningBoundaryServer(t, func(cfg *config.Config) {
+ cfg.Model.Provider = "old-router"
+ cfg.Model.Default = "model-old"
+ cfg.Providers = map[string]config.Provider{
+ "old-router": {
+ Kind: "openai-compatible",
+ BaseURL: oldCatalog.URL,
+ Enabled: true,
+ },
+ }
+ })
+ raw := "model:\n" +
+ " provider: candidate-router\n" +
+ " default: model-a\n" +
+ " reasoning_effort: MiXeD\n" +
+ "providers:\n" +
+ " candidate-router:\n" +
+ " kind: openai-compatible\n" +
+ " base_url: " + candidateCatalog.URL + "\n" +
+ " enabled: true\n"
+ body, err := json.Marshal(map[string]string{"yaml": raw})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body))
+ rec := httptest.NewRecorder()
+ s.handleSaveRawConfig(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ if got := oldFetches.Load(); got != 0 {
+ t.Fatalf("old live provider catalogue fetches = %d, want 0", got)
+ }
+ if got := candidateFetches.Load(); got != 1 {
+ t.Fatalf("candidate provider catalogue fetches = %d, want 1", got)
+ }
+ if after := mustReadServerFile(t, configPath); string(after) != raw {
+ t.Fatalf("saved config = %q, want candidate text %q", after, raw)
+ }
+ if got := s.config(); got.Model.Provider != "candidate-router" ||
+ got.Model.Default != "model-a" || got.Model.ReasoningEffort != "MiXeD" {
+ t.Fatalf("live server config = %+v", got.Model)
+ }
+ if got := a.Config(); got.Model.Provider != "candidate-router" ||
+ got.Model.Default != "model-a" || got.Model.ReasoningEffort != "MiXeD" {
+ t.Fatalf("live agent config = %+v", got.Model)
+ }
+}
+
+func TestHandleSaveRawConfigRejectsInvalidEffortAgainstCandidateWithoutMutation(t *testing.T) {
+ var oldFetches, candidateFetches atomic.Int32
+ oldCatalog := newServerCatalogFixture(t, http.StatusOK, `{
+ "data": [{
+ "id": "model-old",
+ "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}
+ }]
+ }`, &oldFetches)
+ candidateCatalog := newServerCatalogFixture(t, http.StatusOK, `{
+ "data": [{
+ "id": "model-a",
+ "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"}
+ }]
+ }`, &candidateFetches)
+ s, a, configPath := newReasoningBoundaryServer(t, func(cfg *config.Config) {
+ cfg.Model.Provider = "old-router"
+ cfg.Model.Default = "model-old"
+ cfg.Providers = map[string]config.Provider{
+ "old-router": {
+ Kind: "openai-compatible",
+ BaseURL: oldCatalog.URL,
+ Enabled: true,
+ },
+ }
+ })
+ fileBefore := mustReadServerFile(t, configPath)
+ serverBefore := s.config()
+ agentBefore := a.Config()
+ raw := "model:\n" +
+ " provider: candidate-router\n" +
+ " default: model-a\n" +
+ " reasoning_effort: unsupported\n" +
+ "providers:\n" +
+ " candidate-router:\n" +
+ " kind: openai-compatible\n" +
+ " base_url: " + candidateCatalog.URL + "\n" +
+ " enabled: true\n"
+ body, err := json.Marshal(map[string]string{"yaml": raw})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body))
+ rec := httptest.NewRecorder()
+ s.handleSaveRawConfig(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ if got := oldFetches.Load(); got != 0 {
+ t.Fatalf("old live provider catalogue fetches = %d, want 0", got)
+ }
+ if got := candidateFetches.Load(); got != 1 {
+ t.Fatalf("candidate provider catalogue fetches = %d, want 1", got)
+ }
+ if after := mustReadServerFile(t, configPath); !bytes.Equal(after, fileBefore) {
+ t.Fatal("rejected candidate config changed the config file")
+ }
+ if s.config() != serverBefore {
+ t.Fatal("rejected candidate config replaced the live server config")
+ }
+ if a.Config() != agentBefore {
+ t.Fatal("rejected candidate config replaced the live agent config")
+ }
+}
+
+func TestHandleSaveRawConfigDistinguishesUnavailableMetadataFromAutoOnlyModel(t *testing.T) {
+ tests := []struct {
+ name string
+ catalogStatus int
+ catalogBody string
+ wantStatus int
+ wantError string
+ forbiddenError string
+ }{
+ {
+ name: "known Auto-only model",
+ catalogStatus: http.StatusOK,
+ catalogBody: `{"data":[{"id":"model-a"}]}`,
+ wantStatus: http.StatusBadRequest,
+ wantError: "unsupported reasoning override",
+ },
+ {
+ name: "metadata unavailable",
+ catalogStatus: http.StatusServiceUnavailable,
+ catalogBody: `{"error":{"message":"SECRET-UPSTREAM-DIAGNOSTIC"}}`,
+ wantStatus: http.StatusServiceUnavailable,
+ wantError: "use Auto or retry",
+ forbiddenError: "SECRET-UPSTREAM-DIAGNOSTIC",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var fetches atomic.Int32
+ catalog := newServerCatalogFixture(
+ t, tt.catalogStatus, tt.catalogBody, &fetches,
+ )
+ s, a, configPath := newReasoningBoundaryServer(t, nil)
+ fileBefore := mustReadServerFile(t, configPath)
+ serverBefore := s.config()
+ agentBefore := a.Config()
+ raw := "model:\n" +
+ " provider: candidate-router\n" +
+ " default: model-a\n" +
+ " reasoning_effort: high\n" +
+ "providers:\n" +
+ " candidate-router:\n" +
+ " kind: openai-compatible\n" +
+ " base_url: " + catalog.URL + "\n" +
+ " enabled: true\n"
+ body, err := json.Marshal(map[string]string{"yaml": raw})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body))
+ rec := httptest.NewRecorder()
+ s.handleSaveRawConfig(rec, req)
+ if rec.Code != tt.wantStatus {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), tt.wantError) {
+ t.Fatalf("body = %s, want %q", rec.Body.String(), tt.wantError)
+ }
+ if tt.forbiddenError != "" && strings.Contains(rec.Body.String(), tt.forbiddenError) {
+ t.Fatalf("response leaked upstream diagnostic: %s", rec.Body.String())
+ }
+ if got := fetches.Load(); got != 1 {
+ t.Fatalf("catalogue fetches = %d, want 1", got)
+ }
+ if after := mustReadServerFile(t, configPath); !bytes.Equal(after, fileBefore) {
+ t.Fatal("rejected config changed the config file")
+ }
+ if s.config() != serverBefore {
+ t.Fatal("rejected config replaced the live server config")
+ }
+ if a.Config() != agentBefore {
+ t.Fatal("rejected config replaced the live agent config")
+ }
+ })
+ }
+}
+
func TestHandleSaveRoleRejectsExplicitUnsupportedReasoning(t *testing.T) {
var roleDir string
s, a, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) {
@@ -322,6 +594,29 @@ func newServerReasoningCatalog(t *testing.T, chatRequests *atomic.Int32) *httpte
return srv
}
+func newServerCatalogFixture(
+ t *testing.T,
+ status int,
+ response string,
+ fetches *atomic.Int32,
+) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ if fetches != nil {
+ fetches.Add(1)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(response))
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
func assertServerReasoningCapability(t *testing.T, model llm.ModelInfo) {
t.Helper()
if !model.Reasoning {
From 052747015196556d96bbdfcbe655c1c44ad00a74 Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 03:01:52 +0700
Subject: [PATCH 11/41] Validate raw candidates with environment overlays
Apply provider credential and endpoint overrides to write-free candidate parsing so capability checks match the eventual reload without persisting derived values.
Co-authored-by: Cursor
---
internal/config/load.go | 12 ++
internal/config/schema_reasoning_test.go | 60 +++++++
internal/server/handlers_config.go | 2 +-
internal/server/reasoning_test.go | 197 +++++++++++++++++++++++
4 files changed, 270 insertions(+), 1 deletion(-)
diff --git a/internal/config/load.go b/internal/config/load.go
index d50d91e..806686e 100644
--- a/internal/config/load.go
+++ b/internal/config/load.go
@@ -117,6 +117,18 @@ func ParseRaw(text string) (*Config, error) {
return cfg, nil
}
+// ParseRawWithEnv returns a write-free validation candidate with the current
+// process-environment overlays applied exactly as Reload applies them. It does
+// not load dotenv files, update the config cache, or persist derived secrets.
+func ParseRawWithEnv(text string) (*Config, error) {
+ cfg, err := ParseRaw(text)
+ if err != nil {
+ return nil, err
+ }
+ applyEnv(cfg)
+ return cfg, nil
+}
+
// SaveRaw validates then writes YAML text supplied by the dashboard editor.
func SaveRaw(text string) error {
if _, err := ParseRaw(text); err != nil {
diff --git a/internal/config/schema_reasoning_test.go b/internal/config/schema_reasoning_test.go
index cf72857..c823c4f 100644
--- a/internal/config/schema_reasoning_test.go
+++ b/internal/config/schema_reasoning_test.go
@@ -2,6 +2,7 @@ package config
import (
"os"
+ "strings"
"testing"
)
@@ -31,6 +32,65 @@ func TestParseRawDoesNotWriteConfiguration(t *testing.T) {
}
}
+func TestParseRawWithEnvAppliesProviderOverlaysWithoutWriting(t *testing.T) {
+ t.Setenv("ANTARES_HOME", t.TempDir())
+ t.Setenv("ANTARES_CONFIG", "")
+ t.Setenv("ANTARES_PROFILE", "default")
+ t.Setenv("ANTARES_MODEL", "")
+ t.Setenv("ANTARES_PROVIDER", "")
+ t.Setenv("ANTARES_BASE_URL", "")
+ t.Setenv("ANTARES_API_KEY", "")
+ t.Setenv("ROUND2_DECLARED_KEY", "declared-secret")
+ t.Setenv("ANTARES_PROVIDER_DECLARED_API_KEY", "")
+ t.Setenv("ANTARES_PROVIDER_DECLARED_BASE_URL", "http://env-declared.example/v1")
+ t.Setenv("ANTARES_PROVIDER_EXPLICIT_API_KEY", "explicit-secret")
+ t.Setenv("ANTARES_PROVIDER_EXPLICIT_BASE_URL", "http://env-explicit.example/v1")
+
+ if err := Save(Default()); err != nil {
+ t.Fatal(err)
+ }
+ liveBefore := Get()
+ fileBefore := mustReadConfigFile(t)
+ raw := "model:\n" +
+ " provider: declared\n" +
+ " default: model-a\n" +
+ "providers:\n" +
+ " declared:\n" +
+ " kind: openai-compatible\n" +
+ " base_url: http://raw-declared.example/v1\n" +
+ " api_key_env: ROUND2_DECLARED_KEY\n" +
+ " enabled: true\n" +
+ " explicit:\n" +
+ " kind: openai-compatible\n" +
+ " base_url: http://raw-explicit.example/v1\n" +
+ " enabled: true\n"
+
+ candidate, err := ParseRawWithEnv(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ declared := candidate.Providers["declared"]
+ if declared.APIKey != "declared-secret" ||
+ declared.BaseURL != "http://env-declared.example/v1" {
+ t.Fatalf("declared provider = %+v", declared)
+ }
+ explicit := candidate.Providers["explicit"]
+ if explicit.APIKey != "explicit-secret" ||
+ explicit.BaseURL != "http://env-explicit.example/v1" {
+ t.Fatalf("explicit provider = %+v", explicit)
+ }
+ if after := mustReadConfigFile(t); after != fileBefore {
+ t.Fatal("ParseRawWithEnv changed the config file")
+ }
+ if Get() != liveBefore {
+ t.Fatal("ParseRawWithEnv replaced the live config")
+ }
+ if strings.Contains(fileBefore, "declared-secret") ||
+ strings.Contains(fileBefore, "explicit-secret") {
+ t.Fatal("environment credential was persisted")
+ }
+}
+
func fieldByPath(t *testing.T, fields []Field, path string) Field {
t.Helper()
for _, field := range fields {
diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go
index 3caaeff..fd454dc 100644
--- a/internal/server/handlers_config.go
+++ b/internal/server/handlers_config.go
@@ -117,7 +117,7 @@ func (s *Server) handleSaveRawConfig(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err)
return
}
- next, err := config.ParseRaw(body.YAML)
+ next, err := config.ParseRawWithEnv(body.YAML)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
diff --git a/internal/server/reasoning_test.go b/internal/server/reasoning_test.go
index 731d59b..4189774 100644
--- a/internal/server/reasoning_test.go
+++ b/internal/server/reasoning_test.go
@@ -379,6 +379,165 @@ func TestHandleSaveRawConfigRejectsInvalidEffortAgainstCandidateWithoutMutation(
}
}
+func TestHandleSaveRawConfigAppliesCandidateProviderEnvWithoutPersistingSecrets(t *testing.T) {
+ const secret = "round2-candidate-secret"
+ tests := []struct {
+ name string
+ declaredEnv string
+ providerEnv string
+ wantCredential string
+ }{
+ {
+ name: "api_key_env",
+ declaredEnv: secret,
+ wantCredential: secret,
+ },
+ {
+ name: "provider-specific API key",
+ declaredEnv: "wrong-fallback-secret",
+ providerEnv: secret,
+ wantCredential: secret,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var accepted, rejected, wrongEndpoint atomic.Int32
+ catalog := newAuthenticatedServerCatalogFixture(
+ t, secret, &accepted, &rejected,
+ )
+ wrongCatalog := newServerCatalogFixture(
+ t,
+ http.StatusServiceUnavailable,
+ `{"error":{"message":"wrong raw endpoint"}}`,
+ &wrongEndpoint,
+ )
+ s, a, configPath := newReasoningBoundaryServer(t, nil)
+ t.Setenv("ROUND2_CANDIDATE_API_KEY", tt.declaredEnv)
+ t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_API_KEY", tt.providerEnv)
+ t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_BASE_URL", catalog.URL)
+ raw := "model:\n" +
+ " provider: candidate-router\n" +
+ " default: model-a\n" +
+ " reasoning_effort: MiXeD\n" +
+ "providers:\n" +
+ " candidate-router:\n" +
+ " kind: openai-compatible\n" +
+ " base_url: " + wrongCatalog.URL + "\n" +
+ " api_key_env: ROUND2_CANDIDATE_API_KEY\n" +
+ " enabled: true\n"
+ body, err := json.Marshal(map[string]string{"yaml": raw})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body))
+ rec := httptest.NewRecorder()
+ s.handleSaveRawConfig(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ if got := accepted.Load(); got != 1 {
+ t.Fatalf("authenticated catalogue requests = %d, want 1", got)
+ }
+ if got := rejected.Load(); got != 0 {
+ t.Fatalf("rejected catalogue requests = %d, want 0", got)
+ }
+ if got := wrongEndpoint.Load(); got != 0 {
+ t.Fatalf("raw endpoint requests = %d, want 0", got)
+ }
+ saved := mustReadServerFile(t, configPath)
+ if string(saved) != raw {
+ t.Fatalf("saved config = %q, want submitted text %q", saved, raw)
+ }
+ if bytes.Contains(saved, []byte(secret)) ||
+ bytes.Contains(saved, []byte(catalog.URL)) {
+ t.Fatal("saved config contains environment-derived provider data")
+ }
+ if provider := s.config().Providers["candidate-router"]; provider.APIKey != tt.wantCredential ||
+ provider.BaseURL != catalog.URL {
+ t.Fatalf("live server provider = %+v", provider)
+ }
+ if provider := a.Config().Providers["candidate-router"]; provider.APIKey != tt.wantCredential ||
+ provider.BaseURL != catalog.URL {
+ t.Fatalf("live agent provider = %+v", provider)
+ }
+ })
+ }
+}
+
+func TestHandleSaveRawConfigRejectsMissingOrWrongCandidateProviderEnvWithoutMutation(t *testing.T) {
+ const secret = "round2-candidate-secret"
+ tests := []struct {
+ name string
+ declaredEnv string
+ }{
+ {name: "missing credential"},
+ {name: "wrong credential", declaredEnv: "wrong-secret"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var accepted, rejected, wrongEndpoint atomic.Int32
+ catalog := newAuthenticatedServerCatalogFixture(
+ t, secret, &accepted, &rejected,
+ )
+ wrongCatalog := newServerCatalogFixture(
+ t,
+ http.StatusServiceUnavailable,
+ `{"error":{"message":"wrong raw endpoint"}}`,
+ &wrongEndpoint,
+ )
+ s, a, configPath := newReasoningBoundaryServer(t, nil)
+ t.Setenv("ROUND2_CANDIDATE_API_KEY", tt.declaredEnv)
+ t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_API_KEY", "")
+ t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_BASE_URL", catalog.URL)
+ fileBefore := mustReadServerFile(t, configPath)
+ serverBefore := s.config()
+ agentBefore := a.Config()
+ raw := "model:\n" +
+ " provider: candidate-router\n" +
+ " default: model-a\n" +
+ " reasoning_effort: MiXeD\n" +
+ "providers:\n" +
+ " candidate-router:\n" +
+ " kind: openai-compatible\n" +
+ " base_url: " + wrongCatalog.URL + "\n" +
+ " api_key_env: ROUND2_CANDIDATE_API_KEY\n" +
+ " enabled: true\n"
+ body, err := json.Marshal(map[string]string{"yaml": raw})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body))
+ rec := httptest.NewRecorder()
+ s.handleSaveRawConfig(rec, req)
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ if got := accepted.Load(); got != 0 {
+ t.Fatalf("authenticated catalogue requests = %d, want 0", got)
+ }
+ if got := rejected.Load(); got != 1 {
+ t.Fatalf("rejected catalogue requests = %d, want 1", got)
+ }
+ if got := wrongEndpoint.Load(); got != 0 {
+ t.Fatalf("raw endpoint requests = %d, want 0", got)
+ }
+ if after := mustReadServerFile(t, configPath); !bytes.Equal(after, fileBefore) {
+ t.Fatal("rejected environment candidate changed the config file")
+ }
+ if s.config() != serverBefore {
+ t.Fatal("rejected environment candidate replaced the live server config")
+ }
+ if a.Config() != agentBefore {
+ t.Fatal("rejected environment candidate replaced the live agent config")
+ }
+ })
+ }
+}
+
func TestHandleSaveRawConfigDistinguishesUnavailableMetadataFromAutoOnlyModel(t *testing.T) {
tests := []struct {
name string
@@ -617,6 +776,44 @@ func newServerCatalogFixture(
return srv
}
+func newAuthenticatedServerCatalogFixture(
+ t *testing.T,
+ apiKey string,
+ accepted *atomic.Int32,
+ rejected *atomic.Int32,
+) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if r.Header.Get("Authorization") != "Bearer "+apiKey {
+ if rejected != nil {
+ rejected.Add(1)
+ }
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":{"message":"invalid synthetic credential"}}`))
+ return
+ }
+ if accepted != nil {
+ accepted.Add(1)
+ }
+ _, _ = w.Write([]byte(`{
+ "data": [{
+ "id": "model-a",
+ "reasoning": {
+ "supported_efforts": ["MiXeD"],
+ "default_effort": "MiXeD"
+ }
+ }]
+ }`))
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
func assertServerReasoningCapability(t *testing.T, model llm.ModelInfo) {
t.Helper()
if !model.Reasoning {
From 1570731aebc54ad4f9718bab9c3a0ae9649feb4b Mon Sep 17 00:00:00 2001
From: Jihad Irfansyah
Date: Thu, 13 Aug 2026 03:16:53 +0700
Subject: [PATCH 12/41] Adapt reasoning controls to each model
Co-authored-by: Cursor
---
web/src/components/chat/ModelPicker.tsx | 99 +++++++++++++---
web/src/components/chat/ReasoningPicker.tsx | 74 +++++++-----
web/src/lib/i18n.tsx | 25 ++++
web/src/lib/models.ts | 21 ++++
web/src/lib/reasoning.test.mjs | 121 ++++++++++++++++++++
web/src/lib/reasoning.ts | 72 ++++++++++++
web/src/pages/ChatPage.tsx | 83 ++++++++++----
web/src/pages/ConfigPage.tsx | 87 +++++++++++++-
web/src/pages/ModelsPage.tsx | 19 ++-
web/src/pages/ProvidersPage.tsx | 14 ++-
web/src/pages/RolesPage.tsx | 67 ++++++++++-
11 files changed, 605 insertions(+), 77 deletions(-)
create mode 100644 web/src/lib/models.ts
create mode 100644 web/src/lib/reasoning.test.mjs
create mode 100644 web/src/lib/reasoning.ts
diff --git a/web/src/components/chat/ModelPicker.tsx b/web/src/components/chat/ModelPicker.tsx
index 3bdcc2d..325ad08 100644
--- a/web/src/components/chat/ModelPicker.tsx
+++ b/web/src/components/chat/ModelPicker.tsx
@@ -8,6 +8,10 @@ import {
} from "@phosphor-icons/react";
import { get, isDashboardPasswordRequired, post } from "@/lib/api";
import { useI18n } from "@/lib/i18n";
+import type {
+ ChatModelSelection,
+ ReasoningCapability,
+} from "@/lib/models";
import { cn } from "@/lib/utils";
interface AllModel {
@@ -15,6 +19,7 @@ interface AllModel {
name: string;
provider: string;
provider_label: string;
+ reasoning_capability?: ReasoningCapability;
}
interface ListAll {
@@ -22,6 +27,28 @@ interface ListAll {
models: AllModel[];
}
+interface ModelOptions {
+ active: { model: string; provider: string };
+ providers?: Array<{ id: string; label: string }>;
+}
+
+interface ModelInfo {
+ found: boolean;
+ id?: string;
+ name?: string;
+ reasoning_capability?: ReasoningCapability;
+}
+
+function modelSelection(model: AllModel): ChatModelSelection {
+ return {
+ provider: model.provider,
+ model: model.id,
+ name: model.name,
+ providerLabel: model.provider_label,
+ reasoningCapability: model.reasoning_capability,
+ };
+}
+
/**
* Switch the active model straight from the composer, without leaving the chat.
* Lists every connected provider's models (same source as the Models page) and
@@ -30,7 +57,7 @@ interface ListAll {
export function ModelPicker({
onModelChange,
}: {
- onModelChange?: (model: string) => void;
+ onModelChange?: (selection: ChatModelSelection) => void;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
@@ -41,6 +68,7 @@ export function ModelPicker({
const [pickError, setPickError] = useState();
const [pickGate, setPickGate] = useState(false);
const ref = useRef(null);
+ const resolutionRef = useRef(0);
const [activeConfig, setActiveConfig] = useState<{
model: string;
@@ -50,27 +78,65 @@ export function ModelPicker({
const load = () => {
setLoading(true);
return get("/model/list-all")
- .then((d) => setData(d))
+ .then((d) => {
+ setData(d);
+ const activeModel = d.models.find(
+ (model) =>
+ model.id === d.active?.model &&
+ model.provider === d.active?.provider,
+ );
+ if (activeModel) onModelChange?.(modelSelection(activeModel));
+ })
.catch(() => {})
.finally(() => setLoading(false));
};
- // On mount, fetch just the active model from the cheap /model/options endpoint
- // (config only, no per-provider probing) so the trigger shows the persisted
- // last-used model immediately instead of the "pick a model" placeholder —
- // otherwise it looks like the selection resets on every reload.
- const loadActive = () =>
- get<{ active: { model: string; provider: string } }>("/model/options")
- .then((d) => {
- setActiveConfig(d.active);
- if (d.active?.model && d.active?.provider) {
- onModelChange?.(`${d.active.provider}/${d.active.model}`);
+ // The cheap options call identifies the persisted active pair. Resolve that
+ // one model through model-info so the composer has capability metadata before
+ // it restores a model-scoped reasoning preference.
+ useEffect(() => {
+ let cancelled = false;
+ const sequence = ++resolutionRef.current;
+ get("/model/options")
+ .then(async (options) => {
+ if (cancelled || sequence !== resolutionRef.current) return;
+ const active = options.active;
+ setActiveConfig(active);
+ if (!active?.model || !active?.provider) return;
+
+ const providerLabel =
+ options.providers?.find((provider) => provider.id === active.provider)
+ ?.label ?? active.provider;
+ const fallback: ChatModelSelection = {
+ provider: active.provider,
+ model: active.model,
+ name: active.model,
+ providerLabel,
+ };
+
+ try {
+ const info = await get(
+ `/providers/${encodeURIComponent(active.provider)}/model-info?model=${encodeURIComponent(active.model)}`,
+ );
+ if (cancelled || sequence !== resolutionRef.current) return;
+ onModelChange?.({
+ ...fallback,
+ name: info.found ? info.name || active.model : active.model,
+ reasoningCapability: info.found
+ ? info.reasoning_capability
+ : undefined,
+ });
+ } catch {
+ if (!cancelled && sequence === resolutionRef.current) {
+ onModelChange?.(fallback);
+ }
}
})
.catch(() => {});
- useEffect(() => {
- loadActive();
- }, []);
+ return () => {
+ cancelled = true;
+ };
+ }, [onModelChange]);
// The full model list (which probes every provider) is fetched lazily on open,
// and refreshed each open so a newly connected provider's models appear.
@@ -105,6 +171,7 @@ export function ModelPicker({
}, [data, query]);
const pick = async (m: AllModel) => {
+ ++resolutionRef.current;
setSaving(`${m.provider}/${m.id}`);
setPickError(undefined);
try {
@@ -113,7 +180,7 @@ export function ModelPicker({
setData((d) =>
d ? { ...d, active: { model: m.id, provider: m.provider } } : d,
);
- onModelChange?.(`${m.provider}/${m.id}`);
+ onModelChange?.(modelSelection(m));
setOpen(false);
setQuery("");
} catch (e) {
diff --git a/web/src/components/chat/ReasoningPicker.tsx b/web/src/components/chat/ReasoningPicker.tsx
index 0ece110..e12bc6f 100644
--- a/web/src/components/chat/ReasoningPicker.tsx
+++ b/web/src/components/chat/ReasoningPicker.tsx
@@ -1,33 +1,29 @@
import { useEffect, useRef, useState } from 'react'
import { Brain, CaretDown, Check } from '@phosphor-icons/react'
+import { useI18n } from '@/lib/i18n'
+import type { ReasoningCapability, ReasoningValue } from '@/lib/models'
+import { reasoningOptions } from '@/lib/reasoning'
import { cn } from '@/lib/utils'
-// Reasoning effort options. Empty value means "use the configured default"
-// (agent.reasoning_effort, then model.reasoning_effort). The rest map to the
-// provider's thinking budget: none disables thinking, low/medium/high raise it.
-const OPTIONS: { value: string; label: string; hint: string }[] = [
- { value: '', label: 'Default', hint: 'Use the configured effort' },
- { value: 'none', label: 'Off', hint: 'No reasoning' },
- { value: 'low', label: 'Low', hint: 'Brief reasoning' },
- { value: 'medium', label: 'Medium', hint: 'Balanced reasoning' },
- { value: 'high', label: 'High', hint: 'Deep reasoning' },
-]
+export interface ReasoningPickerProps {
+ value: string
+ capability?: ReasoningCapability
+ onChange(value: string): void
+ compact?: boolean
+}
/**
- * Pick the reasoning effort for the next turn straight from the composer. The
- * choice rides on the message body (reasoning_effort) and overrides the
- * configured default for that turn only; it is remembered in localStorage so it
- * survives a reload. Mirrors RolePicker's compact chip style.
+ * Present the exact reasoning values supported by the selected model. Storage
+ * and model changes are owned by the composer; this component only renders the
+ * supplied value and capability.
*/
export function ReasoningPicker({
value,
+ capability,
onChange,
compact = false,
-}: {
- value: string
- onChange: (effort: string) => void
- compact?: boolean
-}) {
+}: ReasoningPickerProps) {
+ const { t } = useI18n()
const [open, setOpen] = useState(false)
const ref = useRef(null)
@@ -40,18 +36,26 @@ export function ReasoningPicker({
return () => document.removeEventListener('mousedown', onClick)
}, [open])
- const current = OPTIONS.find((o) => o.value === value) ?? OPTIONS[0]
+ const options = reasoningOptions(capability)
+ const current = options.find((option) => option.value === value) ?? options[0]
+ const label = (option: ReasoningValue) =>
+ option.value === '' ? t('reasoning.auto') : option.label
const pick = (v: string) => {
onChange(v)
setOpen(false)
}
+ // Unknown/Auto-only models expose no trustworthy override values. Auto is
+ // still the behavior, but there is no useful composer control to show.
+ if (!capability) return null
+
return (
{open ? (
- {OPTIONS.map((o) => (
+ {options.map((option) => (
))}
+ {capability.mandatory || capability.values.length === 0 ? (
+
+ {capability.mandatory
+ ? t('reasoning.mandatory')
+ : t('reasoning.providerControlled')}
+
+ ) : null}
) : null}
diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx
index 6acaaa8..5f37a5f 100644
--- a/web/src/lib/i18n.tsx
+++ b/web/src/lib/i18n.tsx
@@ -451,6 +451,11 @@ const en = {
'ask.add': 'Add answer',
'chat.nothingToCopy': 'There is no reply to copy yet.',
'chat.reasoning': 'Reasoning',
+ 'reasoning.auto': 'Auto',
+ 'reasoning.unsupported': 'Unsupported legacy value: {value}. Choose Auto to replace it.',
+ 'reasoning.autoHint': 'Auto keeps reasoning adaptive or uses the model/provider default.',
+ 'reasoning.mandatory': 'This model always reasons; Auto keeps its required behavior.',
+ 'reasoning.providerControlled': 'This model exposes no reasoning overrides; the provider controls it.',
'chat.working': 'Working…',
'chat.attachAuthFailed': 'Dashboard login expired — refresh and sign in again.',
'chat.waitingAnswer': 'Paused — waiting for your answer',
@@ -1469,6 +1474,11 @@ const id: Dict = {
'ask.add': 'Tambah jawaban',
'chat.nothingToCopy': 'Belum ada balasan untuk disalin.',
'chat.reasoning': 'Penalaran',
+ 'reasoning.auto': 'Otomatis',
+ 'reasoning.unsupported': 'Nilai lama tidak didukung: {value}. Pilih Otomatis untuk menggantinya.',
+ 'reasoning.autoHint': 'Otomatis mempertahankan penalaran adaptif atau memakai default model/provider.',
+ 'reasoning.mandatory': 'Model ini selalu memakai penalaran; Otomatis mempertahankan perilaku wajibnya.',
+ 'reasoning.providerControlled': 'Model ini tidak menyediakan override penalaran; provider yang mengaturnya.',
'chat.working': 'Sedang bekerja…',
'chat.attachAuthFailed': 'Login dashboard kedaluwarsa — muat ulang dan masuk lagi.',
'chat.waitingAnswer': 'Dijeda — menunggu jawabanmu',
@@ -2254,6 +2264,11 @@ const ja: Dict = {
'approval.expired': 'その要求はすでに期限切れです。',
'chat.nothingToCopy': 'コピーできる返信がまだありません。',
'chat.reasoning': '推論',
+ 'reasoning.auto': '自動',
+ 'reasoning.unsupported': '未対応の従来値: {value}。置き換えるには「自動」を選んでください。',
+ 'reasoning.autoHint': '自動では、適応型推論またはモデル/プロバイダーの既定値を使用します。',
+ 'reasoning.mandatory': 'このモデルでは推論が必須です。「自動」は必須の動作を維持します。',
+ 'reasoning.providerControlled': 'このモデルは推論の上書きを公開していません。プロバイダーが制御します。',
'chat.tokensOut': '出力 {n} トークン',
'chat.welcomeTitle': '会話を始める',
'chat.welcomeDesc':
@@ -2956,6 +2971,11 @@ const zh: Dict = {
'approval.expired': '该请求已经超时,不再等待。',
'chat.nothingToCopy': '还没有可复制的回复。',
'chat.reasoning': '推理过程',
+ 'reasoning.auto': '自动',
+ 'reasoning.unsupported': '不支持的旧值:{value}。请选择“自动”进行替换。',
+ 'reasoning.autoHint': '自动会保留自适应推理,或使用模型/提供商的默认值。',
+ 'reasoning.mandatory': '此模型必须进行推理;自动会保留其必需行为。',
+ 'reasoning.providerControlled': '此模型未提供推理覆盖项;由提供商控制。',
'chat.tokensOut': '输出 {n} 个 token',
'chat.welcomeTitle': '开始对话',
'chat.welcomeDesc': 'Antares 可以访问文件、终端、网页搜索、长期记忆和 RAG 索引。',
@@ -3656,6 +3676,11 @@ const ru: Dict = {
'approval.expired': 'Этот запрос больше не ждёт — истёк срок.',
'chat.nothingToCopy': 'Копировать пока нечего.',
'chat.reasoning': 'Рассуждение',
+ 'reasoning.auto': 'Авто',
+ 'reasoning.unsupported': 'Неподдерживаемое устаревшее значение: {value}. Выберите «Авто», чтобы заменить его.',
+ 'reasoning.autoHint': 'Авто оставляет адаптивное рассуждение или использует значение по умолчанию модели/провайдера.',
+ 'reasoning.mandatory': 'Для этой модели рассуждение обязательно; «Авто» сохраняет это поведение.',
+ 'reasoning.providerControlled': 'Эта модель не предоставляет переопределения рассуждения; им управляет провайдер.',
'chat.tokensOut': '{n} токенов на выходе',
'chat.welcomeTitle': 'Начните разговор',
'chat.welcomeDesc':
diff --git a/web/src/lib/models.ts b/web/src/lib/models.ts
new file mode 100644
index 0000000..394d01d
--- /dev/null
+++ b/web/src/lib/models.ts
@@ -0,0 +1,21 @@
+export interface ReasoningValue {
+ value: string
+ label: string
+ kind?: 'disable'
+}
+
+export interface ReasoningCapability {
+ values: ReasoningValue[]
+ default?: string
+ mandatory: boolean
+ can_disable: boolean
+ source: 'live' | 'static'
+}
+
+export interface ChatModelSelection {
+ provider: string
+ model: string
+ name: string
+ providerLabel: string
+ reasoningCapability?: ReasoningCapability
+}
diff --git a/web/src/lib/reasoning.test.mjs b/web/src/lib/reasoning.test.mjs
new file mode 100644
index 0000000..e574bda
--- /dev/null
+++ b/web/src/lib/reasoning.test.mjs
@@ -0,0 +1,121 @@
+import { describe, expect, test } from 'bun:test'
+import {
+ loadReasoningPreference,
+ reasoningOptions,
+ reasoningPreferenceKey,
+ saveReasoningPreference,
+} from './reasoning.ts'
+
+describe('reasoning options', () => {
+ test('preserve opaque values and mark only explicit disable', () => {
+ const cap = {
+ values: [
+ { value: 'none', label: 'Off', kind: 'disable' },
+ { value: 'extra-high', label: 'Extra High' },
+ ],
+ default: 'extra-high',
+ mandatory: false,
+ can_disable: true,
+ source: 'live',
+ }
+
+ expect(reasoningOptions(cap).map((x) => x.value)).toEqual(['', 'none', 'extra-high'])
+ })
+
+ test('offer only Auto when a model has no capability metadata', () => {
+ expect(reasoningOptions(undefined).map((x) => x.value)).toEqual([''])
+ })
+
+ test('omit explicit disable values for mandatory models', () => {
+ const cap = {
+ values: [
+ { value: 'none', label: 'Off', kind: 'disable' },
+ { value: 'MiXeD', label: 'Mixed' },
+ ],
+ mandatory: true,
+ can_disable: false,
+ source: 'static',
+ }
+
+ expect(reasoningOptions(cap).map((x) => x.value)).toEqual(['', 'MiXeD'])
+ })
+})
+
+describe('reasoning preferences', () => {
+ test('migrate a legacy preference once only when valid', () => {
+ const storage = memoryStorage({ 'antares:reasoning': 'high' })
+ const cap = capability(['low', 'high'])
+
+ expect(loadReasoningPreference(storage, 'openai', 'gpt-5', cap)).toEqual({
+ value: 'high',
+ migrated: true,
+ })
+ expect(storage.getItem('antares:reasoning')).toBeNull()
+ expect(storage.getItem(reasoningPreferenceKey('openai', 'gpt-5'))).toBe('high')
+ })
+
+ test('remove an invalid legacy preference after its single migration attempt', () => {
+ const storage = memoryStorage({ 'antares:reasoning': 'HIGH' })
+
+ expect(loadReasoningPreference(storage, 'openai', 'gpt-5', capability(['high']))).toEqual({
+ value: '',
+ migrated: false,
+ })
+ expect(storage.getItem('antares:reasoning')).toBeNull()
+ expect(storage.getItem(reasoningPreferenceKey('openai', 'gpt-5'))).toBeNull()
+ })
+
+ test('sanitize an invalid scoped value without changing case', () => {
+ const key = reasoningPreferenceKey('openai', 'gpt-5')
+ const storage = memoryStorage({ [key]: 'HIGH' })
+
+ expect(loadReasoningPreference(storage, 'openai', 'gpt-5', capability(['high']))).toEqual({
+ value: '',
+ migrated: false,
+ })
+ expect(storage.getItem(key)).toBeNull()
+ })
+
+ test('isolate encoded provider and model storage keys', () => {
+ const storage = memoryStorage()
+ const first = reasoningPreferenceKey('provider/one', 'model:alpha')
+ const second = reasoningPreferenceKey('provider', 'one/model:alpha')
+
+ expect(first).toBe('antares:reasoning:v2:provider%2Fone:model%3Aalpha')
+ expect(second).toBe('antares:reasoning:v2:provider:one%2Fmodel%3Aalpha')
+ expect(first).not.toBe(second)
+
+ saveReasoningPreference(storage, 'provider/one', 'model:alpha', 'MiXeD')
+ saveReasoningPreference(storage, 'provider', 'one/model:alpha', 'extra-high')
+
+ expect(storage.getItem(first)).toBe('MiXeD')
+ expect(storage.getItem(second)).toBe('extra-high')
+ })
+
+ test('store Auto as no explicit scoped override', () => {
+ const key = reasoningPreferenceKey('openai', 'gpt-5')
+ const storage = memoryStorage({ [key]: 'high' })
+
+ saveReasoningPreference(storage, 'openai', 'gpt-5', '')
+
+ expect(storage.getItem(key)).toBeNull()
+ })
+})
+
+function memoryStorage(initial = {}) {
+ const values = new Map(Object.entries(initial))
+ return {
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => values.set(key, value),
+ removeItem: (key) => values.delete(key),
+ }
+}
+
+function capability(values) {
+ return {
+ values: values.map((value) => ({ value, label: value })),
+ mandatory: false,
+ can_disable: false,
+ source: 'static',
+ }
+}
diff --git a/web/src/lib/reasoning.ts b/web/src/lib/reasoning.ts
new file mode 100644
index 0000000..78bf703
--- /dev/null
+++ b/web/src/lib/reasoning.ts
@@ -0,0 +1,72 @@
+import type { ReasoningCapability, ReasoningValue } from '@/lib/models'
+
+export interface StorageLike {
+ getItem(key: string): string | null
+ setItem(key: string, value: string): void
+ removeItem(key: string): void
+}
+
+const LEGACY_REASONING_KEY = 'antares:reasoning'
+const REASONING_KEY_PREFIX = 'antares:reasoning:v2'
+
+export function reasoningOptions(capability?: ReasoningCapability): ReasoningValue[] {
+ if (!capability) return [{ value: '', label: 'Auto' }]
+ const values = capability.values.filter(
+ (option) =>
+ option.kind !== 'disable' ||
+ (capability.can_disable && !capability.mandatory),
+ )
+ return [{ value: '', label: 'Auto' }, ...values]
+}
+
+export function reasoningPreferenceKey(provider: string, model: string): string {
+ return `${REASONING_KEY_PREFIX}:${encodeURIComponent(provider)}:${encodeURIComponent(model)}`
+}
+
+export function loadReasoningPreference(
+ storage: StorageLike,
+ provider: string,
+ model: string,
+ capability?: ReasoningCapability,
+): { value: string; migrated: boolean } {
+ const key = reasoningPreferenceKey(provider, model)
+ const allowed = new Set(reasoningOptions(capability).map((option) => option.value))
+
+ try {
+ const scoped = storage.getItem(key)
+ const legacy = storage.getItem(LEGACY_REASONING_KEY)
+ if (legacy !== null) storage.removeItem(LEGACY_REASONING_KEY)
+
+ if (scoped !== null) {
+ if (scoped && allowed.has(scoped)) {
+ return { value: scoped, migrated: false }
+ }
+ storage.removeItem(key)
+ return { value: '', migrated: false }
+ }
+
+ if (legacy && allowed.has(legacy)) {
+ storage.setItem(key, legacy)
+ return { value: legacy, migrated: true }
+ }
+ } catch {
+ // Storage is best-effort (private browsing and quotas may reject access).
+ }
+
+ return { value: '', migrated: false }
+}
+
+export function saveReasoningPreference(
+ storage: StorageLike,
+ provider: string,
+ model: string,
+ value: string,
+): void {
+ const key = reasoningPreferenceKey(provider, model)
+ try {
+ if (value) storage.setItem(key, value)
+ else storage.removeItem(key)
+ } catch {
+ // Preference persistence must never prevent composing a message.
+ }
+}
diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx
index 0dcfefd..afd3962 100644
--- a/web/src/pages/ChatPage.tsx
+++ b/web/src/pages/ChatPage.tsx
@@ -26,6 +26,12 @@ import {
} from '@/lib/chatStreamQueue'
import { copyText } from '@/lib/clipboard'
import { useI18n, useTimeAgo, type MessageKey } from '@/lib/i18n'
+import type { ChatModelSelection, ReasoningCapability } from '@/lib/models'
+import {
+ loadReasoningPreference,
+ reasoningOptions,
+ saveReasoningPreference,
+} from '@/lib/reasoning'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/primitives'
@@ -363,24 +369,45 @@ export default function ChatPage() {
if (r) localStorage.setItem('antares:last-role', r)
else localStorage.removeItem('antares:last-role')
}, [])
- // Per-turn reasoning effort picked in the composer. Empty means "use the
- // configured default" (agent.reasoning_effort, then model). Persisted so the
- // choice survives a reload, mirroring the role picker.
- const [reasoning, setReasoning] = useState(
- () => localStorage.getItem('antares:reasoning') ?? '',
- )
- const pickReasoning = useCallback((r: string) => {
- setReasoning(r)
- if (r) localStorage.setItem('antares:reasoning', r)
- else localStorage.removeItem('antares:reasoning')
+ // The model, its capability, and its scoped reasoning value move together.
+ // Updating the ref synchronously prevents a send immediately after switching
+ // models from carrying the previous model's override.
+ const [modelSelection, setModelSelection] = useState()
+ const [reasoning, setReasoning] = useState('')
+ const composerReasoningRef = useRef<{
+ selection: ChatModelSelection
+ capability?: ReasoningCapability
+ value: string
+ } | null>(null)
+ const selectModel = useCallback((selection: ChatModelSelection) => {
+ const capability = selection.reasoningCapability
+ const { value } = loadReasoningPreference(
+ localStorage,
+ selection.provider,
+ selection.model,
+ capability,
+ )
+ composerReasoningRef.current = { selection, capability, value }
+ setModelSelection(selection)
+ setReasoning(value)
+ }, [])
+ const pickReasoning = useCallback((value: string) => {
+ const current = composerReasoningRef.current
+ if (!current) return
+ const next = reasoningOptions(current.capability).some(
+ (option) => option.value === value,
+ )
+ ? value
+ : ''
+ saveReasoningPreference(
+ localStorage,
+ current.selection.provider,
+ current.selection.model,
+ next,
+ )
+ composerReasoningRef.current = { ...current, value: next }
+ setReasoning(next)
}, [])
- // Per-chat model override, chosen via the picker in the composer. Kept in a
- // ref so the stream request closure always reads the latest selection.
- const [activeModel, setActiveModel] = useState('')
- const activeModelRef = useRef('')
- useEffect(() => {
- activeModelRef.current = activeModel
- }, [activeModel])
// Project session: the folder this chat is bound to. Chosen on a NEW chat and
// sent with the first message; once the session exists it is fixed (locked).
const [projectDir, setProjectDir] = useState('')
@@ -1086,6 +1113,7 @@ export default function ChatPage() {
setStreaming(true)
setLive({ turn: 1 })
+ const composerReasoning = composerReasoningRef.current
abortRef.current = streamPost(
'/chat',
{
@@ -1095,10 +1123,16 @@ export default function ChatPage() {
role,
// Per-chat model override; omitted when unset so the server falls
// back to the configured default.
- ...(activeModelRef.current ? { model: activeModelRef.current } : {}),
+ ...(composerReasoning
+ ? {
+ model: `${composerReasoning.selection.provider}/${composerReasoning.selection.model}`,
+ }
+ : {}),
// Per-turn reasoning override; omitted when unset so the server falls
// back to the configured default.
- ...(reasoning ? { reasoning_effort: reasoning } : {}),
+ ...(composerReasoning?.value
+ ? { reasoning_effort: composerReasoning.value }
+ : {}),
// Only meaningful when starting a new session; the server ignores it once
// the session exists. Read from the ref so an auto-analyze turn fired
// right after binding still carries the project.
@@ -1170,7 +1204,7 @@ export default function ChatPage() {
},
)
},
- [role, reasoning, projectDir, streaming, sessionId, navigate, runCommand, applyEvent, drainPatches, t],
+ [role, projectDir, streaming, sessionId, navigate, runCommand, applyEvent, drainPatches, t],
)
const send = useCallback(() => {
@@ -1446,8 +1480,13 @@ export default function ChatPage() {
roleSlot={
-
-
+
+
{
diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx
index 8e894cf..cbcaa7b 100644
--- a/web/src/pages/ConfigPage.tsx
+++ b/web/src/pages/ConfigPage.tsx
@@ -15,6 +15,8 @@ import {
import { post } from '@/lib/api'
import { useApi } from '@/lib/hooks'
import { useI18n } from '@/lib/i18n'
+import type { ReasoningCapability } from '@/lib/models'
+import { reasoningOptions } from '@/lib/reasoning'
import { cn } from '@/lib/utils'
import { usePageActions } from '@/components/layout/PageChrome'
import { Button } from '@/components/ui/button'
@@ -44,6 +46,7 @@ interface Field {
default: unknown
secret: boolean
enum?: string[]
+ options_source?: string
help?: string
}
@@ -52,6 +55,15 @@ interface ConfigResponse {
schema: Field[]
}
+interface ReasoningModelsResponse {
+ active: { model: string; provider: string }
+ models: Array<{
+ id: string
+ provider: string
+ reasoning_capability?: ReasoningCapability
+ }>
+}
+
const ESSENTIALS = '__essentials'
const YAML = '__yaml'
@@ -82,6 +94,7 @@ export default function ConfigPage() {
const { t } = useI18n()
const { data, loading, reload } = useApi('/config')
const rawState = useApi<{ yaml: string }>('/config/raw')
+ const modelsState = useApi('/model/list-all')
const [edits, setEdits] = useState>({})
const [saving, setSaving] = useState(false)
@@ -93,6 +106,25 @@ export default function ConfigPage() {
const [section, setSection] = useState(ESSENTIALS)
const [showAdvanced, setShowAdvanced] = useState(false)
+ const configuredProvider = String(
+ edits['model.provider'] ??
+ (data ? readPath(data.values, 'model.provider') : '') ??
+ '',
+ )
+ const configuredModel = String(
+ edits['model.default'] ??
+ (data ? readPath(data.values, 'model.default') : '') ??
+ '',
+ )
+ const reasoningCapability = useMemo(
+ () =>
+ modelsState.data?.models.find(
+ (model) =>
+ model.provider === configuredProvider && model.id === configuredModel,
+ )?.reasoning_capability,
+ [configuredModel, configuredProvider, modelsState.data],
+ )
+
const query = filter.trim().toLowerCase()
const searching = query.length > 0
const dirty = Object.keys(edits).length
@@ -205,6 +237,7 @@ export default function ConfigPage() {
field={f}
showGroup={withGroup}
value={valueOf(f)}
+ reasoningCapability={reasoningCapability}
dirty={f.path in edits}
revealed={!!revealed[f.path]}
onReveal={() => setRevealed((r) => ({ ...r, [f.path]: !r[f.path] }))}
@@ -411,6 +444,7 @@ function SectionRail({
function FieldRow({
field,
value,
+ reasoningCapability,
dirty,
revealed,
showGroup,
@@ -419,6 +453,7 @@ function FieldRow({
}: {
field: Field
value: unknown
+ reasoningCapability?: ReasoningCapability
dirty: boolean
revealed: boolean
showGroup?: boolean
@@ -452,7 +487,13 @@ function FieldRow({
- {field.enum ? (
+ {field.options_source === 'reasoning_capability' ? (
+
+ ) : field.enum ? (
diff --git a/web/src/pages/ProvidersPage.tsx b/web/src/pages/ProvidersPage.tsx
index aa69b8b..ff9981d 100644
--- a/web/src/pages/ProvidersPage.tsx
+++ b/web/src/pages/ProvidersPage.tsx
@@ -7,6 +7,7 @@ import {
Eye,
EyeSlash,
Key,
+ Lightning,
Plugs,
ShieldCheck,
Trash,
@@ -15,6 +16,7 @@ import { del, get, post } from '@/lib/api'
import { agentModelsErrorText, isAgentProvider, providerModelsPath, type ProviderCapability } from '@/lib/providerCapabilities'
import { useApi } from '@/lib/hooks'
import { useI18n } from '@/lib/i18n'
+import type { ReasoningCapability } from '@/lib/models'
import { cn } from '@/lib/utils'
import { PageLayout } from '@/components/layout/PageLayout'
import { Button } from '@/components/ui/button'
@@ -244,6 +246,8 @@ interface AllModel {
provider: string
provider_label: string
context_window: number
+ reasoning: boolean
+ reasoning_capability?: ReasoningCapability
}
interface AgentModel {
@@ -338,7 +342,7 @@ function ProviderModal({
if (!q) return
try {
const r = await get<{ found: boolean; context_window?: number }>(
- `/providers/${encodeURIComponent(p.id)}/model-info?id=${encodeURIComponent(q)}`,
+ `/providers/${encodeURIComponent(p.id)}/model-info?model=${encodeURIComponent(q)}`,
)
if (r.found && r.context_window) {
setNewCtx(String(r.context_window))
@@ -538,6 +542,14 @@ function ProviderModal({
{t('models.ctx', { n: Math.round(m.context_window / 1000) })}
) : null}
+ {m.reasoning ? (
+
+
+ {m.reasoning_capability
+ ? t('models.reasoning')
+ : t('reasoning.providerControlled')}
+
+ ) : null}