textmachine/backend/internal/llm/provider_local.go

120 lines
4.5 KiB
Go

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). A port of
// vojo's provider_local.go; the main difference is an explicit no-proxy transport.
//
// Stand proxy pitfall (memory textmachine-local-stand + validation): the env
// has a webshare proxy configured, NO_PROXY=<local> — Windows notation that Go
// does not understand. Go's stdlib itself does NOT proxy loopback (127.0.0.1/localhost),
// but llama-server on the stand listens on WSL-gateway addresses like 172.18.0.1 —
// such a request would go to the proxy and get a 403, the failover leg would stay
// unhealthy forever, silently paying for the cloud. So the local provider's transport
// (and its health probes in failover.go) is built with Proxy: nil unconditionally.
// 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
}
// Same resolution as the cloud OpenAI-compat path: the local stand rides the identical wire
// shape, so it reads the identical axis rather than assuming the default.
msgs, err := toOpenAIMessages(req.Messages, c.cap.SystemMessages)
if err != nil {
return nil, err
}
resp, err := c.http.complete(ctx, openAIRequest{
model: c.model,
messages: msgs,
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
}