textmachine/backend/internal/config/models.go

830 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package config loads and fail-fast-validates the three YAML files of a run:
// models.yaml (providers/models/prices/timeouts — Р4/Р5: prices only in the
// config, with a check date), pipeline-*.yaml (the C1/C2 core — the «config vs
// code» boundary per Р2) and book.yaml (translation brief + brief_hash).
//
// Donor discipline: all config problems are collected into a LIST and fail the
// start with one error — the operator fixes everything at once, not one bump per launch.
package config
import (
"bytes"
"fmt"
"os"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
"textmachine/backend/internal/ledger"
"textmachine/backend/internal/llm"
)
// Models is the parsed models.yaml.
type Models struct {
// PricesChecked is the date the prices below were verified against the
// providers' official pages. Fail-fast rejects a stale (>120 days) config:
// API price volatility — risk Р10 №5.
PricesChecked string `yaml:"prices_checked"`
DefaultModel string `yaml:"default_model"`
Providers map[string]Provider `yaml:"providers"`
Models map[string]Model `yaml:"models"`
}
// Provider is one backend endpoint.
type Provider struct {
Kind string `yaml:"kind"` // openai | anthropic | local
BaseURL string `yaml:"base_url"`
APIKeyEnv string `yaml:"api_key_env"`
Reasoning string `yaml:"reasoning"` // subset | additive | additive_total (openai kind only)
// EchoesWhenThinkingOff marks a provider empirically shown to ECHO the
// untranslated CJK source instead of translating when thinking is OFF
// (DeepSeek, traced 2026-07-04 — reproducibly on dense CJK, 3/3 retries;
// PROGRESS «Ответ Полигону», BACKEND_SESSION_PROMPT_SILENT_REFUSALS §2). Its
// benign state is held by an off-by-omission: reasoning="off" resolves through
// ReasoningNone which emits NOTHING thinking-related, so the provider default
// (thinking ON) stands. A config that DISABLES thinking at reasoning=off on
// such a provider re-arms a silent refusal (echo = HTTP 200, no translation)
// in prod. LoadModels (echoMineViolation) fail-fasts on exactly that — the
// regression gate §2 mandates. Only DeepSeek carries the flag: pack-17 deliberately did not extend it
// to the other reasoning-off echo family (D19.1 point 2 names both grok slugs too), because the flag
// outlaws a model's off-switch and grok's off-switch has a money consequence that is the owner's call.
// ⚠ The old justification "GLM's thinking:disabled is fine, its input is a Russian draft (no CJK)" is
// STALE against D30.1 — the editor is BILINGUAL and does see dense CJK. The residual exposure is made
// loud instead of silent (pipeline.Runner.sourceEchoExposure) and caught downstream by the echo gate
// (D19.2). Validation-only metadata — it never reaches the wire, so it is NOT part of the snapshot.
EchoesWhenThinkingOff bool `yaml:"echoes_when_thinking_off"`
// AcceptsLabels is the set of content labels this provider is ALLOWED TO RECEIVE — the capability
// half of the generic mechanism (D39.25): a book whose content_labels are not a subset of a model's
// accepts_labels is never routed to it, and never reaches it at runtime either (the assert in
// runAttempt). The values are DATA — the engine knows none of them, so admitting a provider for a
// new label is one line here. A POINTER on purpose: nil = inherit (provider default for a model),
// a pointer to an EMPTY list = "declares none" — the distinction a plain slice cannot express, so a
// per-model override can SUBTRACT (a provider-wide permission narrowed for one model) instead of
// silently inheriting it. Whether a label is a ToS statement about this endpoint's operator is the
// author's judgement; the engine only compares sets.
AcceptsLabels *[]string `yaml:"accepts_labels"`
// LegacyPermissive is the RETIRED `permissive:` flag, declared to be REJECTED (the
// Stage.LegacyPrompt discipline): it was a single boolean meaning "may serve 18+", which the label
// set replaces with data. A pointer so PRESENCE is caught at any value — `permissive: false` was
// also a statement, and a config still carrying it would otherwise read as "no labels accepted"
// while its author believes the old gate still guards.
LegacyPermissive *bool `yaml:"permissive"`
CacheTTL string `yaml:"cache_ttl"` // anthropic kind: "", "5m", "1h"
Model string `yaml:"model"` // local kind: the backend's own tag
MaxTokens int `yaml:"max_tokens"`
// Temperature (local kind): the request override — ollama honors the request
// temperature over the Modelfile, and a cloud role's temperature would
// silently upset the local model. 0 = inherit the request.
Temperature float64 `yaml:"temperature"`
Timeouts Timeouts `yaml:"timeouts"`
// Capabilities is the DEFAULT wire shape for this provider's models (D3.1);
// a per-Model block overrides it field-by-field. Nil = OpenAI-compat
// baseline (max_tokens + send temperature + off-by-omission reasoning).
Capabilities *CapabilitiesConfig `yaml:"capabilities"`
}
// CapabilitiesConfig is the declarative per-model wire shape (D3.1). Empty
// fields inherit: a Model block layers over its Provider block, which layers
// over the kind baseline. Separate from Provider.Reasoning (subset|additive),
// which is a billing semantic — this is the wire body form.
type CapabilitiesConfig struct {
BudgetField string `yaml:"budget_field"` // max_tokens | max_completion_tokens
Temperature *TemperatureCap `yaml:"temperature"`
Reasoning *ReasoningCapCfg `yaml:"reasoning"`
// MinMaxTokens is a per-model floor on the derived max_tokens (D24.3): a
// thinking model whose reasoning eats the budget returns finish=length/empty
// below this minimum. 0 (unset) = inherit / no floor. Schema, not a comment —
// the Kimi≥16k / Gemini≥8k / DeepSeek≥8k min-budgets that were prose notes.
MinMaxTokens int `yaml:"min_max_tokens"`
// SystemMessages is the endpoint's system-message cardinality: "" (inherit / multi) or
// "single" for an endpoint that carries exactly ONE system message. Declared on the PROVIDER
// as a rule, because it is a property of the endpoint's request translation and not of a
// model's talent — every model behind the same base_url shares it.
SystemMessages string `yaml:"system_messages"`
}
// TemperatureCap declares how temperature reaches the wire.
type TemperatureCap struct {
Mode string `yaml:"mode"` // send | omit | force
Value float64 `yaml:"value"` // used when mode == force
}
// ReasoningCapCfg declares how the neutral reasoning effort maps to the wire.
type ReasoningCapCfg struct {
Control string `yaml:"control"` // none | effort | extra_body_disable | mandatory
OffEffort string `yaml:"off_effort"` // effort: value sent when reasoning=off
OffExtraBody map[string]any `yaml:"off_extra_body"` // extra_body_disable: merged when reasoning=off
OnExtraBody map[string]any `yaml:"on_extra_body"` // extra_body_disable: merged when reasoning is on (D6.2)
}
// Timeouts is the retry profile per provider (the per-provider profile from the
// validation verdict; per-role overrides — Phase 1).
type Timeouts struct {
// AttemptS is the FLOOR of one attempt's deadline, not the deadline itself: the real one is derived
// from the output budget the call carries (llm/attemptcut.go). Read as a fixed value it was wrong by
// construction for the editor — 240 s is what the vendor's own 128 000-tokens-per-hour figure gives
// for the DRAFT's ~8.5k budget, and the same number was carried to a stage budgeted at 16 000 with a
// doubling to 32 000, i.e. to a call that could not finish inside it at any speed the model holds.
AttemptS int `yaml:"attempt_s"`
MaxAttempts int `yaml:"max_attempts"`
BackoffCapS int `yaml:"backoff_cap_s"`
// TokSFloor is the slowest generation speed this provider has been OBSERVED to hold — below the p10
// of its own request_log, rounded down, and the rounding declared where it is set. Unset ⇒ the
// vendor default (128 000 tokens/hour), which is slower than any provider we have measured and
// therefore only ever grants a call MORE time than it needs.
TokSFloor float64 `yaml:"tok_s_floor"`
// QueueSlackS is how long the VENDOR documents a request may wait before generation starts. It is
// the vendor's number and not a guess: a slack smaller than what the vendor publishes silently
// re-decides how long we are willing to wait, in the direction of cutting calls we have paid for.
QueueSlackS int `yaml:"queue_slack_s"`
// AttemptMaxS caps the derived deadline — the longest a single call may hold a reservation. 0 =
// uncapped, i.e. the derivation stands on its own.
AttemptMaxS int `yaml:"attempt_max_s"`
}
func (t Timeouts) Profile() llm.RetryProfile {
return llm.RetryProfile{
AttemptTimeout: time.Duration(t.AttemptS) * time.Second,
MaxAttempts: t.MaxAttempts,
BackoffCap: time.Duration(t.BackoffCapS) * time.Second,
TokensPerSecFloor: t.TokSFloor,
QueueSlack: time.Duration(t.QueueSlackS) * time.Second,
AttemptMax: time.Duration(t.AttemptMaxS) * time.Second,
}
}
// Model is one priced model entry.
type Model struct {
Provider string `yaml:"provider"`
Price Price `yaml:"price"`
// ExtraBody is merged into request JSON for this model (provider-specific
// knobs: GLM thinking.type, Qwen enable_thinking …).
ExtraBody map[string]any `yaml:"extra_body"`
// MinCachePrefixTokens: Anthropic models — the minimum cacheable prefix
// (shorter — the cache is silently not created); the Phase 1 context assembler checks it.
MinCachePrefixTokens int `yaml:"min_cache_prefix_tokens"`
Note string `yaml:"note"`
// Capabilities overrides this model's provider-default wire shape (D3.1),
// field-by-field (model wins). Nil = inherit the provider/kind baseline.
Capabilities *CapabilitiesConfig `yaml:"capabilities"`
// AcceptsLabels narrows (never widens) the provider's accepted content labels for THIS model. nil =
// inherit the provider's set; a pointer to an empty list = this model accepts none. Widening is a
// load error: a permission is a statement about the endpoint that receives the bytes, so one model
// entry must not be able to grant what its provider does not (fail-closed, D39.26 point 6).
AcceptsLabels *[]string `yaml:"accepts_labels"`
// RateLimit caps how many parallel wave workers may call THIS model at once (WS1 §1б, review-2
// F4): mistral-large-latest fails ~48% of calls under N-parallelism (a token-bucket / concurrency
// cap, tier-dependent — quirks §Транспорт) while grok is 0%, so a model can bound its own
// wave concurrency. Optional min_interval paces call STARTS. A TRANSPORT axis — wire-neutral,
// NOT snapshot-folded (it never touches the request bytes). Zero = unlimited (the default).
RateLimit RateLimit `yaml:"rate_limit"`
}
// RateLimit is the per-model wave-concurrency guard (WS1). max_concurrency 0 = unlimited;
// min_interval_ms 0 = no pacing.
type RateLimit struct {
MaxConcurrency int `yaml:"max_concurrency"`
MinIntervalMS int `yaml:"min_interval_ms"`
}
// Price mirrors ledger.ModelPrice in YAML form (USD per 1M tokens).
type Price struct {
InputPerM float64 `yaml:"input_per_m"`
CachedPerM float64 `yaml:"cached_per_m"`
CacheWritePerM float64 `yaml:"cache_write_per_m"`
OutputPerM float64 `yaml:"output_per_m"`
}
func (p Price) ToLedger() ledger.ModelPrice {
return ledger.ModelPrice{
InputPerM: p.InputPerM,
CachedPerM: p.CachedPerM,
CacheWritePerM: p.CacheWritePerM,
OutputPerM: p.OutputPerM,
}
}
// maxPriceAge is how stale prices_checked may be before the config is
// rejected (the quarterly Р4 review + buffer).
const maxPriceAge = 120 * 24 * time.Hour
// LoadModels reads and validates models.yaml.
func LoadModels(path string) (*Models, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", path, err)
}
var m Models
// STRICT decode (KnownFields), sanctioned for pack-16 after a stand audit found ZERO unknown keys in any
// book-shaped yaml in the repo or on the stand: yaml.v3 silently DROPS an unknown field, so a typo reads
// as "not set" and the run looks normal — `langpack_extend` misspelled means the book's private canon is
// quietly absent, `glossary_seed` misspelled means the seed never loads. The pipeline loader has been
// strict since pack-15; these are the remaining halves of the same silent-substitution class.
dec := yaml.NewDecoder(bytes.NewReader(raw))
dec.KnownFields(true)
if err := dec.Decode(&m); err != nil {
return nil, fmt.Errorf("config: parse %s: %w", path, err)
}
var problems []string
bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
if m.PricesChecked == "" {
bad("prices_checked is required (prices with no check date are forbidden, Р4)")
} else if t, err := time.Parse("2006-01-02", m.PricesChecked); err != nil {
bad("prices_checked %q is not YYYY-MM-DD", m.PricesChecked)
} else if time.Since(t) > maxPriceAge {
bad("prices_checked %s is older than %d days — re-verify provider prices", m.PricesChecked, int(maxPriceAge.Hours()/24))
}
if m.DefaultModel == "" {
bad("default_model is required (price fallback anchor: an unknown model never costs $0)")
} else if _, ok := m.Models[m.DefaultModel]; !ok {
bad("default_model %q is not defined in models", m.DefaultModel)
}
for name, p := range m.Providers {
switch p.Kind {
case "openai", "anthropic", "local":
default:
bad("provider %s: unknown kind %q", name, p.Kind)
}
if p.BaseURL == "" && p.Kind != "anthropic" {
bad("provider %s: base_url is required", name)
}
if p.Kind == "openai" && p.Reasoning != "" && p.Reasoning != "subset" && p.Reasoning != "additive" && p.Reasoning != "additive_total" {
bad("provider %s: reasoning must be subset|additive|additive_total, got %q", name, p.Reasoning)
}
if p.Kind == "anthropic" && p.CacheTTL != "" && p.CacheTTL != "5m" && p.CacheTTL != "1h" {
bad("provider %s: cache_ttl must be 5m|1h, got %q", name, p.CacheTTL)
}
validateTimeouts(bad, name, p.Timeouts)
if p.Kind == "local" && p.Model == "" {
bad("provider %s: local kind requires model (its own tag)", name)
}
// The Anthropic adapter takes NO Capability at all (clients.go builds it without one), so a
// wire-shape declaration there is inert — and worse than inert: it still resolves, still
// marshals into the capability the job snapshot carries, and therefore still re-buys the book
// for a line that changes no byte on the wire. Refused by KIND, the way cache_ttl is refused
// on the kinds that cannot use it. Named for system_messages because that is the axis whose
// silent no-op would restore the exact defect it exists to close.
if p.Kind == "anthropic" && p.Capabilities != nil && p.Capabilities.SystemMessages != "" {
bad("provider %s: capabilities.system_messages is an OpenAI-compat wire shape and the anthropic adapter takes no capability — it would change nothing on the wire and still move the snapshot. Drop it (the Messages API carries system as its own blocks)", name)
}
if p.LegacyPermissive != nil {
bad("provider %s: `permissive:` is retired — declare WHICH content labels this endpoint may receive: `accepts_labels: [<label>]` (a book's content_labels must be a subset of it, D39.25/D39.26). Drop the flag", name)
}
validateLabelSet(bad, "provider "+name, p.AcceptsLabels)
validateCapabilities(bad, "provider "+name, p.Capabilities)
}
for name, mod := range m.Models {
prov, ok := m.Providers[mod.Provider]
if !ok {
bad("model %s: provider %q is not defined", name, mod.Provider)
continue
}
isLocal := prov.Kind == "local"
if !isLocal && (mod.Price.InputPerM <= 0 || mod.Price.OutputPerM <= 0) {
bad("model %s: non-local models require non-zero input/output prices", name)
}
validateCapabilities(bad, "model "+name, mod.Capabilities)
validateLabelSet(bad, "model "+name, mod.AcceptsLabels)
// A per-model set may only NARROW its provider's (fail-closed): a model row must not be able to
// grant a permission the endpoint does not have. A SILENT provider (no accepts_labels) counts as
// accepting NOTHING here — otherwise the natural data edit (one line on the model row, since the
// translator/editor choice is per model) would grant a label the endpoint's ToS forbids while
// every provider row in the catalog stays silent, which is today's state.
if mod.AcceptsLabels != nil {
var provSet []string
if prov.AcceptsLabels != nil {
provSet = *prov.AcceptsLabels
}
for _, l := range *mod.AcceptsLabels {
if !containsLabel(provSet, l) {
bad("model %s: accepts_labels contains %q, which its provider %s does not accept — a model may only NARROW the endpoint's permissions, never widen them (add the label to provider %s if that endpoint really may receive such content)", name, l, mod.Provider, mod.Provider)
}
}
}
if mod.RateLimit.MaxConcurrency < 0 || mod.RateLimit.MinIntervalMS < 0 {
bad("model %s: rate_limit.max_concurrency/min_interval_ms must be ≥0 (a concurrency cap / pacing interval)", name)
}
if why := m.echoMineViolation(name); why != "" {
bad("model %s on echo-prone provider %s: %s — this re-arms the DeepSeek echo mine "+
"(the provider echoes the untranslated CJK source when thinking is OFF; PROGRESS «Ответ Полигону», §2); "+
"an echo-prone provider MUST keep thinking at its default (reasoning.control none|mandatory, and no "+
"thinking-disable extra_body) so a silent refusal is never shipped to prod", name, mod.Provider, why)
}
}
if len(problems) > 0 {
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
}
return &m, nil
}
// Prices builds the ledger pricer from the config. Besides the model entries,
// each LOCAL provider's own backend tag is registered at $0: the local leg's
// adapter returns LLMResponse.Model = its own tag (huihui_ai/qwen3-…), and
// without this entry PriceFor would fall back to the paid default anchor — free
// local calls would be booked at the cloud price, phantom-eating the ceilings.
func (m *Models) Prices() (*ledger.Pricer, error) {
prices := make(map[string]ledger.ModelPrice, len(m.Models)+len(m.Providers))
for _, p := range m.Providers {
if p.Kind == "local" && p.Model != "" {
prices[p.Model] = ledger.ModelPrice{}
}
}
for name, mod := range m.Models {
prices[name] = mod.Price.ToLedger()
}
return ledger.NewPricer(prices, m.Models[m.DefaultModel].Price.ToLedger())
}
// AcceptsLabels returns the content labels a model is allowed to RECEIVE: its own override when it
// declares one (nil = inherit), else its provider's. An unknown model accepts NOTHING (fail closed —
// an undefined model must never look permitted). The result is read-only; callers must not mutate it.
func (m *Models) AcceptsLabels(modelName string) []string {
mod, ok := m.Models[modelName]
if !ok {
return nil
}
if mod.AcceptsLabels != nil {
return *mod.AcceptsLabels
}
if p, ok := m.Providers[mod.Provider]; ok && p.AcceptsLabels != nil {
return *p.AcceptsLabels
}
return nil
}
// MissingLabels returns the labels of `want` that modelName may NOT receive, in the order given. Empty
// result = the model accepts every requested label (the routing invariant of D39.26 point 7). This is
// the ONE comparison the whole mechanism rests on: it names no label value, so a new label is data.
func (m *Models) MissingLabels(modelName string, want []string) []string {
if len(want) == 0 {
return nil
}
accepted := m.AcceptsLabels(modelName)
var missing []string
for _, w := range want {
ok := false
for _, a := range accepted {
if a == w {
ok = true
break
}
}
if !ok {
missing = append(missing, w)
}
}
return missing
}
// ProviderOf returns a model's provider key ("" for an unknown model) — for error messages that must
// name the endpoint, not just the slug (the operator fixes a provider row, not a model row).
func (m *Models) ProviderOf(modelName string) string {
return m.Models[modelName].Provider
}
// providerReasoning returns a model's provider billing semantic ("subset" | "additive" | "";
// D3/D6.2). Unknown model → "" (no additive assumption).
func (m *Models) providerReasoning(modelName string) string {
mod, ok := m.Models[modelName]
if !ok {
return ""
}
return m.Providers[mod.Provider].Reasoning
}
// ThinksOnWire reports whether a call to modelName at the neutral effort `reasoning` leaves the
// provider's thinking ON — judged from the RESOLVED WIRE SHAPE, not from the declared effort string:
// `reasoning: "off"` suppresses thinking only where the capability carries an off-switch. With
// control=none nothing thinking-related is sent, so the PROVIDER DEFAULT stands (thinking ON for the
// reasoning families) and "off" is a no-op — the DeepSeek echo-mine semantic. control=mandatory always
// thinks (the disable is a 400).
//
// Its complement — the wire actively SUPPRESSES thinking — is the D19.1 point-2 echo zone
// ("reasoning-off + dense CJK is a property of the model CLASS"), and that is this predicate's only
// consumer (pipeline.Runner.sourceEchoExposure). The MONEY gate deliberately does not use it: see
// AdditiveReasoningTokens.
func (m *Models) ThinksOnWire(modelName, reasoning string) bool {
switch m.ResolveCapability(modelName).Reasoning.Control {
case llm.ReasoningExtraBodyDisable:
// ⚠ EMPTY is not "the provider default" for this control — it is DISABLED. Capability.applyToBody
// merges OffExtraBody for `effort == "" || effort == "off"` alike, because a disable-capability's
// whole purpose is to keep thinking off unless someone opts in. This arm used to read
// `reasoning != "off"` and so answered "it thinks" for the unset case, disagreeing with the wire it
// exists to describe — which silently exempted the shape it matters most for: a GLM-shaped model
// configured with no `reasoning:` key at all (the DEFAULT), shipping dense CJK into the echo zone
// with no warning. Must mirror applyToBody exactly.
return reasoning != "" && reasoning != "off"
case llm.ReasoningEffortField:
// Here empty DOES mean the provider default: applyToBody emits nothing, and the off-switch is sent
// only at an explicit "off" (grok then thinks at its own default `low`).
return reasoning != "off"
default: // none (off-by-omission) | mandatory — no switch reaches the wire
return true
}
}
// AdditiveReasoningTokens is the reasoning-token buffer to ADD to a reservation for a call to
// modelName (D13.6): the stage's declared reasoning_max_tokens when the provider bills reasoning
// ADDITIVELY (xAI — on top of completion), else 0 (a subset provider folds reasoning into maxTokens).
//
// It deliberately does NOT consult the effort or the wire shape any more (D39.26 добор B): the old
// `reasoning == "off"` exit reserved nothing for a model that keeps thinking at "off" because it has no
// off-switch, which is the blind ceiling D6.2/D13.6 exists to prevent. Judging the wire instead would
// re-open the same hole for the shape the добор names, so the rule is flat: an additive provider gets
// its declared buffer reserved. When the call really does suppress thinking the reservation is merely
// CONSERVATIVE — the ceiling tightens, it never goes blind — and it stays consistent with the load gate
// (LoadPipeline demands the buffer for every additive stage), so a gate and a reservation can never
// disagree about the same call. The `reasoning` parameter is kept for the call-site symmetry the runner
// reads at (model, effort, buffer) and is intentionally unused.
func (m *Models) AdditiveReasoningTokens(modelName, _ string, declaredBuffer int) int {
if declaredBuffer <= 0 {
return 0
}
if m.providerReasoning(modelName) == "additive" {
return declaredBuffer
}
return 0
}
// MinMaxTokens is the resolved per-model max_tokens FLOOR (D24.3), computed through
// the same provider→model capability layering as the wire shape (ResolveCapability),
// so what the runner floors against always matches what the snapshot folds. 0 = no
// floor (reasoning-off models GLM/grok; an unknown model). The runner applies this to
// the derived budget of EVERY call — primary attempts and the escalation hop take the
// floor of THEIR own model — before the request_hash, so the floor is wire-visible.
func (m *Models) MinMaxTokens(modelName string) int {
return m.ResolveCapability(modelName).MinMaxTokens
}
// APIKey resolves a provider's key from the environment ("" if the provider
// declares no env var — the local backend).
func (p Provider) APIKey() string {
if p.APIKeyEnv == "" {
return ""
}
return os.Getenv(p.APIKeyEnv)
}
// ResolveCapability computes the wire shape for a model (D3.1): the per-kind
// baseline, then the provider-default capabilities, then the per-model override
// (model wins, field-by-field). The runner uses the SAME method to fold the
// capability into the job snapshot, so what is hashed always matches what is
// sent — editing a capability invalidates checkpoints instead of false-hitting.
func (m *Models) ResolveCapability(modelName string) llm.Capability {
mod := m.Models[modelName]
prov := m.Providers[mod.Provider]
resolved := baselineCapability(prov.Kind)
applyCapConfig(&resolved, prov.Capabilities)
applyCapConfig(&resolved, mod.Capabilities)
return resolved
}
// AttemptDeadline is how long ONE call to this model may run: the provider's retry profile applied to
// the call's own output budget (llm.RetryProfile.DeadlineFor). It resolves model→provider→timeouts the
// same way ResolveCapability and MinMaxTokens resolve their fields, so a caller that needs to SAY how
// long a call may wait reads the same answer the transport will act on rather than assembling one.
//
// An unknown model yields the zero profile, whose derivation still returns the vendor default — the
// same direction every fallback on this path takes: too long, never too short.
func (m *Models) AttemptDeadline(modelName string, maxTokens int) time.Duration {
return m.Providers[m.Models[modelName].Provider].Timeouts.Profile().DeadlineFor(maxTokens)
}
// thinkingControlExtraKeys are keys whose purpose is to toggle a provider's thinking
// on the wire — top-level (GLM/DeepSeek {"thinking":…}, Qwen {"enable_thinking":…}, a
// raw {"reasoning_effort":…}) OR nested (DeepSeek-V3.1+ disables via
// chat_template_kwargs.thinking:false). On an echo-prone provider their mere PRESENCE,
// at any depth, is the mine, whatever the value.
//
// ⚠ What is banned is the RAW WIRE channel, not the subject: since D39.87 a stage or a
// gate MAY set the effort level (`reasoning: "low"`) on such a provider, and on a
// ReasoningNone capability that emits the very same reasoning_effort key legally. The
// difference is not the byte on the wire, it is who decided it: the config path goes
// through Capability.applyToBody, which knows what "off" means for this control and
// keeps a disable from ever being emitted here, while extra_body is merged verbatim and
// can therefore SUPPRESS thinking — which is the mine. So: the effort level is the
// owner's to choose; the on/off switch is not, and this map is what keeps the second
// one out of the first one's clothing.
var thinkingControlExtraKeys = map[string]bool{
"thinking": true, "enable_thinking": true, "reasoning_effort": true, "reasoning": true,
}
// findThinkingControlKey RECURSIVELY scans an extra_body value for any thinking-control
// key, returning its dotted path (e.g. "chat_template_kwargs.thinking") or "". The
// recursion is what catches nested disable forms a flat top-level check would miss
// (external-review finding — chat_template_kwargs.thinking is DeepSeek's documented
// V3.1+ disable, the most likely real arming form).
func findThinkingControlKey(v any, path string) string {
m, ok := v.(map[string]any)
if !ok {
return ""
}
for k, sub := range m {
here := k
if path != "" {
here = path + "." + k
}
if thinkingControlExtraKeys[k] {
return here
}
if found := findThinkingControlKey(sub, here); found != "" {
return found
}
}
return ""
}
// echoMineViolation reports, for one model, whether its RESOLVED wire shape would
// suppress thinking at reasoning=off on an echo-prone provider — the DeepSeek echo
// mine (§2). It returns a human cause string, or "" when safe. It covers both injection
// surfaces: the capabilities.reasoning switch (extra_body_disable merges a
// thinking-disable at off; effort sends reasoning_effort at off — both push thinking
// off the default) AND a thinking-control key anywhere in the model's extra_body,
// TOP-LEVEL OR NESTED (merged into the wire body by openAIRequest). A provider without the flag is never
// gated, so GLM's {thinking:{type:disabled}} stays legal — NOT because its editor input is CJK-free (that
// rationale is stale since the editor became bilingual, D30.1) but because the flag is a per-provider
// datum nobody has extended; the exposure surfaces as a load warning instead (see the field's doc).
func (m *Models) echoMineViolation(name string) string {
mod := m.Models[name]
prov, ok := m.Providers[mod.Provider]
if !ok || !prov.EchoesWhenThinkingOff {
return ""
}
switch m.ResolveCapability(name).Reasoning.Control {
case llm.ReasoningExtraBodyDisable:
return "capabilities.reasoning.control=extra_body_disable would merge a thinking-disable at reasoning=off"
case llm.ReasoningEffortField:
return "capabilities.reasoning.control=effort would send reasoning_effort at reasoning=off, suppressing thinking"
}
if path := findThinkingControlKey(map[string]any(mod.ExtraBody), ""); path != "" {
return fmt.Sprintf("extra_body carries the thinking-control key %q", path)
}
return ""
}
// baselineCapability is the per-kind default before any capabilities block: the
// OpenAI-compat wire (max_tokens + send temperature), with reasoning off by
// omission for cloud and by the ollama "none" switch for local.
func baselineCapability(kind string) llm.Capability {
c := llm.Capability{Budget: llm.BudgetMaxTokens, Temp: llm.TempSend}
if kind == "local" {
c.Reasoning = llm.ReasoningCap{Control: llm.ReasoningEffortField, OffEffort: "none"}
} else {
c.Reasoning = llm.ReasoningCap{Control: llm.ReasoningNone}
}
return c
}
// applyCapConfig layers a capabilities block over a resolved capability. Each
// top-level field (budget / temperature / reasoning) inherits when the block
// leaves it empty, so a model overrides only what it declares.
func applyCapConfig(c *llm.Capability, cfg *CapabilitiesConfig) {
if cfg == nil {
return
}
if cfg.BudgetField != "" {
c.Budget = llm.BudgetField(cfg.BudgetField)
}
if cfg.Temperature != nil {
c.Temp = llm.TempMode(cfg.Temperature.Mode)
c.TempValue = cfg.Temperature.Value
}
if cfg.Reasoning != nil {
c.Reasoning = llm.ReasoningCap{
Control: llm.ReasoningControl(cfg.Reasoning.Control),
OffEffort: cfg.Reasoning.OffEffort,
OffExtraBody: cfg.Reasoning.OffExtraBody,
OnExtraBody: cfg.Reasoning.OnExtraBody,
}
}
if cfg.MinMaxTokens > 0 { // 0 = unset → inherit the provider/kind floor (model wins when it declares one)
c.MinMaxTokens = cfg.MinMaxTokens
}
// "" = unset → inherit (the multi baseline, or the provider's declaration). An explicit
// "multi" NORMALISES to the zero value: it is how a model says "not this provider's single-
// slot rule" and how an author records a verified endpoint, and it must cost nothing —
// resolving it to a distinct string would put a key in the snapshot and re-buy the book for
// a line that changed no byte on the wire.
switch cfg.SystemMessages {
case "":
case "multi":
c.SystemMessages = llm.SystemMessagesMulti
default:
c.SystemMessages = llm.SystemMessagesMode(cfg.SystemMessages)
}
}
// canonicalLabel reports whether a label is written the way LoadBook normalises a book's labels
// (trimmed, lower-case). Every other place a label is AUTHORED — a policy entry, a label_models key, an
// accepts_labels entry — is checked against this instead of being normalised silently: normalising in
// four places invites one of them drifting, while rejecting says which spelling is meant.
func canonicalLabel(l string) bool {
return l != "" && l == strings.ToLower(strings.TrimSpace(l))
}
// sortedLabelKeys returns a label-keyed map's keys in a deterministic order, so a validation message
// built from a map cannot differ between two identical loads.
func sortedLabelKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// containsLabel is set membership over a label list (the lists are a handful of entries — least
// mechanism beats a map here, and the comparison stays order-independent).
func containsLabel(set []string, want string) bool {
for _, s := range set {
if s == want {
return true
}
}
return false
}
// validateLabelSet fail-fasts an accepts_labels block: an empty STRING is a typo (the empty LIST is
// how "accepts none" is written), and a duplicate is a config smell worth naming rather than absorbing
// silently. Labels are compared verbatim, so leading/trailing space would make two spellings of one
// permission — rejected rather than trimmed, because a permission must not be guessed at.
// maxAttemptSeconds bounds every deadline knob a provider may set. It is a TYPO GUARD, not a policy: the
// owner ratified waiting up to about twenty minutes for one call, and this sits an order of magnitude
// above that so a legitimate configuration is never refused. What it refuses is a slipped digit —
// `queue_slack_s: 6000` reads as an hour and forty minutes of waiting nobody chose, and the only place
// that shows up is a run that looks hung.
const maxAttemptSeconds = 4 * 60 * 60
// validateTimeouts checks the deadline knobs AGAINST EACH OTHER, which is the part no per-field check
// can do. The derivation clamps the derived deadline up to `attempt_s` and then down to `attempt_max_s`,
// so a cap below the floor wins — and the floor's own doccomment, which calls it a FLOOR, quietly stops
// being true for every call that provider makes. That is a config a person can write in one keystroke
// and cannot see afterwards: nothing logs it, and the calls simply get less time than the file says.
func validateTimeouts(bad func(string, ...any), name string, t Timeouts) {
if t.AttemptS < 0 || t.AttemptS > maxAttemptSeconds {
bad("provider %s: attempt_s %d is out of range (0..%d seconds)", name, t.AttemptS, maxAttemptSeconds)
}
if t.QueueSlackS < 0 || t.QueueSlackS > maxAttemptSeconds {
bad("provider %s: queue_slack_s %d is out of range (0..%d seconds)", name, t.QueueSlackS, maxAttemptSeconds)
}
if t.AttemptMaxS < 0 || t.AttemptMaxS > maxAttemptSeconds {
bad("provider %s: attempt_max_s %d is out of range (0..%d seconds)", name, t.AttemptMaxS, maxAttemptSeconds)
}
if t.TokSFloor < 0 {
bad("provider %s: tok_s_floor %g is negative — a speed floor below zero grants a call infinite time", name, t.TokSFloor)
}
if t.AttemptMaxS > 0 && t.AttemptMaxS < t.AttemptS {
bad("provider %s: attempt_max_s %d is BELOW attempt_s %d — the cap would win and every call would "+
"get less time than the declared floor, silently", name, t.AttemptMaxS, t.AttemptS)
}
}
func validateLabelSet(bad func(string, ...any), where string, set *[]string) {
if set == nil {
return
}
seen := map[string]bool{}
for i, l := range *set {
switch {
case l == "":
bad("%s: accepts_labels[%d] is empty — write `accepts_labels: []` to accept none", where, i)
case !canonicalLabel(l):
bad("%s: accepts_labels[%d] %q must be written lower-case and without surrounding whitespace — a book's content_labels are normalised on load, so any other spelling could never match (fail-closed, but silently)", where, i, l)
case seen[l]:
bad("%s: accepts_labels[%d] %q is a duplicate", where, i, l)
}
seen[l] = true
}
}
// validateCapabilities fail-fasts a capabilities block's enum fields (part of
// the models.yaml problem list, so a typo is caught at load, not as a 4xx).
func validateCapabilities(bad func(string, ...any), where string, c *CapabilitiesConfig) {
if c == nil {
return
}
if c.MinMaxTokens < 0 {
bad("%s: capabilities.min_max_tokens must be ≥0 (a token floor), got %d", where, c.MinMaxTokens)
}
switch c.BudgetField {
case "", "max_tokens", "max_completion_tokens":
default:
bad("%s: capabilities.budget_field must be max_tokens|max_completion_tokens, got %q", where, c.BudgetField)
}
switch c.SystemMessages {
case "", "multi", "single":
default:
bad("%s: capabilities.system_messages must be multi|single, got %q", where, c.SystemMessages)
}
if c.Temperature != nil {
switch c.Temperature.Mode {
case "send", "omit", "force":
default:
bad("%s: capabilities.temperature.mode must be send|omit|force, got %q", where, c.Temperature.Mode)
}
}
if c.Reasoning != nil {
switch c.Reasoning.Control {
case "none", "effort", "extra_body_disable", "mandatory":
default:
bad("%s: capabilities.reasoning.control must be none|effort|extra_body_disable|mandatory, got %q", where, c.Reasoning.Control)
}
// The control must carry the companion field it consumes, else the
// disable silently no-ops at runtime (a typo'd off_extra_body key is
// dropped by non-strict yaml → nil → thinking stays ON). Catch it at
// load, not as a paid-but-empty completion.
if c.Reasoning.Control == "extra_body_disable" && len(c.Reasoning.OffExtraBody) == 0 {
bad("%s: capabilities.reasoning.control=extra_body_disable requires a non-empty off_extra_body (the disable switch)", where)
}
if c.Reasoning.Control == "effort" && c.Reasoning.OffEffort == "" {
bad("%s: capabilities.reasoning.control=effort requires off_effort (the value sent when reasoning=off)", where)
}
}
}
// CheckKeys verifies that every non-local model the pipeline can ACTUALLY call
// has its provider API key resolvable in the environment — preflight fail-fast,
// so an empty key does not surface as a 401 only AFTER reserve / slot charge.
//
// Only reachable models are checked, not the entire Р4 stack:
// - STAGE models — always (called on every chunk);
// - ESCALATION-chain models — ONLY if at least one gate is enabled:
// escalation is triggered solely by a gate failure, so when
// gates.*.enabled=false (Phase 0) it is unreachable and its keys are not needed.
// Without this condition, translating a single chunk (DeepSeek+GLM) would also
// require Anthropic/Kimi keys from the default chains, which are not called in Phase 0.
//
// a local-only run requires no cloud keys.
func (m *Models) CheckKeys(pipe *Pipeline) error {
needed := map[string]struct{}{}
// A LABELLED book's reachable set is label-dependent: its resolved stage models and single hops (plus
// the repair model when that gate is on) — a configured escalate_to it replaced, and a chain no policy
// references, are unreachable for it, so demanding THEIR keys would block a valid labelled run
// (D39.26 point 2). An unlabelled book keeps the historical set exactly: stage models, and — once an
// escalation budget exists — every named chain plus the per-stage escalate_to.
if len(pipe.ContentLabels) > 0 {
for _, mdl := range pipe.ReachableModels() {
needed[mdl] = struct{}{}
}
return m.checkKeysFor(needed)
}
for _, st := range pipe.Stages {
needed[st.Model] = struct{}{}
}
// ⚠ AND THE GATES, which this branch did not ask about for two rounds. A gate that calls a model reaches
// a provider exactly like a stage does; the labelled branch above gets them through ReachableModels(),
// and this one — the branch an UNLABELLED book takes, i.e. the shipping default — collected stage models
// by hand and stopped. A terminologist, classifier or repair model on a provider with no key therefore
// passed the preflight, and the run discovered it after the draft wave was bought.
for _, mdl := range pipe.gateModels() {
needed[mdl] = struct{}{}
}
// Escalation models — named chains (step 7) and single-hop fallbacks (Milestone 2.5) —
// are reachable ONLY once an escalation budget is set (opt-in), NOT when a gate is
// enabled: the coverage gate is a deterministic runner-side check that calls no
// model, so enabling it must not demand escalation keys (self-review finding —
// otherwise turning on the gate blocks a valid deepseek+glm run on a missing Kimi
// key from an unused chain stub).
if pipe.Escal.BudgetUSD > 0 {
for _, chain := range pipe.Escal.Chains {
for _, mdl := range chain {
needed[mdl] = struct{}{}
}
}
for _, st := range pipe.Stages {
if st.EscalateTo != "" {
needed[st.EscalateTo] = struct{}{}
}
}
}
return m.checkKeysFor(needed)
}
// checkKeysFor is the env-resolution half of CheckKeys, shared by the labelled and unlabelled reachable
// sets so the "which key is missing" message can never drift between them.
func (m *Models) checkKeysFor(needed map[string]struct{}) error {
var problems []string
seen := map[string]bool{} // dedupe per provider
for name := range needed {
mod, ok := m.Models[name]
if !ok {
continue // existence already checked by LoadPipeline
}
prov := m.Providers[mod.Provider]
if prov.Kind == "local" || prov.APIKeyEnv == "" || seen[mod.Provider] {
continue
}
seen[mod.Provider] = true
if prov.APIKey() == "" {
problems = append(problems, fmt.Sprintf("provider %s: env %s is not set (needed for model %s)", mod.Provider, prov.APIKeyEnv, name))
}
}
if len(problems) > 0 {
return fmt.Errorf("missing API keys (fill in backend/.env):\n - %s", strings.Join(problems, "\n - "))
}
return nil
}