// Package llm — провайдер LLM за интерфейсом (дискриминатор type). // // Реализация выбирается полем [llm].type (см. openspec/specs/recognition/spec.md). // Первый и пока единственный тип — "openai-compat": OpenAI-совместимый Chat // Completions API (локальные серверы LM Studio/llama.cpp/Ollama и облачные // совместимые провайдеры — DeepSeek, Qwen и др.). // // Пакет отвечает за один вызов модели и транспортную устойчивость (ретраи // на сетевые сбои, 429 и 5xx). Бюджет переразбора ответа со схемой-в-промпте // (llm.max_retries) принадлежит вызывающему recognize, а не провайдеру. package llm import ( "context" "errors" "fmt" "log/slog" "time" ) // Role — роль сообщения в диалоге. type Role string const ( RoleSystem Role = "system" RoleUser Role = "user" RoleAssistant Role = "assistant" ) // Message — одно сообщение запроса. type Message struct { Role Role Content string } // Request — запрос к модели. type Request struct { Messages []Message JSONMode bool // response_format: json_object (структурированный вывод) Temperature *float64 // nil — не передаём, у модели остаётся её дефолт MaxTokens int // 0 — не передаём } // Usage — расход токенов и стоимость (если провайдер их сообщает). type Usage struct { PromptTokens int CompletionTokens int TotalTokens int Cost float64 } // Response — ответ модели. type Response struct { Content string Model string Usage Usage // FinishReason — причина завершения генерации, как её назвал провайдер // ("stop", "length", …). Пусто — провайдер её не сообщил. Нужна // вызывающему: обрыв по длине неотличим от мусора по одному лишь телу // ответа, а повторять такой запрос бессмысленно (см. recognize). FinishReason string } // FinishLength — значение FinishReason, которым OpenAI-совместимые провайдеры // сообщают обрыв генерации по достижении предела токенов. const FinishLength = "length" // Provider — абстракция доступа к LLM. recognize работает только с ним и не // знает про конкретный транспорт. type Provider interface { Complete(ctx context.Context, req Request) (Response, error) } // Config — параметры провайдера (подмножество [llm] из конфига). type Config struct { Type string BaseURL string APIKey string Model string Proxy string Timeout time.Duration } // ErrUnknownType — запрошенный [llm].type не поддерживается. var ErrUnknownType = errors.New("llm: unknown provider type") // ErrRequestTooLarge — провайдер отказал из-за размера самого запроса // (переполнение контекста), а не из-за его содержимого. Вызывающему это // отдельная ветвь: такой отказ не лечится повтором, а называется человеку // своей причиной и крутится настройкой ([recognition].max_files). // // Признак ставит транспорт — там, где ещё целы HTTP-статус и полное тело // ответа (см. openai.go). Вызывающий проверяет его errors.Is: у итоговой // ошибки текст обрезан и в него попадает эхо запроса, так что матчить по нему // нельзя (docs/conventions/errors.md). var ErrRequestTooLarge = errors.New("llm: request too large") // New собирает провайдер по дискриминатору cfg.Type. logger nil → slog.Default(). func New(cfg Config, logger *slog.Logger) (Provider, error) { if logger == nil { logger = slog.Default() } switch cfg.Type { case "openai-compat": return newOpenAICompat(cfg, logger) case "": return nil, fmt.Errorf("%w: %q (set [llm].type)", ErrUnknownType, cfg.Type) default: return nil, fmt.Errorf("%w: %q", ErrUnknownType, cfg.Type) } }