textmachine/backend/internal/llm/llm.go

111 lines
5.7 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 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 (3044%). 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)
}