// 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 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) } // 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: [