From 1c35c9cf68c0d21d7227c81b88d164b74ccea669 Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:03:57 +0100 Subject: [PATCH] fix(ollama): normalize the base URL on the environment path too The saved-profile path normalized /v1 but FromEnv did not, so ARTEX_LLM_BASE_URL=http://host:11434 sent chat to /chat/completions and got a 404 on a LAN Ollama server. Share one helper between both entry points, and add an env-path test with a bare host and with a full endpoint URL. Also soften the keyless comment: a local Ollama ignores the bearer token, but a gateway in front of it can require one, which belongs in a saved profile. --- agent/provider.go | 46 ++++++++++++++++++++++++--------------- server/llm_ollama_test.go | 24 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/agent/provider.go b/agent/provider.go index e79d60c..6ce4f8a 100644 --- a/agent/provider.go +++ b/agent/provider.go @@ -147,6 +147,23 @@ const DefaultOllamaBaseURL = "http://127.0.0.1:11434/v1" // to the local server) and keeps the header stable. const ollamaPlaceholderKey = "ollama" +// normalizeOllamaBaseURL maps whatever the operator typed onto the /v1 root the +// OpenAI-compatible provider expects: it strips a pasted "/chat/completions" +// suffix, an empty value becomes the local default, and a missing "/v1" is +// appended so the natural "http://host:11434" also works. Shared by the saved +// profile path (ConfigFrom) and the environment path (FromEnv): both used to +// differ, and only the profile path normalized. +func normalizeOllamaBaseURL(raw string) string { + b := strings.TrimRight(strings.TrimSuffix(strings.TrimSpace(raw), "/chat/completions"), "/") + if b == "" { + return DefaultOllamaBaseURL + } + if !strings.HasSuffix(b, "/v1") { + b += "/v1" + } + return b +} + // NeedsAPIKey reports whether this provider format requires an API key to be // usable. Ollama (local server) is keyless; every cloud format needs one. func (c Config) NeedsAPIKey() bool { return c.Format != FormatOllama } @@ -224,13 +241,11 @@ func FromEnv() (Config, bool) { } case "ollama": c.Format = FormatOllama - // Deliberately NOT inheriting OPENAI_API_KEY: Ollama ignores the bearer - // token, so the only thing the inheritance could do is ship a real cloud - // key to the local (or LAN) endpoint. An authenticated Ollama gateway can - // still be configured as a saved profile with an explicit key. - if c.BaseURL == "" { - c.BaseURL = DefaultOllamaBaseURL - } + // Deliberately NOT inheriting OPENAI_API_KEY: a local Ollama ignores the + // bearer token, so the only thing the inheritance could do is ship a real + // cloud key to the endpoint. A gateway that does require a key can still + // be configured as a saved profile with an explicit one. + c.BaseURL = normalizeOllamaBaseURL(c.BaseURL) if c.Model == "" { c.Model = "llama3.2" } @@ -278,16 +293,10 @@ func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config { case "ollama": c.Format = FormatOllama // Ollama's OpenAI-compatible endpoint lives at /v1 and the provider - // appends "/chat/completions"; tolerate a full endpoint URL and the - // natural Ollama URL without the /v1 suffix. Never leave it empty: an - // empty BaseURL would let the SDK fall back to OpenAI's public endpoint - // with this profile's request. - c.BaseURL = strings.TrimRight(strings.TrimSuffix(strings.TrimSpace(c.BaseURL), "/chat/completions"), "/") - if c.BaseURL == "" { - c.BaseURL = DefaultOllamaBaseURL - } else if !strings.HasSuffix(c.BaseURL, "/v1") { - c.BaseURL += "/v1" - } + // appends "/chat/completions". Never leave it empty: an empty BaseURL + // would let the SDK fall back to OpenAI's public endpoint with this + // profile's request. + c.BaseURL = normalizeOllamaBaseURL(c.BaseURL) if c.Model == "" { c.Model = "llama3.2" } @@ -340,7 +349,8 @@ func (c Config) NewProvider() (llm.Provider, error) { // Ollama speaks OpenAI's Chat Completions on the wire. Bundle the keyless // defaults here so an Ollama config can never leak toward the SDK's env- // driven fallbacks: BaseURL to OpenAI's public endpoint, APIKey to - // OPENAI_API_KEY. + // OPENAI_API_KEY. A local Ollama ignores the placeholder token; a gateway + // that requires one carries a real key in the profile. if wireFormat == FormatOllama { wireFormat = llm.FormatOpenAI if strings.TrimSpace(baseURL) == "" { diff --git a/server/llm_ollama_test.go b/server/llm_ollama_test.go index 8a65966..f6e5144 100644 --- a/server/llm_ollama_test.go +++ b/server/llm_ollama_test.go @@ -231,6 +231,30 @@ func TestOllamaListModels(t *testing.T) { } } +// The environment path must normalize the base URL too: ARTEX_LLM_BASE_URL is +// the natural way to configure a LAN Ollama server, and it used to send chat to +// /chat/completions instead of /v1/chat/completions. +func TestOllamaFromEnvNormalizesBaseURL(t *testing.T) { + t.Setenv("ARTEX_LLM_PROVIDER", "ollama") + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("ARTEX_LLM_BASE_URL", "http://box:11434") + c, ok := agent.FromEnv() + if !ok { + t.Fatal("ollama env config must be usable") + } + if c.BaseURL != "http://box:11434/v1" { + t.Fatalf("env base URL not normalized: %q", c.BaseURL) + } + t.Setenv("ARTEX_LLM_BASE_URL", "http://box:11434/v1/chat/completions") + c, ok = agent.FromEnv() + if !ok { + t.Fatal("ollama env config must be usable") + } + if c.BaseURL != "http://box:11434/v1" { + t.Fatalf("full endpoint URL not normalized: %q", c.BaseURL) + } +} + // An Ollama config resolved from the environment must not inherit OPENAI_API_KEY: // Ollama ignores the bearer token, so inheritance would only ship the cloud key // to the local or LAN endpoint.