728 lines
34 KiB
Go
728 lines
34 KiB
Go
// 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"`
|
||
}
|
||
|
||
// 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 int `yaml:"attempt_s"`
|
||
MaxAttempts int `yaml:"max_attempts"`
|
||
BackoffCapS int `yaml:"backoff_cap_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,
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
if p.Kind == "local" && p.Model == "" {
|
||
bad("provider %s: local kind requires model (its own tag)", 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
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)
|
||
}
|
||
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{}{}
|
||
}
|
||
// 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
|
||
}
|