From 615448156c10113715db74c045e37904939a2a59 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 22 Jun 2026 16:35:34 +0100 Subject: [PATCH] feat: allow API base URL override via flag or env var Add a general --base-url/-b flag and OC_CONFIG_BASE_URL environment variable that set options.baseURL for any provider, overriding the catalogue's static and per-provider base URLs. The flag wins over the env var, which wins over the catalogue defaults. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 24 ++++++--- catalog.go | 19 ++++++- catalog_test.go | 104 ++++++++++++++++++++++++++++++++++++--- docs/llamacpp/gemma4.md | 5 +- docs/llamacpp/qwen3.6.md | 5 +- main.go | 13 ++++- main_test.go | 43 +++++++++++++++- 7 files changed, 190 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index ff55556f..e0b9d371 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,11 @@ Then just run `opencode`. ```sh oc-config list -oc-config add --provider [--model-family ] [--model ] +oc-config add --provider [--model-family ] [--model ] [--base-url ] oc-config remove --provider [--model-family ] [--model ] ``` -Short flags: `-p` (provider), `-f` (model-family), `-m` (model). +Short flags: `-p` (provider), `-f` (model-family), `-m` (model), `-b` (base-url). ### Examples @@ -71,9 +71,9 @@ oc-config add -p ollama -f llama # Claude on AWS Bedrock (uses your AWS credentials) oc-config add -p amazon-bedrock -f claude -# Any OpenAI-compatible endpoint, set via env -OPENAI_API_KEY=sk-... OPENAI_BASE_URL=https://my-endpoint/v1 \ - oc-config add -p openai-compatible -m my-model +# Any OpenAI-compatible endpoint, base URL via flag +OPENAI_API_KEY=sk-... \ + oc-config add -p openai-compatible -m my-model --base-url https://my-endpoint/v1 # Pin a specific default model oc-config add -p openrouter -f deepseek-v4 -m deepseek/deepseek-v4-pro @@ -95,8 +95,18 @@ list` shows them). Values are looked up in `.env` next to the tool first, then your shell environment. Local providers like Ollama and llama.cpp need no key; Bedrock authenticates through your AWS credentials. -Base URLs default to the usual local ports and can be overridden via env -(`OLLAMA_BASE_URL`, `LLAMACPP_BASE_URL`, `OPENAI_BASE_URL`). +Base URLs default to the usual local ports. Override the endpoint for **any** +provider with `--base-url`/`-b` or the `OC_CONFIG_BASE_URL` env var — handy for +proxies, gateways, or a server on a non-default host: + +```sh +oc-config add -p openai-compatible -m my-model --base-url https://gateway/v1 +OC_CONFIG_BASE_URL=https://gateway/v1 oc-config add -p openai-compatible -m my-model +``` + +The flag wins over the env var, and either wins over the catalogue's defaults +and the per-provider variables (`OLLAMA_BASE_URL`, `LLAMACPP_BASE_URL`, +`OPENAI_BASE_URL`). ## Guides diff --git a/catalog.go b/catalog.go index 3527b956..8b2ba2a7 100644 --- a/catalog.go +++ b/catalog.go @@ -14,6 +14,12 @@ import ( // override. const providersEnv = "OC_CONFIG_PROVIDERS" +// baseURLEnv names the environment variable that overrides the provider's API +// base URL, regardless of which provider is selected. The --base-url flag takes +// precedence over it; both win over the catalogue's static and per-provider +// (optionsFromEnv) base URLs. +const baseURLEnv = "OC_CONFIG_BASE_URL" + // providersYAML is the externalised provider/model-family catalogue, embedded // into the binary at build time but maintained as a plain file. // @@ -111,7 +117,12 @@ func (f *Family) modelKeys() []string { // model into an opencode provider block, returning the block and the // fully-qualified default model (provider/model), or "" if none was selected. // resolve looks up env vars (typically from .env, then the environment). -func buildProviderBlock(id string, p *Provider, familyName, modelOverride string, resolve func(string) string) (block map[string]any, defaultModel string, err error) { +// +// baseURLOverride, when non-empty, sets options.baseURL for any provider. It +// comes from the --base-url flag; when empty, the OC_CONFIG_BASE_URL env var is +// consulted via resolve. Either wins over the catalogue's static baseURL and +// any per-provider optionsFromEnv mapping. +func buildProviderBlock(id string, p *Provider, familyName, modelOverride, baseURLOverride string, resolve func(string) string) (block map[string]any, defaultModel string, err error) { block = map[string]any{} if p.Name != "" { block["name"] = p.Name @@ -129,6 +140,12 @@ func buildProviderBlock(id string, p *Provider, familyName, modelOverride string options[optKey] = v } } + if baseURLOverride == "" { + baseURLOverride = resolve(baseURLEnv) + } + if baseURLOverride != "" { + options["baseURL"] = baseURLOverride + } if p.APIKeyEnv != "" { key := resolve(p.APIKeyEnv) switch { diff --git a/catalog_test.go b/catalog_test.go index 4389c000..7739047c 100644 --- a/catalog_test.go +++ b/catalog_test.go @@ -117,7 +117,7 @@ func TestBuildProviderBlock_OpenRouterKeyInjected(t *testing.T) { cat, _ := loadCatalog() p := cat.Providers["openrouter"] - block, model, err := buildProviderBlock("openrouter", p, "deepseek-v4", "", envMap(map[string]string{ + block, model, err := buildProviderBlock("openrouter", p, "deepseek-v4", "", "", envMap(map[string]string{ "DEEPSEEK_API_KEY": "sk-or-v1-abc", })) if err != nil { @@ -138,7 +138,7 @@ func TestBuildProviderBlock_OpenRouterKeyInjected(t *testing.T) { func TestBuildProviderBlock_RequiredKeyMissing(t *testing.T) { cat, _ := loadCatalog() p := cat.Providers["openrouter"] - if _, _, err := buildProviderBlock("openrouter", p, "deepseek-v4", "", noEnv); err == nil { + if _, _, err := buildProviderBlock("openrouter", p, "deepseek-v4", "", "", noEnv); err == nil { t.Fatal("expected error when required key is missing") } } @@ -146,7 +146,7 @@ func TestBuildProviderBlock_RequiredKeyMissing(t *testing.T) { func TestBuildProviderBlock_KeyPrefixMismatch(t *testing.T) { cat, _ := loadCatalog() p := cat.Providers["openrouter"] - _, _, err := buildProviderBlock("openrouter", p, "deepseek-v4", "", envMap(map[string]string{ + _, _, err := buildProviderBlock("openrouter", p, "deepseek-v4", "", "", envMap(map[string]string{ "DEEPSEEK_API_KEY": "wrong-prefix-key", })) if err == nil || !strings.Contains(err.Error(), "start with") { @@ -158,7 +158,7 @@ func TestBuildProviderBlock_BedrockNoKeyRegionFromEnv(t *testing.T) { cat, _ := loadCatalog() p := cat.Providers["amazon-bedrock"] - block, model, err := buildProviderBlock("amazon-bedrock", p, "claude", "", envMap(map[string]string{ + block, model, err := buildProviderBlock("amazon-bedrock", p, "claude", "", "", envMap(map[string]string{ "AWS_REGION": "eu-west-2", })) if err != nil { @@ -180,7 +180,7 @@ func TestBuildProviderBlock_CustomProviderDefaults(t *testing.T) { cat, _ := loadCatalog() p := cat.Providers["ollama"] - block, model, err := buildProviderBlock("ollama", p, "llama", "", noEnv) + block, model, err := buildProviderBlock("ollama", p, "llama", "", "", noEnv) if err != nil { t.Fatalf("buildProviderBlock: %v", err) } @@ -200,7 +200,7 @@ func TestBuildProviderBlock_ModelOverrideAddsEntry(t *testing.T) { cat, _ := loadCatalog() p := cat.Providers["ollama"] - block, model, err := buildProviderBlock("ollama", p, "", "my-model", noEnv) + block, model, err := buildProviderBlock("ollama", p, "", "my-model", "", noEnv) if err != nil { t.Fatalf("buildProviderBlock: %v", err) } @@ -217,7 +217,97 @@ func TestBuildProviderBlock_ModelOverrideAddsEntry(t *testing.T) { func TestBuildProviderBlock_UnknownFamily(t *testing.T) { cat, _ := loadCatalog() p := cat.Providers["openrouter"] - if _, _, err := buildProviderBlock("openrouter", p, "nope", "", noEnv); err == nil { + if _, _, err := buildProviderBlock("openrouter", p, "nope", "", "", noEnv); err == nil { t.Fatal("expected error for unknown family") } } + +// TestBuildProviderBlock_BaseURLFlagOverrides checks that the --base-url value +// wins over both the catalogue's static baseURL and the per-provider +// optionsFromEnv mapping (here OLLAMA_BASE_URL). +func TestBuildProviderBlock_BaseURLFlagOverrides(t *testing.T) { + cat, _ := loadCatalog() + p := cat.Providers["ollama"] + + block, _, err := buildProviderBlock("ollama", p, "llama", "", "https://flag.example/v1", envMap(map[string]string{ + "OLLAMA_BASE_URL": "https://per-provider.example/v1", + })) + if err != nil { + t.Fatalf("buildProviderBlock: %v", err) + } + opts := block["options"].(map[string]any) + if opts["baseURL"] != "https://flag.example/v1" { + t.Errorf("baseURL = %v, want the --base-url flag value", opts["baseURL"]) + } +} + +// TestBuildProviderBlock_BaseURLFromEnv checks that, with no flag, the general +// OC_CONFIG_BASE_URL env var overrides the catalogue's static baseURL. +func TestBuildProviderBlock_BaseURLFromEnv(t *testing.T) { + cat, _ := loadCatalog() + p := cat.Providers["ollama"] + + block, _, err := buildProviderBlock("ollama", p, "llama", "", "", envMap(map[string]string{ + baseURLEnv: "https://from-env.example/v1", + })) + if err != nil { + t.Fatalf("buildProviderBlock: %v", err) + } + opts := block["options"].(map[string]any) + if opts["baseURL"] != "https://from-env.example/v1" { + t.Errorf("baseURL = %v, want the OC_CONFIG_BASE_URL value", opts["baseURL"]) + } +} + +// TestBuildProviderBlock_BaseURLFlagBeatsEnv checks the precedence: an explicit +// --base-url flag wins over OC_CONFIG_BASE_URL. +func TestBuildProviderBlock_BaseURLFlagBeatsEnv(t *testing.T) { + cat, _ := loadCatalog() + p := cat.Providers["ollama"] + + block, _, err := buildProviderBlock("ollama", p, "llama", "", "https://flag.example/v1", envMap(map[string]string{ + baseURLEnv: "https://from-env.example/v1", + })) + if err != nil { + t.Fatalf("buildProviderBlock: %v", err) + } + opts := block["options"].(map[string]any) + if opts["baseURL"] != "https://flag.example/v1" { + t.Errorf("baseURL = %v, want the flag to win over the env var", opts["baseURL"]) + } +} + +// TestBuildProviderBlock_BaseURLOnPlainProvider checks that the override applies +// even to a provider that carries no baseURL in the catalogue, injecting a fresh +// options.baseURL. +func TestBuildProviderBlock_BaseURLOnPlainProvider(t *testing.T) { + cat, _ := loadCatalog() + p := cat.Providers["openrouter"] + + block, _, err := buildProviderBlock("openrouter", p, "deepseek-v4", "", "https://gateway.example/v1", envMap(map[string]string{ + "DEEPSEEK_API_KEY": "sk-or-v1-abc", + })) + if err != nil { + t.Fatalf("buildProviderBlock: %v", err) + } + opts := block["options"].(map[string]any) + if opts["baseURL"] != "https://gateway.example/v1" { + t.Errorf("baseURL = %v, want it injected on a provider without a default", opts["baseURL"]) + } +} + +// TestBuildProviderBlock_NoBaseURLOverride confirms the catalogue's static +// baseURL is left untouched when neither flag nor env var is set. +func TestBuildProviderBlock_NoBaseURLOverride(t *testing.T) { + cat, _ := loadCatalog() + p := cat.Providers["ollama"] + + block, _, err := buildProviderBlock("ollama", p, "llama", "", "", noEnv) + if err != nil { + t.Fatalf("buildProviderBlock: %v", err) + } + opts := block["options"].(map[string]any) + if opts["baseURL"] != "http://localhost:11434/v1" { + t.Errorf("baseURL = %v, want the catalogue default", opts["baseURL"]) + } +} diff --git a/docs/llamacpp/gemma4.md b/docs/llamacpp/gemma4.md index 0c637832..3d8389ab 100644 --- a/docs/llamacpp/gemma4.md +++ b/docs/llamacpp/gemma4.md @@ -118,10 +118,11 @@ oc-config add -p llamacpp -m gemma-4-12b-it The model name is just a label — `llama-server` serves whichever model it has loaded regardless of what's requested — so call it whatever you find readable. -Running on a non-default host or port? Tell the provider via `LLAMACPP_BASE_URL`: +Running on a non-default host or port? Point the provider at it with the +`--base-url`/`-b` flag (or `LLAMACPP_BASE_URL`): ```sh -LLAMACPP_BASE_URL=http://127.0.0.1:9090/v1 oc-config add -p llamacpp -m gemma-4-12b-it +oc-config add -p llamacpp -m gemma-4-12b-it --base-url http://127.0.0.1:9090/v1 ``` Now start `opencode` and select `llamacpp/gemma-4-12b-it`. diff --git a/docs/llamacpp/qwen3.6.md b/docs/llamacpp/qwen3.6.md index bf92d009..2900a62b 100644 --- a/docs/llamacpp/qwen3.6.md +++ b/docs/llamacpp/qwen3.6.md @@ -93,10 +93,11 @@ oc-config add -p llamacpp -m qwen3.6-35b-a3b The model name is just a label — `llama-server` serves whichever model it has loaded regardless of what's requested — so call it whatever you find readable. -Running on a non-default host or port? Tell the provider via `LLAMACPP_BASE_URL`: +Running on a non-default host or port? Point the provider at it with the +`--base-url`/`-b` flag (or `LLAMACPP_BASE_URL`): ```sh -LLAMACPP_BASE_URL=http://127.0.0.1:9090/v1 oc-config add -p llamacpp -m qwen3.6-35b-a3b +oc-config add -p llamacpp -m qwen3.6-35b-a3b --base-url http://127.0.0.1:9090/v1 ``` Now start `opencode` and select `llamacpp/qwen3.6-35b-a3b`. diff --git a/main.go b/main.go index 84c214c5..4e00547e 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,11 @@ // oc-config add --provider [--model-family ] [--model ] // oc-config remove --provider [--model-family ] [--model ] // -// Short flags: -p (provider), -f (model-family), -m (model). +// Short flags: -p (provider), -f (model-family), -m (model), -b (base-url). +// +// The API base URL can be overridden for any provider with --base-url/-b or the +// OC_CONFIG_BASE_URL environment variable; the flag wins over the env var, and +// either wins over the catalogue's defaults. package main import ( @@ -63,6 +67,8 @@ Flags: -p, --provider provider name (see `+"`oc-config list`"+`) -f, --model-family model family to add or remove -m, --model model id to set as default / to add or remove + -b, --base-url override the provider API base URL + (or set OC_CONFIG_BASE_URL) -c, --providers path to a providers.yaml override (or set OC_CONFIG_PROVIDERS) @@ -79,6 +85,7 @@ type selection struct { family string model string providers string + baseURL string } func parseSelection(name string, args []string) (selection, error) { @@ -92,6 +99,8 @@ func parseSelection(name string, args []string) (selection, error) { fs.StringVar(&s.model, "m", "", "model id (shorthand)") fs.StringVar(&s.providers, "providers", "", "path to a providers.yaml override") fs.StringVar(&s.providers, "c", "", "providers.yaml override (shorthand)") + fs.StringVar(&s.baseURL, "base-url", "", "override the provider API base URL") + fs.StringVar(&s.baseURL, "b", "", "API base URL override (shorthand)") if err := fs.Parse(args); err != nil { return s, err } @@ -119,7 +128,7 @@ func cmdAdd(args []string) error { return fmt.Errorf("unknown provider %q (see `oc-config list`)", sel.provider) } - block, defaultModel, err := buildProviderBlock(sel.provider, p, sel.family, sel.model, resolveEnv) + block, defaultModel, err := buildProviderBlock(sel.provider, p, sel.family, sel.model, sel.baseURL, resolveEnv) if err != nil { return err } diff --git a/main_test.go b/main_test.go index 07261f51..c571644d 100644 --- a/main_test.go +++ b/main_test.go @@ -63,6 +63,22 @@ func TestParseSelection(t *testing.T) { if _, err := parseSelection("add", []string{"-f", "llama"}); err == nil { t.Error("expected error when --provider is missing") } + + // Base URL flag, long and short forms. + s, err = parseSelection("add", []string{"-p", "ollama", "--base-url", "https://long.example/v1"}) + if err != nil { + t.Fatal(err) + } + if s.baseURL != "https://long.example/v1" { + t.Errorf("--base-url parsed wrong: %q", s.baseURL) + } + s, err = parseSelection("add", []string{"-p", "ollama", "-b", "https://short.example/v1"}) + if err != nil { + t.Fatal(err) + } + if s.baseURL != "https://short.example/v1" { + t.Errorf("-b parsed wrong: %q", s.baseURL) + } } func TestCmdAdd_EndToEnd(t *testing.T) { @@ -89,6 +105,29 @@ func TestCmdAdd_EndToEnd(t *testing.T) { } } +func TestCmdAdd_BaseURLOverride(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("OPENAI_API_KEY", "sk-test") + + out := captureStdout(t, func() { + if err := cmdAdd([]string{"-p", "openai-compatible", "-f", "gpt", "-b", "https://proxy.example/v1"}); err != nil { + t.Fatalf("cmdAdd: %v", err) + } + }) + if !strings.Contains(out, "Base URL: https://proxy.example/v1") { + t.Errorf("missing base URL in summary:\n%s", out) + } + + path := filepath.Join(dir, "opencode", "opencode.json") + m := readConfigMap(t, path) + prov := m["provider"].(map[string]any)["openai-compatible"].(map[string]any) + opts := prov["options"].(map[string]any) + if opts["baseURL"] != "https://proxy.example/v1" { + t.Errorf("baseURL = %v, want the flag override written to config", opts["baseURL"]) + } +} + func TestCmdAdd_Errors(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) @@ -110,7 +149,7 @@ func TestCmdRemove_EndToEnd(t *testing.T) { // Seed a family, then remove the whole provider via the CLI. cat, _ := loadCatalog() - block, dm, _ := buildProviderBlock("openrouter", cat.Providers["openrouter"], "deepseek-v4", "", envMap(map[string]string{ + block, dm, _ := buildProviderBlock("openrouter", cat.Providers["openrouter"], "deepseek-v4", "", "", envMap(map[string]string{ "DEEPSEEK_API_KEY": "sk-or-v1-x", })) if err := writeConfig(path, "openrouter", block, dm); err != nil { @@ -137,7 +176,7 @@ func TestCmdRemove_FamilyAndNoOp(t *testing.T) { path, _ := resolveConfigFile() cat, _ := loadCatalog() - block, dm, _ := buildProviderBlock("openrouter", cat.Providers["openrouter"], "deepseek-v4", "", envMap(map[string]string{ + block, dm, _ := buildProviderBlock("openrouter", cat.Providers["openrouter"], "deepseek-v4", "", "", envMap(map[string]string{ "DEEPSEEK_API_KEY": "sk-or-v1-x", })) writeConfig(path, "openrouter", block, dm)