Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ Then just run `opencode`.

```sh
oc-config list
oc-config add --provider <name> [--model-family <family>] [--model <id>]
oc-config add --provider <name> [--model-family <family>] [--model <id>] [--base-url <url>]
oc-config remove --provider <name> [--model-family <family>] [--model <id>]
```

Short flags: `-p` (provider), `-f` (model-family), `-m` (model).
Short flags: `-p` (provider), `-f` (model-family), `-m` (model), `-b` (base-url).

### Examples

Expand All @@ -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
Expand All @@ -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

Expand Down
19 changes: 18 additions & 1 deletion catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
104 changes: 97 additions & 7 deletions catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -138,15 +138,15 @@ 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")
}
}

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") {
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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"])
}
}
5 changes: 3 additions & 2 deletions docs/llamacpp/gemma4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
5 changes: 3 additions & 2 deletions docs/llamacpp/qwen3.6.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
13 changes: 11 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
// oc-config add --provider <name> [--model-family <family>] [--model <id>]
// oc-config remove --provider <name> [--model-family <family>] [--model <id>]
//
// 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 (
Expand Down Expand Up @@ -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)

Expand All @@ -79,6 +85,7 @@ type selection struct {
family string
model string
providers string
baseURL string
}

func parseSelection(name string, args []string) (selection, error) {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
43 changes: 41 additions & 2 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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())

Expand All @@ -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 {
Expand All @@ -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)
Expand Down
Loading