textmachine/backend/internal/config/models.go

531 lines
23 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 (
"fmt"
"os"
"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. It distinguishes the SAFE GLM editor, whose
// thinking:disabled is fine because its input is a Russian draft (no CJK to
// echo): GLM's provider carries no such flag. Validation-only metadata — it
// never reaches the wire, so it is NOT part of the snapshot (no --resnapshot).
EchoesWhenThinkingOff bool `yaml:"echoes_when_thinking_off"`
// Permissive marks a provider allowed to serve the 18+ channel B (the most
// permissive large APIs: xAI/Grok, DeepSeek official, local abliterated — D3).
// It is the TYPE that enforces channel-B isolation (D4.1): a channel=adult stage
// may only run on, and escalate to, a permissive provider — a structural gate,
// not a comment, so an SFW-only provider can never silently serve/answer a
// permissive chunk. Validation-only metadata (never on the wire, not in snapshot).
Permissive 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"`
// 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
if err := yaml.Unmarshal(raw, &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)
}
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)
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())
}
// providerPermissive reports whether a model's provider is marked permissive
// (channel-B-eligible, D4.1). Unknown model → false (fail closed: an undefined
// model is never treated as permissive).
func (m *Models) providerPermissive(modelName string) bool {
mod, ok := m.Models[modelName]
if !ok {
return false
}
return m.Providers[mod.Provider].Permissive
}
// 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
}
// AdditiveReasoningTokens is the reasoning-token buffer to ADD to a reservation for a call to
// modelName at effort `reasoning` (D13.6): the stage's declared reasoning_max_tokens when the
// provider bills reasoning ADDITIVELY (xAI — on top of completion) AND the effort MAY think,
// else 0 (a subset provider folds reasoning into maxTokens). "May think" = any effort except an
// explicit "off": low|medium|high think, and an OMITTED/"" effort falls to the provider default
// (xAI-grok = thinking ON) — so "" must reserve the buffer too (self-review finding #4). The
// runner passes this to ledger.EstimateUSD so the reservation covers the overshoot instead of
// blinding the ceiling. LoadPipeline fail-fasts if the buffer is missing for an additive
// non-"off" stage, so a >0 declaredBuffer is guaranteed here for that case.
func (m *Models) AdditiveReasoningTokens(modelName, reasoning string, declaredBuffer int) int {
if declaredBuffer <= 0 || reasoning == "off" {
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: thinking must stay at the provider
// default, and these keys exist only to move it off that default.
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 non-echo-prone
// provider (GLM: its editor input is a Russian draft, nothing CJK to echo) is never
// gated, so its {thinking:{type:disabled}} stays legal.
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
}
}
// 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{}{}
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{}{}
}
}
}
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
}