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 } // 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 } // NewLocalClient builds the local-backend adapter. func NewLocalClient(cfg LocalConfig, logger *slog.Logger) LLMClient { return &localClient{ http: newOpenAIClient("local", cfg.BaseURL, cfg.APIKey, cfg.Profile, nil, NoProxyClient(), logger), model: cfg.Model, temp: cfg.Temperature, maxTok: cfg.MaxTokens, } } func (c *localClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) { msgs := make([]openAIMessage, len(req.Messages)) for i, m := range req.Messages { msgs[i] = openAIMessage{Role: m.Role, Content: m.Content} } maxTok := req.MaxTokens if c.maxTok > 0 { maxTok = c.maxTok } temp := req.Temperature if c.temp > 0 { temp = c.temp } var respFormat any if req.JSONOnly { respFormat = map[string]string{"type": "json_object"} } effort := req.ReasoningEffort if effort == "off" { // ollama's /v1 maps reasoning_effort onto Qwen3 thinking; "none" is its // explicit off-switch (verified in vojo). effort = "none" } resp, err := c.http.complete(ctx, openAIRequest{ Model: c.model, Messages: msgs, MaxTokens: maxTok, Temperature: temp, Stream: false, ReasoningEffort: effort, ResponseFormat: respFormat, }) 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 }