package llm import ( "context" "log/slog" "net/http" ) // provider_local.go is the adapter for a self-hosted OpenAI-compatible // inference backend (ollama / llama-server on the owner's GTX 1070). Порт // vojo provider_local.go; главное отличие — явный no-proxy транспорт. // // Прокси-грабли стенда (memory textmachine-local-stand + валидация): в env // прописан webshare-прокси, NO_PROXY= — виндовая нотация, которую Go // не понимает. Go stdlib сам НЕ проксирует loopback (127.0.0.1/localhost), но // llama-server на стенде слушает WSL-gateway-адреса вида 172.18.0.1 — такой // запрос ушёл бы на прокси и получил 403, нога failover навсегда осталась бы // unhealthy, молча оплачивая облако. Поэтому транспорт локального провайдера // (и его health-пробы в failover.go) строится с Proxy: nil безусловно. // LocalConfig configures the local backend adapter. type LocalConfig struct { BaseURL string // e.g. http://127.0.0.1:11434/v1 or http://172.18.0.1:11434/v1 APIKey string // usually empty — no Authorization header then // Model is the adapter's own tag (e.g. huihui_ai/qwen3-abliterated:8b): // the pipeline asks for role-model names that don't exist locally. The // response's Model reports what actually served, so billing prices it at // the local $0 entry. Model string // Temperature overrides the request's (ollama honours a request temperature // over the Modelfile; passing the cloud role's value through would mistune // the local model). 0 = keep the request's. Temperature float64 // MaxTokens overrides the request's: ollama's cap covers thinking AND // answer together (unlike xAI, where thinking bills on top), so a // cloud-sized budget lets a thinking model burn it all on reasoning and // return empty content. 0 = inherit the request's. MaxTokens int Profile RetryProfile // Cap is the resolved wire shape (capability.go). If its reasoning control // is unset, NewLocalClient defaults it to the ollama off-switch (map "off" // onto reasoning_effort:"none"). Cap Capability } // NoProxyClient is an http.Client that bypasses any environment proxy. // Exported so the failover prober uses the same transport discipline. func NoProxyClient() *http.Client { return &http.Client{Transport: &http.Transport{Proxy: nil}} } type localClient struct { http *openAIClient model string temp float64 maxTok int cap Capability } // NewLocalClient builds the local-backend adapter. func NewLocalClient(cfg LocalConfig, logger *slog.Logger) LLMClient { cap := cfg.Cap if cap.Reasoning.Control == "" { // ollama's /v1 maps reasoning_effort onto Qwen3 thinking; "none" is its // explicit off-switch (verified in vojo). Default it here so a zero-Cap // caller keeps the off→"none" behavior; BuildClient passes the same via // the resolver, so the snapshot's stageSnap capability matches the wire. cap.Reasoning = ReasoningCap{Control: ReasoningEffortField, OffEffort: "none"} } return &localClient{ http: newOpenAIClient("local", cfg.BaseURL, cfg.APIKey, cfg.Profile, nil, NoProxyClient(), logger), model: cfg.Model, temp: cfg.Temperature, maxTok: cfg.MaxTokens, cap: cap, } } func (c *localClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) { maxTok := req.MaxTokens if c.maxTok > 0 { maxTok = c.maxTok } temp := req.Temperature if c.temp > 0 { temp = c.temp } resp, err := c.http.complete(ctx, openAIRequest{ model: c.model, messages: toOpenAIMessages(req.Messages), maxTokens: maxTok, temperature: temp, stream: false, reasoningEffort: req.ReasoningEffort, // neutral; cap maps off→"none" for ollama responseFormat: jsonResponseFormat(req.JSONOnly), cap: c.cap, }) if err != nil { return nil, err } return &LLMResponse{ Text: resp.Text(), Usage: Usage{ PromptTokens: resp.Usage.PromptTokens, CachedTokens: resp.Usage.cacheRead(), CompletionTokens: resp.Usage.CompletionTokens, // ReasoningTokens deliberately 0: ollama counts thinking INSIDE // completion_tokens (subset semantics). It's all $0 regardless. }, Model: c.model, // the local model answered, whatever the pipeline asked for FinishReason: resp.FinishReason(), ProviderRequestID: resp.ID, }, nil }