mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -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.
100 lines
3.5 KiB
Go
100 lines
3.5 KiB
Go
package reasoning
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// modelsdev_snapshot.json is a trimmed snapshot of https://models.dev/api.json:
|
|
// for a set of first-party providers, each model's api id mapped to its
|
|
// reasoning capability (the `reasoning` flag and `reasoning_options`). Routers
|
|
// (OpenRouter/Azure/etc.) are intentionally excluded — they carry generic or
|
|
// stale option lists, so a lookup resolves to the first-party provider instead.
|
|
//
|
|
// Provenance is recorded in the snapshot's `_source`/`_fetched` header fields.
|
|
// To regenerate: fetch api.json, then for each provider in
|
|
// {anthropic, openai, google, xai, groq, deepseek} keep `{reasoning,
|
|
// reasoning_options}` per api id, and bump `_fetched`. Runtime auto-refresh is a
|
|
// separate change.
|
|
//
|
|
//go:embed modelsdev_snapshot.json
|
|
var snapshotBytes []byte
|
|
|
|
// snapshotDoc is the on-disk shape of the embedded snapshot.
|
|
type snapshotDoc struct {
|
|
Providers map[string]map[string]Capability `json:"providers"`
|
|
}
|
|
|
|
// Catalog is a reasoning-capability lookup keyed by provider slug and api model
|
|
// id. It is read-only after construction.
|
|
type Catalog struct {
|
|
byProvider map[string]map[string]Capability
|
|
}
|
|
|
|
// ParseCatalog builds a Catalog from a models.dev-style snapshot document.
|
|
func ParseCatalog(data []byte) (Catalog, error) {
|
|
var doc snapshotDoc
|
|
if err := json.Unmarshal(data, &doc); err != nil {
|
|
return Catalog{}, fmt.Errorf("reasoning: parse catalog: %w", err)
|
|
}
|
|
if len(doc.Providers) == 0 {
|
|
return Catalog{}, fmt.Errorf("reasoning: catalog has no providers")
|
|
}
|
|
return Catalog{byProvider: doc.Providers}, nil
|
|
}
|
|
|
|
// embedded is the catalog parsed from the bundled snapshot. A malformed embed is
|
|
// a build/release error, so it panics at init rather than failing silently.
|
|
var embedded = mustParseEmbedded()
|
|
|
|
func mustParseEmbedded() Catalog {
|
|
c, err := ParseCatalog(snapshotBytes)
|
|
if err != nil {
|
|
panic("reasoning: invalid embedded snapshot: " + err.Error())
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Embedded returns the catalog parsed from the bundled models.dev snapshot.
|
|
func Embedded() Catalog { return embedded }
|
|
|
|
// Lookup returns the reasoning capability for a model identified by its Zero
|
|
// provider kind and the API model id — the provider's wire name (e.g.
|
|
// "claude-opus-4-1-20250805"), NOT the friendly registry id ("claude-opus-4.1").
|
|
// The match is exact: the api model id is only trimmed, not case-folded, and no
|
|
// router prefixes ("openai/…") or suffixes (":cloud") are stripped. ok is false
|
|
// when the provider or model is not in the snapshot, in which case the caller
|
|
// falls back to its next capability source.
|
|
func (c Catalog) Lookup(provider, apiModel string) (Capability, bool) {
|
|
apiModel = strings.TrimSpace(apiModel)
|
|
if apiModel == "" {
|
|
return Capability{}, false
|
|
}
|
|
for _, slug := range providerSlugs(provider) {
|
|
models, ok := c.byProvider[slug]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if entry, ok := models[apiModel]; ok {
|
|
// Deep copy so callers cannot mutate the shared catalog through the
|
|
// returned Controls / Values slices or budget pointers.
|
|
return entry.clone(), true
|
|
}
|
|
}
|
|
return Capability{}, false
|
|
}
|
|
|
|
// providerSlugs maps a Zero provider kind to the models.dev provider slug to
|
|
// look up. Gemini and Google both resolve to "google" (AI Studio), the
|
|
// first-party source for Gemini models.
|
|
func providerSlugs(provider string) []string {
|
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
|
case "gemini":
|
|
return []string{"google"}
|
|
default:
|
|
return []string{strings.ToLower(strings.TrimSpace(provider))}
|
|
}
|
|
}
|