textmachine/backend/internal/config/models.go

224 lines
9.1 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 (провайдеры/модели/цены/таймауты — Р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)
}
// CheckKeys verifies that every non-local model REFERENCED by the pipeline has
// its provider API key resolvable in the environment — preflight fail-fast,
// чтобы пустой ключ не всплывал 401-м уже ПОСЛЕ reserve/списания слота
// (находка ревью). Проверяются только используемые модели: local-only прогон
// не должен падать на незаполненных облачных ключах. Escalation-цепочки тоже
// учитываются — их модели вызываются в Фазе 1.
func (m *Models) CheckKeys(pipe *Pipeline) error {
needed := map[string]struct{}{}
for _, st := range pipe.Stages {
needed[st.Model] = struct{}{}
}
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
}