354 lines
15 KiB
Go
354 lines
15 KiB
Go
// Package config loads and fail-fast-validates the three YAML files of a run:
|
||
// models.yaml (провайдеры/модели/цены/таймауты — Р4/Р5: цены только в конфиге
|
||
// с датой проверки), pipeline-*.yaml (ядро C1/C2 — граница «конфиг vs код» по
|
||
// Р2) and book.yaml (translation brief + brief_hash).
|
||
//
|
||
// Дисциплина донора: все проблемы конфига собираются СПИСКОМ и валят старт
|
||
// одной ошибкой — оператор чинит всё сразу, а не по одной кочке за запуск.
|
||
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 дней) config:
|
||
// волатильность цен API — риск Р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 (openai kind only)
|
||
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): override запроса — ollama чтит request-
|
||
// температуру поверх Modelfile, и температура облачной роли молча
|
||
// расстроила бы локальную модель. 0 = наследовать запрос.
|
||
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"`
|
||
}
|
||
|
||
// 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 (профиль per-провайдер из
|
||
// вердикта валидации; per-role overrides — Фаза 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 (провайдер-
|
||
// специфичные ручки: GLM thinking.type, Qwen enable_thinking …).
|
||
ExtraBody map[string]any `yaml:"extra_body"`
|
||
// MinCachePrefixTokens: Anthropic-модели — минимальный кэшируемый префикс
|
||
// (короче — кэш молча не создаётся); сборщик контекста Фазы 1 сверяется.
|
||
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"`
|
||
}
|
||
|
||
// 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 (ежеквартальный пересмотр Р4 + буфер).
|
||
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 (цены без даты проверки запрещены Р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 (fallback-якорь цены: неизвестная модель никогда не стоит $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" {
|
||
bad("provider %s: reasoning must be subset|additive, 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 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: адаптер локальной
|
||
// ноги отдаёт LLMResponse.Model = свой тег (huihui_ai/qwen3-…), и без этой
|
||
// записи PriceFor свалился бы на платный default-якорь — бесплатные локальные
|
||
// вызовы книжились бы по облачной цене, фантомно съедая потолки.
|
||
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())
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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,
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
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,
|
||
// чтобы пустой ключ не всплывал 401-м уже ПОСЛЕ reserve/списания слота.
|
||
//
|
||
// Проверяются только достижимые модели, не весь Р4-стек:
|
||
// - модели СТАДИЙ — всегда (зовутся на каждом чанке);
|
||
// - модели ЭСКАЛАЦИОННЫХ цепочек — ТОЛЬКО если включён хоть один гейт:
|
||
// эскалация запускается исключительно по провалу гейта, поэтому при
|
||
// gates.*.enabled=false (Фаза 0) она недостижима и её ключи не нужны.
|
||
// Без этого условия перевод одного чанка (DeepSeek+GLM) требовал бы ещё
|
||
// ключи Anthropic/Kimi из дефолтных цепочек, которые в Фазе 0 не зовутся.
|
||
//
|
||
// local-only прогон облачных ключей не требует.
|
||
func (m *Models) CheckKeys(pipe *Pipeline) error {
|
||
needed := map[string]struct{}{}
|
||
for _, st := range pipe.Stages {
|
||
needed[st.Model] = struct{}{}
|
||
}
|
||
if pipe.gatesEnabled() {
|
||
for _, chain := range pipe.Escal.Chains {
|
||
for _, mdl := range chain {
|
||
needed[mdl] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
var problems []string
|
||
seen := map[string]bool{} // dedupe per provider
|
||
for name := range needed {
|
||
mod, ok := m.Models[name]
|
||
if !ok {
|
||
continue // существование уже проверено 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 не задан (нужен для модели %s)", mod.Provider, prov.APIKeyEnv, name))
|
||
}
|
||
}
|
||
if len(problems) > 0 {
|
||
return fmt.Errorf("отсутствуют API-ключи (заполните backend/.env):\n - %s", strings.Join(problems, "\n - "))
|
||
}
|
||
return nil
|
||
}
|