// 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"` } // 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"` } // 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) } } 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) } } 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) }