mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* feat(reasoning): typed per-provider reasoning capability from a models.dev catalog
Providers describe reasoning control with incompatible concepts — OpenAI a
discrete effort enum (split by minor version), Anthropic and Gemini 2.5 a
thinking-token budget, some models only a thinking on/off toggle — which a flat
effort string cannot represent and a model-name heuristic cannot detect.
Add internal/reasoning: a Capability type (a model's reasoning flag plus its
typed Controls — effort/budget_tokens/toggle) and a Catalog that looks capability
up by provider + api model id. The data is an embedded, trimmed snapshot of the
community capability database (first-party providers only; routers excluded since
they carry generic/stale option lists), so detection is offline and exact instead
of guessed.
The package depends only on the standard library, so the model registry and
provider adapters can consume it without an import cycle. This change is purely
additive — nothing is wired into the live resolution path yet.
Tests pin the per-version ground truth (gpt-5 minimal-not-none, gpt-5.1
none-not-minimal, gpt-5.1-codex-max xhigh, gpt-5-pro locked to high; Anthropic
budget-only legacy vs effort-only newer with the budget removed; Gemini 2.5
budget/toggle vs Gemini 3 effort) and that every reasoning model Zero ships today
is covered by the snapshot.
* fix(reasoning): address review — presence-aware budget bounds, deep-copy lookup, first-party-only snapshot
- Control.Min/Max are now *int so an explicit bound is distinct from an omitted
one: Gemini's `min: 0` ("thinking can be disabled") and Anthropic's missing
max (unbounded) are preserved instead of both collapsing to 0. Budget() becomes
BudgetControl() returning the control so callers read the pointers.
- Catalog.Lookup returns a deep copy (clone) of the matched Capability, so a
caller can no longer mutate the shared embedded catalog through the returned
Controls / Values slices or budget pointers.
- Drop the google-vertex section from the snapshot: it re-hosts third-party MaaS
models (Anthropic/Moonshot/OpenAI/DeepSeek/Qwen/Meta), violating first-party
-only. google (AI Studio) already covers first-party Gemini, so the gemini
provider kind resolves there. Snapshot 158 -> 123 models, 16.8 KB.
Tests pin the explicit min: 0 preservation, the nil max on budget-only Anthropic
models, and that a mutation of a Lookup result does not leak into the catalog.
* fix(reasoning): address round-2 review — accessor deep-copy, max tier, provenance
- EffortControl/BudgetControl now return a cloned Control, so a caller holding a
Capability that shares state with the catalog cannot mutate Min/Max pointers or
the Values slice through an accessor result (CodeRabbit). Lookup already deep
-copies; this closes the accessor path too.
- Add modelregistry.ReasoningEffortMax ("max") + accept it in ValidReasoningEffort,
so the snapshot's "max" tier (e.g. gpt-5.1-codex-max, newer Anthropic) has a
constant to map to. No curated model lists it yet.
- Snapshot provenance: add a _fetched date and a regeneration recipe (documented
in catalog.go), so refreshes are reproducible/auditable.
- Document that Lookup takes the API model id (wire name), not the friendly
registry id, and that the match is exact (no case-fold/prefix-strip).
- Record the source-of-truth intent in the package doc: this catalog is the
intended authoritative source; modelregistry's name-pattern inference is demoted
to a fallback when it is wired in (a later change). Rename the `cap` builtin
shadow to `entry`.
Tests pin the accessor deep-copy isolation.
148 lines
5.4 KiB
Go
148 lines
5.4 KiB
Go
// Package reasoning models how each provider's models expose reasoning-effort
|
|
// control. Providers disagree on the concept — OpenAI uses a discrete effort
|
|
// enum, Anthropic and Gemini 2.5 use a thinking-token budget, some models only
|
|
// toggle thinking on/off — so a single flat effort string cannot describe them.
|
|
//
|
|
// Capability is the typed, per-model description of that control, sourced from a
|
|
// community capability catalog (models.dev). It is the data the rest of Zero
|
|
// consults to decide which reasoning tiers a model actually supports, replacing
|
|
// model-name guessing. This package depends only on the standard library, so the
|
|
// model registry and provider adapters can import it without a cycle.
|
|
//
|
|
// Source of truth: this catalog is intended to become the authoritative reasoning
|
|
// -capability source. When it is wired into the live path, modelregistry's
|
|
// name-pattern inference (reasoningEffortsForModelName) is demoted to a fallback
|
|
// for models the catalog does not cover; the catalog wins where it has an entry.
|
|
// This commit is additive groundwork — nothing consumes it yet — so there is no
|
|
// behavioral divergence until that wiring lands.
|
|
package reasoning
|
|
|
|
import "strings"
|
|
|
|
// ControlKind enumerates how a model exposes reasoning control. The values
|
|
// mirror the models.dev reasoning_options[].type field.
|
|
type ControlKind string
|
|
|
|
const (
|
|
// ControlEffort is a discrete effort enum (OpenAI reasoning_effort, Gemini 3
|
|
// thinkingLevel, newer Claude output_config.effort).
|
|
ControlEffort ControlKind = "effort"
|
|
// ControlBudget is a thinking-token budget (Gemini 2.5 thinkingBudget, legacy
|
|
// Claude thinking.budget_tokens).
|
|
ControlBudget ControlKind = "budget_tokens"
|
|
// ControlToggle is an on/off thinking switch with no levels.
|
|
ControlToggle ControlKind = "toggle"
|
|
)
|
|
|
|
// Control is one reasoning control a model accepts. For an effort control,
|
|
// Values lists the accepted tiers ordered weakest to strongest. For a budget
|
|
// control, Min/Max bound the thinking-token budget; each is nil when the
|
|
// provider does not bound that side, kept distinct from an explicit 0 (Gemini
|
|
// uses min: 0 to mean "thinking can be disabled", which a plain int would lose).
|
|
type Control struct {
|
|
Kind ControlKind `json:"type"`
|
|
Values []string `json:"values,omitempty"`
|
|
Min *int `json:"min,omitempty"`
|
|
Max *int `json:"max,omitempty"`
|
|
}
|
|
|
|
// clone returns a deep copy of the control so the embedded catalog cannot be
|
|
// mutated through a returned Control's slice or budget pointers.
|
|
func (ctrl Control) clone() Control {
|
|
out := Control{Kind: ctrl.Kind}
|
|
if ctrl.Values != nil {
|
|
out.Values = append([]string(nil), ctrl.Values...)
|
|
}
|
|
if ctrl.Min != nil {
|
|
min := *ctrl.Min
|
|
out.Min = &min
|
|
}
|
|
if ctrl.Max != nil {
|
|
max := *ctrl.Max
|
|
out.Max = &max
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Capability is the reasoning capability of a single model: whether it reasons
|
|
// at all, and through which controls. A model may carry more than one control
|
|
// (e.g. a newer Claude model exposes both an effort enum and a token budget); a
|
|
// reasoning model with no controls reasons but exposes no knob (always-on).
|
|
type Capability struct {
|
|
Reasoning bool `json:"reasoning"`
|
|
Controls []Control `json:"reasoning_options,omitempty"`
|
|
}
|
|
|
|
// clone returns a deep copy of the capability, cloning each control, so a caller
|
|
// cannot mutate the shared catalog through the returned value's slices.
|
|
func (c Capability) clone() Capability {
|
|
out := Capability{Reasoning: c.Reasoning}
|
|
if c.Controls != nil {
|
|
out.Controls = make([]Control, len(c.Controls))
|
|
for i, ctrl := range c.Controls {
|
|
out.Controls[i] = ctrl.clone()
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Supported reports whether the model performs any reasoning.
|
|
func (c Capability) Supported() bool { return c.Reasoning }
|
|
|
|
// EffortControl returns the model's effort control and whether it has one. The
|
|
// returned Control is a deep copy, so a caller cannot mutate the shared catalog
|
|
// through its Values slice.
|
|
func (c Capability) EffortControl() (Control, bool) {
|
|
for _, ctrl := range c.Controls {
|
|
if ctrl.Kind == ControlEffort {
|
|
return ctrl.clone(), true
|
|
}
|
|
}
|
|
return Control{}, false
|
|
}
|
|
|
|
// EffortValues returns the ordered effort tiers the model accepts, or nil when
|
|
// it has no effort control (a budget- or toggle-only model, or a non-reasoning
|
|
// model).
|
|
func (c Capability) EffortValues() []string {
|
|
if ctrl, ok := c.EffortControl(); ok {
|
|
return ctrl.Values
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SupportsEffort reports whether tier is one of the model's accepted effort
|
|
// values (case-insensitive).
|
|
func (c Capability) SupportsEffort(tier string) bool {
|
|
tier = strings.ToLower(strings.TrimSpace(tier))
|
|
for _, v := range c.EffortValues() {
|
|
if strings.ToLower(v) == tier {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// BudgetControl returns the model's token-budget control and whether it has one.
|
|
// The returned Control is a deep copy: its Min/Max are independent pointers, so a
|
|
// caller cannot mutate the shared catalog through them. Min/Max are nil when that
|
|
// bound is unspecified (a real 0, e.g. Gemini's min: 0, is a non-nil pointer to 0).
|
|
func (c Capability) BudgetControl() (Control, bool) {
|
|
for _, ctrl := range c.Controls {
|
|
if ctrl.Kind == ControlBudget {
|
|
return ctrl.clone(), true
|
|
}
|
|
}
|
|
return Control{}, false
|
|
}
|
|
|
|
// HasControl reports whether the model exposes a control of the given kind.
|
|
func (c Capability) HasControl(kind ControlKind) bool {
|
|
for _, ctrl := range c.Controls {
|
|
if ctrl.Kind == kind {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|