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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Run a single test: `go test -run TestWriteConfig_Idempotent ./...`
## Layout

- `main.go` — CLI: command dispatch (`add`/`remove`/`list`), flag parsing (`parseSelection` registers both long and short flags against the same vars), and user-facing output.
- `catalog.go` — the embedded provider catalogue (`//go:embed providers.yaml`), its types, and `buildProviderBlock`, which turns a provider+family+model selection into an opencode provider block. The catalogue can be overridden at runtime via the `--providers`/`-c` flag or `OC_CONFIG_PROVIDERS` env var (flag > env > embedded; see `resolveCatalogPath`/`loadCatalogFrom`).
- `catalog.go` — the embedded provider catalogue (`//go:embed providers.yaml`), its types, and `buildProviderBlock`, which turns a provider+family+model selection into an opencode provider block. The catalogue can be overridden at runtime via the `--providers` flag or `OC_CONFIG_PROVIDERS` env var (flag > env > embedded; see `resolveCatalogPath`/`loadCatalogFrom`).
- `config.go` — opencode config IO: JSONC read/merge/write, env/key resolution, JSON-Pointer helpers.
- `providers.yaml` — externalised provider/model-family data (URLs, model ids, key env vars). **Add providers/models here, not in Go.** Embedded at build time but kept external for maintenance.
- `*_test.go` — `catalog_test.go` (catalogue integrity + `buildProviderBlock`), `config_test.go` (merge/remove/IO), `main_test.go` (CLI layer).
Expand Down
14 changes: 11 additions & 3 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>] [--base-url <url>]
oc-config add --provider <name> [--model-family <family>] [--model <id>] [--context <size>] [--base-url <url>]
oc-config remove --provider <name> [--model-family <family>] [--model <id>]
```

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

### Examples

Expand All @@ -78,6 +78,10 @@ OPENAI_API_KEY=sk-... \
# Pin a specific default model
oc-config add -p openrouter -f deepseek-v4 -m deepseek/deepseek-v4-pro

# Set the context window — human suffixes or an absolute count, both fine
oc-config add -p llamacpp -m my-model -c 128k
oc-config add -p llamacpp -m my-model --context 200000

# Take a provider back out
oc-config remove -p ollama

Expand All @@ -88,6 +92,10 @@ oc-config remove -p openrouter -f deepseek-v4
`add` sets the chosen model as opencode's default. `remove` clears the default
if it pointed at something you removed.

`--context`/`-c` records each added model's context window. Parsing is
forgiving: `128k`, `1m`, `1.5m`, `200000`, `128,000`, even `128 K tokens` all
land where you'd expect (`k`/`m`/`g` are decimal — `128k` is 128,000 tokens).

## Keys and endpoints

Each provider declares which environment variable holds its key (`oc-config
Expand Down Expand Up @@ -125,7 +133,7 @@ Don't want to rebuild? Point `oc-config` at your own catalogue at runtime — th
flag wins, then the env var, then the built-in default:

```sh
oc-config list --providers ./my-providers.yaml # or: -c
oc-config list --providers ./my-providers.yaml
OC_CONFIG_PROVIDERS=./my-providers.yaml oc-config list
```

Expand Down
73 changes: 73 additions & 0 deletions context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package main

import (
"fmt"
"strconv"
"strings"
)

// parseContextSize parses a human-friendly context window size into a token
// count. It is deliberately lenient: surrounding whitespace, commas, and
// underscores are ignored, an optional k/m/g/t suffix is honoured
// (case-insensitive, decimal — k=1e3, m=1e6, g/b=1e9, t=1e12), a fractional
// value may precede a suffix (e.g. "1.5m"), and a trailing "tokens"/"tok" word
// is tolerated. So "128k", "128,000", "128_000", " 128 K tokens ", and
// "0.128m" all parse to 128000.
func parseContextSize(s string) (int, error) {
orig := s
// Normalise: drop separators, lowercase, strip a trailing "tokens" word.
s = strings.ToLower(strings.TrimSpace(s))
s = strings.NewReplacer(",", "", "_", "", " ", "").Replace(s)
for _, suf := range []string{"tokens", "token", "toks", "tok"} {
if strings.HasSuffix(s, suf) {
s = strings.TrimSuffix(s, suf)
break
}
}

mult := 1.0
if n := len(s); n > 0 {
switch s[n-1] {
case 'k':
mult, s = 1e3, s[:n-1]
case 'm':
mult, s = 1e6, s[:n-1]
case 'g', 'b':
mult, s = 1e9, s[:n-1]
case 't':
mult, s = 1e12, s[:n-1]
}
}

if s == "" {
return 0, fmt.Errorf("invalid context size %q", orig)
}
val, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, fmt.Errorf("invalid context size %q", orig)
}
tokens := val * mult
if tokens < 1 {
return 0, fmt.Errorf("context size %q must be at least 1 token", orig)
}
return int(tokens), nil
}

// applyContextSize sets limit.context on every model in models, merging into
// any existing limit map rather than replacing it. The model values are the
// map[string]any entries produced by buildProviderBlock.
func applyContextSize(models map[string]any, ctx int) {
for k, v := range models {
m, ok := v.(map[string]any)
if !ok {
m = map[string]any{}
}
limit, ok := m["limit"].(map[string]any)
if !ok {
limit = map[string]any{}
}
limit["context"] = ctx
m["limit"] = limit
models[k] = m
}
}
94 changes: 94 additions & 0 deletions context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package main

import "testing"

func TestParseContextSize_Lenient(t *testing.T) {
// Each input must parse to the same 128000-token window, exercising the
// suffix, separator, whitespace, fraction, and trailing-word leniency.
for _, in := range []string{
"128000",
"128,000",
"128_000",
"128k",
"128K",
" 128 k ",
"128 K tokens",
"128ktok",
"0.128m",
} {
got, err := parseContextSize(in)
if err != nil {
t.Errorf("parseContextSize(%q) errored: %v", in, err)
continue
}
if got != 128000 {
t.Errorf("parseContextSize(%q) = %d, want 128000", in, got)
}
}
}

func TestParseContextSize_Suffixes(t *testing.T) {
cases := map[string]int{
"200000": 200000,
"1m": 1_000_000,
"1M": 1_000_000,
"1.5m": 1_500_000,
"2g": 2_000_000_000,
"3b": 3_000_000_000,
"1t": 1_000_000_000_000,
"32k": 32_000,
}
for in, want := range cases {
got, err := parseContextSize(in)
if err != nil {
t.Errorf("parseContextSize(%q) errored: %v", in, err)
continue
}
if got != want {
t.Errorf("parseContextSize(%q) = %d, want %d", in, got, want)
}
}
}

func TestParseContextSize_Invalid(t *testing.T) {
for _, in := range []string{
"", // empty
" ", // blank
"abc", // not a number
"k", // suffix only, no value
"12x", // unknown suffix folds into the number and fails
"0", // below one token
"-5k", // negative
"0.0001k", // rounds below one token
} {
if got, err := parseContextSize(in); err == nil {
t.Errorf("parseContextSize(%q) = %d, want error", in, got)
}
}
}

func TestApplyContextSize(t *testing.T) {
// A model with an existing limit must keep sibling limit keys.
models := map[string]any{
"a": map[string]any{"name": "A"},
"b": map[string]any{"name": "B", "limit": map[string]any{"output": 8192}},
"c": "not-a-map", // hardened against unexpected shapes
}
applyContextSize(models, 200000)

a := models["a"].(map[string]any)["limit"].(map[string]any)
if a["context"] != 200000 {
t.Errorf("model a context = %v, want 200000", a["context"])
}
b := models["b"].(map[string]any)["limit"].(map[string]any)
if b["context"] != 200000 {
t.Errorf("model b context = %v, want 200000", b["context"])
}
if b["output"] != 8192 {
t.Errorf("model b output limit not preserved: %v", b["output"])
}
c := models["c"].(map[string]any)["limit"].(map[string]any)
if c["context"] != 200000 {
t.Errorf("non-map model c was not normalised: %v", c)
}
}
7 changes: 7 additions & 0 deletions docs/llamacpp/gemma4.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ 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.

Match opencode's context window to the `--ctx-size` you launched the server
with, so it doesn't overshoot what `llama-server` will accept:

```sh
oc-config add -p llamacpp -m gemma-4-12b-it --context 32k
```

Running on a non-default host or port? Point the provider at it with the
`--base-url`/`-b` flag (or `LLAMACPP_BASE_URL`):

Expand Down
7 changes: 7 additions & 0 deletions docs/llamacpp/qwen3.6.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ 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.

Match opencode's context window to the `--ctx-size` you launched the server
with, so it doesn't overshoot what `llama-server` will accept:

```sh
oc-config add -p llamacpp -m qwen3.6-35b-a3b --context 32k
```

Running on a non-default host or port? Point the provider at it with the
`--base-url`/`-b` flag (or `LLAMACPP_BASE_URL`):

Expand Down
33 changes: 27 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
// 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), -b (base-url).
// Short flags: -p (provider), -f (model-family), -m (model), -c (context),
// -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
Expand Down Expand Up @@ -60,20 +61,23 @@ func usage() {

Usage:
oc-config list
oc-config add --provider <name> [--model-family <family>] [--model <id>]
oc-config add --provider <name> [--model-family <family>] [--model <id>] [--context <size>]
oc-config remove --provider <name> [--model-family <family>] [--model <id>]

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
-c, --context context window size for the added model(s); accepts
human suffixes (128k, 1m) or an absolute count (200000)
-b, --base-url override the provider API base URL
(or set OC_CONFIG_BASE_URL)
-c, --providers path to a providers.yaml override
--providers path to a providers.yaml override
(or set OC_CONFIG_PROVIDERS)

add: deep-merges the provider into the opencode config, preserving everything
else. Specify a family and/or an explicit model.
else. Specify a family and/or an explicit model. --context sets the
model's limit.context window.
remove: removes the provider, or just the named models when a family/model is
given. Clears the default model if it pointed at something removed.
`)
Expand All @@ -84,6 +88,7 @@ type selection struct {
provider string
family string
model string
context string
providers string
baseURL string
}
Expand All @@ -97,8 +102,9 @@ func parseSelection(name string, args []string) (selection, error) {
fs.StringVar(&s.family, "f", "", "model family (shorthand)")
fs.StringVar(&s.model, "model", "", "model id")
fs.StringVar(&s.model, "m", "", "model id (shorthand)")
fs.StringVar(&s.context, "context", "", "context window size (e.g. 128k, 1m, 200000)")
fs.StringVar(&s.context, "c", "", "context window size (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 {
Expand Down Expand Up @@ -133,6 +139,19 @@ func cmdAdd(args []string) error {
return err
}

var contextSize int
if sel.context != "" {
contextSize, err = parseContextSize(sel.context)
if err != nil {
return err
}
models, _ := block["models"].(map[string]any)
if len(models) == 0 {
return fmt.Errorf("--context/-c needs a model: specify --model-family/-f and/or --model/-m")
}
applyContextSize(models, contextSize)
}

configFile, err := resolveConfigFile()
if err != nil {
return err
Expand All @@ -150,6 +169,9 @@ func cmdAdd(args []string) error {
if defaultModel != "" {
fmt.Printf("Default model: %s\n", defaultModel)
}
if contextSize > 0 {
fmt.Printf("Context window: %d tokens\n", contextSize)
}
if opts, ok := block["options"].(map[string]any); ok {
if _, ok := opts["apiKey"]; ok {
fmt.Printf("API key injected from %s.\n", p.APIKeyEnv)
Expand Down Expand Up @@ -217,7 +239,6 @@ func cmdList(args []string) error {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
var providers string
fs.StringVar(&providers, "providers", "", "path to a providers.yaml override")
fs.StringVar(&providers, "c", "", "providers.yaml override (shorthand)")
if err := fs.Parse(args); err != nil {
return err
}
Expand Down
Loading
Loading