// Package llm is the provider-neutral seam between the pipeline and concrete // model backends (порт vojo/apps/ai-bot llm.go с расширениями Фазы 0 — см. // docs/architecture/03-implementation-notes.md §3.5). Nothing here names a // vendor: the runner composes context, the ledger prices usage, and thin // adapters (provider_openai.go, provider_anthropic.go, provider_local.go) map // these types to/from each backend's wire format. package llm import "context" // Message is one provider-neutral chat turn. type Message struct { Role string // "system" | "user" | "assistant" Content string // CacheBoundary marks THIS message as the end of a stable prompt prefix // (раскладка Р5: стабильный префикс = system+brief+style sheet+резюме, // волатильный хвост = глоссарий+STM+чанк). The Anthropic adapter attaches // cache_control to the corresponding block (max 4 boundaries per request — // API limit); OpenAI-compatible providers cache by prefix automatically and // ignore the flag. The context assembler decides the layout; adapters never // guess where the boundary is. CacheBoundary bool } // Usage is the provider-neutral token accounting returned with a completion. // It drives billing (ledger.CostUSD) — the counts are the API's own, // authoritative even if our price constants drift. // // Invariant: PromptTokens is the TOTAL input (cached + cache-written + // uncached). Anthropic reports the three parts separately (input_tokens is // only the uncached remainder), so its adapter SUMS them into PromptTokens; // OpenAI-compatible providers already report the total in prompt_tokens. type Usage struct { PromptTokens int // CachedTokens is the subset of PromptTokens served from the provider's // prompt cache (cache READ — billed at the discounted cache-hit rate). CachedTokens int // CacheCreationTokens is the subset of PromptTokens WRITTEN to the cache // this call. Anthropic bills writes at a premium (×1.25 for 5m TTL, ×2 for // 1h — проверено 2026-07-04); dropping this field would undercount spend, // the same failure class as the reasoning-token undercount already paid // for in vojo (30–44%). Providers without explicit cache writes leave 0. CacheCreationTokens int CompletionTokens int // ReasoningTokens are thinking tokens billed at the output rate but NOT // included in CompletionTokens. Semantics are per-adapter: xAI reports // them additively (verified in vojo against cost_in_usd_ticks); the OpenAI // spec and ollama count them INSIDE completion_tokens, so those adapters // must leave 0 to avoid double-billing; Anthropic bills thinking inside // output_tokens, so its adapter also leaves 0. ReasoningTokens int } // Neutral finish reasons. The coverage gate branches on these: a length cut // needs a retry with a bigger max_tokens, NOT an escalation (реальный кейс // Gemini из эксперимента 02 — thinking съел бюджет, sent_cov упал до 0.11). const ( FinishStop = "stop" // natural end of turn FinishLength = "length" // max_tokens exhausted FinishContentFilter = "content_filter" // provider-side filter cut the output FinishRefusal = "refusal" // model/classifier refused (Anthropic stop_reason=refusal) ) // LLMRequest is a provider-neutral completion request. // // Deliberately absent (vs vojo): Tools (tool-calling вне скоупа MVP по Р2) and // ConvID (xAI-специфичный кэш-хинт; вернём с xAI-адаптером, если понадобится). type LLMRequest struct { Model string Messages []Message MaxTokens int // Temperature: how (and whether) it reaches the wire is decided per-model // by the Capability resolver (capability.go: send incl. explicit 0 | omit | // force) — the adapter no longer gates on > 0. Temperature float64 // ReasoningEffort: "" = provider default, "off" = explicitly disable // thinking, "low"|"medium"|"high" = enable at that depth. Each adapter maps // this to its wire form; providers with non-standard switches (GLM // thinking.type, Qwen enable_thinking) are handled via per-model ExtraBody // in models.yaml instead (эмпирика полигона: thinking у GLM — таймауты ×3). ReasoningEffort string // JSONOnly asks the backend to constrain output to a single valid JSON // object (OpenAI-compat response_format json_object). Used by judge/gate // roles; false serializes away. JSONOnly bool } // LLMResponse is a provider-neutral completion result. type LLMResponse struct { Text string Usage Usage // Model is the model that ACTUALLY served this completion. It can differ // from LLMRequest.Model when a routing decorator (failover) answered with a // different backend — billing and telemetry must follow the responder, or a // free local answer books at cloud prices. "" = unknown; consumers fall // back to the requested model. Model string // FinishReason is one of the Finish* constants ("" = unknown/legacy). FinishReason string ProviderRequestID string // the backend's response id, logged for support/debug } // LLMClient is any chat-completion backend. Implementations are thin adapters // over a wire protocol; the pipeline depends only on this interface, so // clients can be swapped or decorated (failover) without touching business // logic. Streaming is deliberately NOT in the interface yet ([НУЖНО РЕШЕНИЕ] // п.4 в PROGRESS): when it lands it will be a second, optional interface // (StreamingLLMClient) so existing adapters keep compiling. type LLMClient interface { Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) }