diff --git a/AGENTS.md b/AGENTS.md index bdf0f263..363b8386 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,22 +25,22 @@ The binary lives under `cmd/`; the domain logic is split into `internal/` packag - `cmd/outfit/main.go` — CLI: command dispatch (`add`/`remove`/`list`/`show`/`apply`/`unapply`/`alias`/`unalias`/`serve`/`export`/`harness`/`init-providers`/`remote`/`completion`, plus the hidden `__complete`), flag parsing (`parseSelection` registers both long and short flags against the same vars, and returns the chosen harness name separately so it never leaks into a `Selection`), and user-facing output. `add` and `apply` share `applySelection`, the core that writes one provider selection; `remove` and `unapply` share `removeSelection`, its inverse. Both resolve the active harness (`harness.Resolve`) and route through it. `show` reports the active harness's current config via `harness.State` (the same read `export` reconstructs an Outfit from). `serve` is harness-agnostic (it launches `llama-server`, not a config write). `harness` launches the active harness (forwarding trailing args), or manages the stored default with `--get`/`--set`; its `--outfit`/`-O` routes through the same `applySelection` before launching, so a launch can dress the harness first. That flag's value is optional (`outfitPathFlag` implements `IsBoolFlag`) — bare it means the default `./Outfit`, so a path has to be attached (`--outfit=`), since positional args belong to the harness; the exception is a *leading* positional that names an Outfit, which is applied and not forwarded (see "Alias registry" below). `alias`/`unalias` manage the registry that lets a name stand in for a path, and `writeAliases` renders it for both `alias --list` and `show`. Tests: `main_test.go` (dispatch/add/remove/list), `apply_test.go` (`apply`/`unapply`/`export`), `harness_test.go` (the `harness` command, `show`, and Pi-routed add/export/remove), `serve_test.go`, `alias_test.go`, `complete_test.go`. - `cmd/outfit/complete.go` + `completion.bash` — tab completion, and the only reason the CLI is no longer a single source file: `//go:embed` needs the script beside it. `outfit completion bash` prints the script; the script calls the hidden `outfit __complete `, which prints one candidate per line and a final `:nofile`/`:file` directive. The `commands` table there mirrors `run`'s switch and each `cmdX`'s `FlagSet` (the `flag` package cannot enumerate a `FlagSet`); `TestCompletionCoversDispatch` scans `main.go` and fails when a new command is not completable. - `internal/config` — outfit's own config file, `${XDG_CONFIG_HOME:-~/.config}/outfit/config.json`: the default-harness preference and the Outfit alias registry. A **leaf package** — stdlib imports only, and it must stay that way (in particular it must never import `internal/outfit` to parse an Outfit), because that is what lets `readOutfit` resolve a name without dragging in the harness abstraction. Every write goes through `Update`, a read-modify-write of the whole document; unknown top-level keys round-trip through `File.extra`. -- `internal/outfit` — the `Outfit` file format and the shared `Selection` type: a flat, Dockerfile-style description of one provider selection (`PROVIDER`/`FAMILY`/`MODEL`/`ALIAS`/`CONTEXT`/`OUTPUT`/`BASEURL`/`PRESET`/`REMOTE`). `Parse` reads it into a `Selection` (keywords case-insensitive via `canonicalKeyword`, UPPERCASE canonical, `#` comments); `Format` renders one back out for `export`. `apply` defaults to `./Outfit` (`outfit.DefaultFile`). The harness is deliberately **not** a keyword — it is a runtime choice, so an Outfit stays portable. +- `internal/outfit` — the `Outfit` file format and the shared `Selection` type: a flat, Dockerfile-style description of one provider selection (`PROVIDER`/`MODEL`/`ALIAS`/`CONTEXT`/`OUTPUT`/`BASEURL`/`PRESET`/`REMOTE`). `Parse` reads it into a `Selection` (keywords case-insensitive via `canonicalKeyword`, UPPERCASE canonical, `#` comments); `Format` renders one back out for `export`. `apply` defaults to `./Outfit` (`outfit.DefaultFile`). The harness is deliberately **not** a keyword — it is a runtime choice, so an Outfit stays portable. - `internal/harness` — the harness abstraction. The `Harness` interface (`Name`/`Command`/`ConfigPath`/`Apply`/`Remove`/`State`), a `registry` of the opencode and pi adapters (`adapters.go`, which wrap `catalog` + `opencode`/`pi` + `contextsize`), runtime selection via `Resolve` with precedence **`--harness`/`-H` flag > `OUTFIT_HARNESS` env > stored preference > opencode**, and preference load/save (`LoadPreference`/`SavePreference`), which delegate to `internal/config` — that package, not this one, owns the config file. **Start here when adding a third harness:** implement the interface and register it. -- `internal/catalog` — the embedded provider catalogue (`//go:embed providers.yaml`), its types, and the two block builders (`apiKeyOptional` marks a provider that also works unauthenticated — `llamacpp` names both a keyless local server and the authenticated remote deployment — so the Pi builder writes its keyless placeholder instead of a `$VAR` that resolves to nothing; every other provider keeps the reference, which Pi resolves at run time): `BuildProviderBlock` turns a provider+family+model selection into an opencode provider block; `BuildPiProvider` turns the same into a Pi provider entry. `MatchFamily` does the reverse for `export` (configured models -> family name). The catalogue can be overridden at runtime via the `--providers` flag or `OUTFIT_PROVIDERS` env var (flag > env > embedded; see `ResolveCatalogPath`/`LoadFrom`). `providers.yaml` lives here. +- `internal/catalog` — the embedded provider catalogue (`//go:embed providers.yaml`), its types, and the two block builders (`apiKeyOptional` marks a provider that also works unauthenticated — `llamacpp` names both a keyless local server and the authenticated remote deployment — so the Pi builder writes its keyless placeholder instead of a `$VAR` that resolves to nothing; every other provider keeps the reference, which Pi resolves at run time): `BuildProviderBlock` turns a provider+model selection into an opencode provider block; `BuildPiProvider` turns the same into a Pi provider entry. The catalogue can be overridden at runtime via the `--providers` flag or `OUTFIT_PROVIDERS` env var (flag > env > embedded; see `ResolveCatalogPath`/`LoadFrom`). `providers.yaml` lives here. - `internal/opencode` — opencode config IO: JSONC read/merge/write, env/key resolution, JSON-Pointer helpers. `LoadConfigState` reads the config back (configured providers, their model keys, the default model, context/output limits) for `export`. - `internal/pi` — Pi config IO: resolves `~/.pi/agent/models.json` (note: `~/.pi/agent/`, **not** XDG), deep-merges one managed provider entry into it preserving everything else (other providers, unknown fields), `Remove`, and reads provider state back for `export`. Written `0600`. - `cmd/outfit/remote.go` — the `remote` command group (`start`/`stop`/`status`/`deploy`), the CLI's only nested subcommand: each subcommand is a thin wrapper over `internal/remote`. `deploy` is the exception that carries logic: `deployConfigFor` turns an Outfit (plus its preset) into a `remote.DeployConfig` — `PROVIDER` picks the runner (`llamacpp`/`vllm`; other providers are not self-hosted engines, so there is nothing to deploy), `MODEL` or the preset's `hf` names the weights as `repo:QUANT`, `CONTEXT` or the preset's `ctx-size` the window, `ALIAS` the served name, and the preset's remaining flags become `serveArgs`. The `[*]` globals are layered under the section (so `ngl`/`jinja` written there survive), and `cloudOwnedFlags` drops what the cloud sets itself (host/port/model/hf/api-key/ctx-size/alias/metrics), matched on `preset.CanonicalKey` so short aliases count. Config discovery (`resolveRemoteConfig`): an explicit `[path]` argument must name an Outfit with a `REMOTE` instruction; with no argument, `./Outfit` is consulted when present (its `REMOTE` resolved relative to the Outfit, like `PRESET`), falling back to the per-user `remote.json`. Tests: `remote_test.go` (dispatch plus full flows against an `httptest.Server`, with `stubAWSEnv` pinning the AWS credential chain to static env credentials). - `internal/remote` — controls the scale-to-zero GPU inference instance defined in [`remote/`](remote/) by calling its start/stop/deploy Lambda Function URLs (IAM auth, so requests are SigV4-signed via `aws-sdk-go-v2` — the repo's only AWS/network dependency). `Deploy` is the only call with a body, and SigV4 covers the payload hash, so `call`/`sign` take the bytes and hash exactly what is sent. `DeployConfig` deliberately carries no weights prefix: the Lambda derives the S3 layout and seeds the weights when they are absent, replying `{seeding, seedInstanceId}`. Config in `${XDG_CONFIG_HOME:-~/.config}/outfit/remote.json` (`start_url`/`stop_url`/`deploy_url`/`region`), overridable via `OUTFIT_REMOTE_*` env vars; `deploy_url` is optional so configs predating deploy still work for start/stop, and only `Deploy` complains when it is missing; the region falls back to `AWS_REGION`, then to the region embedded in the Function URL host. `Start` retries while the endpoint reports 503/starting, honouring `retry_after_seconds`. - `internal/contextsize` — parses human-friendly sizes (`128k`, `1.5m`, `200000`); `Apply` sets `limit.context` and `limit.output` on opencode model blocks; `DefaultOutput` returns a quarter of the context (the fallback when `OUTPUT` is unset). - `internal/preset` — parses llama.cpp [preset `.ini`](https://github.com/ggml-org/llama.cpp/blob/master/docs/preset.md) files into named sections of flags, and renders a `llama-server` command for `serve` (`Parse`/`Flags`/`FormatCommand`). -- `internal/catalog/providers.yaml` — externalised provider/model-family data (URLs, model ids, key env vars, and a `pi:` block per Pi-capable provider). **Add providers/models here, not in Go.** Embedded at build time but kept external for maintenance. +- `internal/catalog/providers.yaml` — externalised provider plumbing (URLs, key env vars, npm packages, and a `pi:` block per Pi-capable provider) — no model ids. **Add providers here, not in Go.** Embedded at build time but kept external for maintenance. - `examples/` — runnable guides, each a directory with a README and an `Outfit`. - `remote/` — the cloud GPU deployment `outfit remote` drives: a TypeScript CDK project (AWS Lambdas, EC2 Image Builder, S3 weights) with its own `package.json`, tests and docs, built and tested by pnpm rather than Go. It is the only non-Go part of the repository; CI runs its checks separately. **This repository is public, so nothing identifying a deployment — account ids, ARNs, Function URL hosts, allocated addresses, bucket names, resource ids — may be committed here or anywhere else in the repo.** `scripts/check-no-cloud-identifiers.sh` enforces that in CI; deployment state belongs in the gitignored files under `remote/` (`.env`, `Outfit`, `remote.json`, `cdk-outputs.json`). ## Architecture notes (the important part) -**Harness routing.** `cmd` does the harness-neutral work (load the catalogue, validate the family/model, parse sizes, pick the provider) and then hands a `Selection` to the harness resolved by `harness.Resolve`. Each adapter owns its config format end-to-end. The import graph is one-way: `harness` imports `catalog`/`opencode`/`pi`/`contextsize`/`outfit`/`config`; none of those import `harness`, and `config` sits at the bottom importing nothing, so there are no cycles. A model is keyed by its `ALIAS` when given, otherwise the provider-native `MODEL` (`modelKey` in `adapters.go`). +**Harness routing.** `cmd` does the harness-neutral work (load the catalogue, validate the model, parse sizes, pick the provider) and then hands a `Selection` to the harness resolved by `harness.Resolve`. Each adapter owns its config format end-to-end. The import graph is one-way: `harness` imports `catalog`/`opencode`/`pi`/`contextsize`/`outfit`/`config`; none of those import `harness`, and `config` sits at the bottom importing nothing, so there are no cycles. A model is keyed by its `ALIAS` when given, otherwise the provider-native `MODEL` (`modelKey` in `adapters.go`). **Alias registry.** `outfit alias` registers an Outfit under a name, and `readOutfit` — the single choke point `apply`/`unapply`/`serve`/`harness --outfit` all share — resolves it, so one change gives every command the shorthand. Three rules hold it together. (1) The lookup is gated on the argument being *name-shaped* (`config.NameShaped`), so a path-shaped argument never causes a config read at all; that is what keeps `serve` working with no config of ours present, and what keeps the test suite off the developer's real config. (2) A path on disk beats a registered name (`namesAnOutfit` decides), so registering an alias can never change what an already-working command does; the shadowing is reported, not silent. (3) The registry stores the absolute path of the **file**, never the directory, so `serve`'s relative-`PRESET` resolution still lands next to the aliased Outfit. Under `harness`, the first positional is taken as an Outfit only when no `--outfit` was given, flag parsing was not terminated by an explicit `--` (`flagsTerminated`), and it names one; a `--` immediately after a *consumed* Outfit is dropped, so every other command line is forwarded byte-for-byte. @@ -59,7 +59,7 @@ The binary lives under `cmd/`; the domain logic is split into `internal/` packag **`serve`.** `outfit serve` launches `llama-server` for an Outfit without touching any harness config. With a `PRESET` it flattens the matching `.ini` section (plus `[*]` defaults) into flags via `internal/preset`; otherwise it derives a command from `MODEL`/`ALIAS`/`CONTEXT`/`BASEURL`. Anything the Outfit states overrides the preset. `--dry-run`/`-n` prints the command without running it. -Invariants to keep: opencode merges stay idempotent and never drop unrelated config or comments; Pi merges preserve sibling providers and unknown fields; output stays `0600`; the harness never appears in an Outfit, and neither does an alias — both are machine-local, so an Outfit stays portable; outfit's own `config.json` is only ever written read-modify-write, so a new setting cannot clobber an existing one; `__complete` never returns an error and never writes to stderr, whatever the state of the config or catalogue; and every family's `defaultModel` must be one of its `models` (enforced by `TestCatalogIntegrity`). +Invariants to keep: opencode merges stay idempotent and never drop unrelated config or comments; Pi merges preserve sibling providers and unknown fields; output stays `0600`; the harness never appears in an Outfit, and neither does an alias — both are machine-local, so an Outfit stays portable; outfit's own `config.json` is only ever written read-modify-write, so a new setting cannot clobber an existing one; `__complete` never returns an error and never writes to stderr, whatever the state of the config or catalogue. ## Traps diff --git a/README.md b/README.md index c336f4e7..611a8411 100644 --- a/README.md +++ b/README.md @@ -86,13 +86,13 @@ See what's in the catalogue: outfit list ``` -Add a provider and a model family: +Add a provider and a model: ```sh # OpenRouter needs a key — put it in .env first: echo 'DEEPSEEK_API_KEY=sk-or-v1-...' > .env -outfit add --provider openrouter --model-family deepseek-v4 +outfit add --provider openrouter --model deepseek/deepseek-v4-flash ``` Then just run `opencode`. That's it — your agent is pointed at the new model, and @@ -102,17 +102,17 @@ the rest of your config is untouched. ```sh # A local Ollama model (no key required) -outfit add -p ollama -f llama +outfit add -p ollama -m llama3.2 # Claude on AWS Bedrock (uses your AWS credentials) -outfit add -p amazon-bedrock -f claude +outfit add -p amazon-bedrock -m anthropic.claude-3-5-sonnet # Any OpenAI-compatible endpoint, base URL via flag OPENAI_API_KEY=sk-... \ outfit add -p openai-compatible -m my-model --base-url https://my-endpoint/v1 # Pin a specific default model -outfit add -p openrouter -f deepseek-v4 -m deepseek/deepseek-v4-pro +outfit add -p openrouter -m deepseek/deepseek-v4-pro # Set the context window — human suffixes or an absolute count, both fine outfit add -p llamacpp -m my-model -c 128k @@ -124,8 +124,8 @@ outfit add -p llamacpp -m my-model -c 128k -o 32k # Take a provider back out outfit remove -p ollama -# Or just drop one family's models -outfit remove -p openrouter -f deepseek-v4 +# Or just drop one model +outfit remove -p openrouter -m deepseek/deepseek-v4-flash ``` On opencode, `add` sets the chosen model as the default and `remove` clears it @@ -145,8 +145,8 @@ quarter of the context for you. It can't exceed the context window. ```sh outfit list outfit show [--harness ] # show what the harness has configured -outfit add --provider [--model-family ] [--model ] [--alias ] [--context ] [--output ] [--base-url ] -outfit remove --provider [--model-family ] [--model ] +outfit add --provider [--model ] [--alias ] [--context ] [--output ] [--base-url ] +outfit remove --provider [--model ] [--alias ] outfit apply [path] [--output ] # apply an Outfit file or directory (default ./Outfit) outfit unapply [path] # remove what an Outfit file selects outfit alias [path] [-n ] [-l] # name an Outfit; -l lists them @@ -163,7 +163,7 @@ outfit remote [path] # (deploy sets what it serves, from the Outfit) ``` -Short flags: `-p` (provider), `-f` (model-family), `-m` (model), `-a` (alias), `-c` (context), `-o` (output), `-u` (base-url), `-H` (harness), `-O` (outfit), and under `alias`: `-n` (name), `-l` (list), `-F` (force). +Short flags: `-p` (provider), `-m` (model), `-a` (alias), `-c` (context), `-o` (output), `-u` (base-url), `-H` (harness), `-O` (outfit), and under `alias`: `-n` (name), `-l` (list), `-F` (force). Anywhere a `[path]` appears above you can put a name registered with [`outfit alias`](#aliases) instead. @@ -185,7 +185,7 @@ is also supported. The harness is chosen at runtime — never baked into an `Out file — so the same selection works for either. ```sh -outfit add -p ollama -f llama --harness pi # this command only +outfit add -p ollama -m llama3.2 --harness pi # this command only outfit harness --set pi # make Pi the default for future commands outfit harness # launch the active harness (forwards trailing args) outfit harness -O # apply ./Outfit, then launch the harness @@ -207,8 +207,7 @@ your coding agent? Drop an `Outfit` in your project: ```dockerfile # Outfit PROVIDER openrouter -FAMILY deepseek-v4 -MODEL deepseek/deepseek-v4-pro # optional; the provider-native model ref +MODEL deepseek/deepseek-v4-pro # the provider-native model ref ALIAS deepseek # optional; friendly name for the model CONTEXT 128k # optional; context window OUTPUT 32k # optional; max output tokens @@ -348,7 +347,7 @@ with a ready-to-apply `Outfit`: ## Adding providers and models Everything `outfit` knows lives in `internal/catalog/providers.yaml`. Add a -provider, a model family, or a new model there and rebuild — no Go required. The +provider there and rebuild — no Go required. The file is commented with the schema. Don't want to rebuild? Write the catalogue out with diff --git a/cmd/outfit/apply_test.go b/cmd/outfit/apply_test.go index 28cbb0f1..bc682469 100644 --- a/cmd/outfit/apply_test.go +++ b/cmd/outfit/apply_test.go @@ -187,16 +187,15 @@ func TestCmdUnapply_DefaultFileMissing(t *testing.T) { } } -// TestCmdUnapply_FamilyRoundTrip checks the realest inverse case: applying an -// Outfit that names a FAMILY then unapplying the same file clears every model -// the family added, going through unapply's family-resolution branch. -func TestCmdUnapply_FamilyRoundTrip(t *testing.T) { +// TestCmdUnapply_RoundTrip checks the realest inverse case: applying an Outfit +// that names a MODEL then unapplying the same file clears the model it added. +func TestCmdUnapply_RoundTrip(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") outfitFile := filepath.Join(dir, "Outfit") - mustWrite(t, outfitFile, "PROVIDER openrouter\nFAMILY deepseek-v4\n") + mustWrite(t, outfitFile, "PROVIDER openrouter\nMODEL deepseek/deepseek-v4-flash\n") captureStdout(t, func() { if err := cmdApply([]string{outfitFile}); err != nil { t.Fatalf("cmdApply: %v", err) @@ -215,7 +214,7 @@ func TestCmdUnapply_FamilyRoundTrip(t *testing.T) { m := readConfigMap(t, filepath.Join(dir, "opencode", "opencode.json")) or, _ := m["provider"].(map[string]any)["openrouter"].(map[string]any) if models, ok := or["models"].(map[string]any); ok && len(models) != 0 { - t.Errorf("family models should have been removed, still have: %v", models) + t.Errorf("model should have been removed, still have: %v", models) } } @@ -301,7 +300,7 @@ func TestCmdApply_EndToEnd(t *testing.T) { t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") outfitFile := filepath.Join(dir, "Outfit") - mustWrite(t, outfitFile, "PROVIDER openrouter\nFAMILY deepseek-v4\n") + mustWrite(t, outfitFile, "PROVIDER openrouter\nMODEL deepseek/deepseek-v4-flash\n") out := captureStdout(t, func() { if err := cmdApply([]string{outfitFile}); err != nil { @@ -337,7 +336,7 @@ func TestCmdExport_RoundTripsWithApply(t *testing.T) { // Seed config via apply, then export it back out. outfitFile := filepath.Join(dir, "Outfit") - mustWrite(t, outfitFile, "PROVIDER openrouter\nFAMILY deepseek-v4\n") + mustWrite(t, outfitFile, "PROVIDER openrouter\nMODEL deepseek/deepseek-v4-flash\n") captureStdout(t, func() { if err := cmdApply([]string{outfitFile}); err != nil { t.Fatalf("cmdApply: %v", err) @@ -349,8 +348,7 @@ func TestCmdExport_RoundTripsWithApply(t *testing.T) { t.Fatalf("cmdExport: %v", err) } }) - // The family's models match deepseek-v4, so export should name the family. - if !strings.Contains(out, "PROVIDER openrouter") || !strings.Contains(out, "FAMILY deepseek-v4") { + if !strings.Contains(out, "PROVIDER openrouter") || !strings.Contains(out, "MODEL deepseek/deepseek-v4-flash") { t.Errorf("unexpected export:\n%s", out) } @@ -367,10 +365,9 @@ func TestCmdExport_NoProviders(t *testing.T) { } } -// TestCmdExport_ModelOnlyFallsBackToModel covers a provider whose configured -// models match no known family (a bare llama.cpp label): export should still -// produce a valid Outfit naming the MODEL. -func TestCmdExport_ModelOnlyFallsBackToModel(t *testing.T) { +// TestCmdExport_ModelOnly covers a provider configured with a bare model label +// (a llama.cpp model): export should produce a valid Outfit naming the MODEL. +func TestCmdExport_ModelOnly(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) @@ -400,45 +397,6 @@ func TestCmdExport_ModelOnlyFallsBackToModel(t *testing.T) { } } -// TestCmdExport_FamilyPlusNonDefaultModel checks that when the default model is -// not the family's own default, export keeps both the FAMILY and the MODEL. -func TestCmdExport_FamilyPlusNonDefaultModel(t *testing.T) { - dir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", dir) - t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") - - cat, _ := catalog.Load() - fam := cat.Providers["openrouter"].Families["deepseek-v4"] - // Find a model in the family that is not its default. - var nonDefault string - for _, k := range fam.ModelKeys() { - if k != fam.DefaultModel { - nonDefault = k - break - } - } - if nonDefault == "" { - t.Skip("family has no non-default model to exercise this path") - } - - outfitFile := filepath.Join(dir, "Outfit") - mustWrite(t, outfitFile, "PROVIDER openrouter\nFAMILY deepseek-v4\nMODEL "+nonDefault+"\n") - captureStdout(t, func() { - if err := cmdApply([]string{outfitFile}); err != nil { - t.Fatalf("cmdApply: %v", err) - } - }) - - out := captureStdout(t, func() { - if err := cmdExport(nil); err != nil { - t.Fatalf("cmdExport: %v", err) - } - }) - if !strings.Contains(out, "FAMILY deepseek-v4") || !strings.Contains(out, "MODEL "+nonDefault) { - t.Errorf("expected both FAMILY and the non-default MODEL:\n%s", out) - } -} - // TestCmdExport_MultipleProviders covers provider selection when several are // configured: without a hint it errors, with -p it exports the chosen one, and // an unknown -p errors. @@ -450,7 +408,7 @@ func TestCmdExport_MultipleProviders(t *testing.T) { path, _ := opencode.ResolveConfigFile() cat, _ := catalog.Load() for _, id := range []string{"ollama", "llamacpp"} { - block, _, err := catalog.BuildProviderBlock(id, cat.Providers[id], "", "label-"+id, "", noEnv) + block, _, err := catalog.BuildProviderBlock(id, cat.Providers[id], "label-"+id, "", noEnv) if err != nil { t.Fatal(err) } @@ -482,7 +440,7 @@ func TestCmdApply_BadOutfitContent(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", dir) bad := filepath.Join(dir, "Outfit") - mustWrite(t, bad, "FAMILY llama\n") // no PROVIDER + mustWrite(t, bad, "MODEL llama3.2\n") // no PROVIDER if err := cmdApply([]string{bad}); err == nil { t.Error("expected error for an Outfit without a PROVIDER") } diff --git a/cmd/outfit/complete.go b/cmd/outfit/complete.go index a78c6382..b2dab8e6 100644 --- a/cmd/outfit/complete.go +++ b/cmd/outfit/complete.go @@ -61,8 +61,6 @@ const ( kindAliasOnly // registered aliases; a path would be meaningless kindHarness kindProvider - kindFamily - kindModel kindShell ) @@ -85,16 +83,17 @@ type command struct { // selectionFlags are the flags add and remove share (see parseSelection). var selectionFlags = []string{ - "--provider", "-p", "--model-family", "-f", "--model", "-m", + "--provider", "-p", "--model", "-m", "--alias", "-a", "--context", "-c", "--output", "-o", "--providers", "--base-url", "-u", "--harness", "-H", } -// selectionValues maps those of them that take a value. +// selectionValues maps those of them that take a value. The model id has no +// static source to complete against — the catalogue no longer enumerates models +// — so --model/-m consumes its value without offering candidates. var selectionValues = map[string]candidateKind{ "--provider": kindProvider, "-p": kindProvider, - "--model-family": kindFamily, "-f": kindFamily, - "--model": kindModel, "-m": kindModel, + "--model": kindNone, "-m": kindNone, "--alias": kindNone, "-a": kindNone, "--context": kindNone, "-c": kindNone, "--output": kindNone, "-o": kindNone, @@ -308,18 +307,6 @@ func candidatesFor(kind candidateKind, words []string) ([]string, string) { return nil, directiveNoFile } return cat.SortedProviderNames(), directiveNoFile - case kindFamily: - p := providerOn(words) - if p == nil { - return nil, directiveNoFile - } - return p.SortedFamilyNames(), directiveNoFile - case kindModel: - p := providerOn(words) - if p == nil { - return nil, directiveNoFile - } - return modelNames(p, flagValue(words, "--model-family", "-f")), directiveNoFile case kindShell: return completionShells, directiveNoFile default: @@ -343,34 +330,6 @@ func loadCatalogFor(words []string) (*catalog.Catalog, error) { return catalog.LoadFrom(catalog.ResolveCatalogPath(flagValue(words, "--providers"))) } -// providerOn returns the provider named by --provider/-p on the command line so -// far, or nil when there is none (or the catalogue will not load). -func providerOn(words []string) *catalog.Provider { - name := flagValue(words, "--provider", "-p") - if name == "" { - return nil - } - cat, err := loadCatalogFor(words) - if err != nil { - return nil - } - return cat.Providers[name] -} - -// modelNames returns the model keys of one family, or of every family when none -// is named. -func modelNames(p *catalog.Provider, family string) []string { - if fam, ok := p.Families[family]; ok { - return fam.ModelKeys() - } - var models []string - for _, name := range p.SortedFamilyNames() { - models = append(models, p.Families[name].ModelKeys()...) - } - sort.Strings(models) - return models -} - // flagValue finds the value already given to one of names, in either the // attached (--flag=value, which bash splits into three words) or detached // (--flag value) form. It returns "" when the flag is not on the line. diff --git a/cmd/outfit/complete_test.go b/cmd/outfit/complete_test.go index 343f8f47..1676e7f1 100644 --- a/cmd/outfit/complete_test.go +++ b/cmd/outfit/complete_test.go @@ -196,12 +196,6 @@ func TestComplete_FlagValues(t *testing.T) { if got, _ := complete(t, "add", "--provider", ""); !hasAll(got, "llamacpp", "openrouter") { t.Errorf("providers missing from %v", got) } - if got, _ := complete(t, "add", "-p", "llamacpp", "-f", ""); len(got) == 0 { - t.Error("no families offered for a named provider") - } - if got, _ := complete(t, "add", "-f", ""); len(got) != 0 { - t.Errorf("families offered with no provider named: %v", got) - } if _, directive := complete(t, "apply", "--providers", ""); directive != directiveFile { t.Errorf("--providers should complete paths, got %q", directive) } @@ -228,38 +222,27 @@ func TestComplete_EqualsForm(t *testing.T) { if got, _ := complete(t, "add", "--provider", "=", ""); !hasAll(got, "llamacpp") { t.Errorf("providers missing from %v", got) } - // A family still resolves against a provider given in that form. - if got, _ := complete(t, "add", "--provider", "=", "llamacpp", "-f", ""); len(got) == 0 { - t.Error("no families offered for a provider given as --provider=") - } } -// TestComplete_ModelValues checks that --model/-m offers the catalogue's model -// keys — every family's when no family is fixed, and just one family's when it -// is. +// TestComplete_ModelValues checks that --model/-m has no static candidates — +// the catalogue no longer enumerates models — but that the flag still consumes +// its value so a following flag completes normally rather than being read as the +// model. func TestComplete_ModelValues(t *testing.T) { isolateConfig(t) - // With a family fixed, only that family's models. - got, directive := complete(t, "add", "-p", "openrouter", "-f", "deepseek-v4", "-m", "") - if !hasAll(got, "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro") { - t.Errorf("family's models missing from %v", got) + // No static model source, so no candidates and no error. + got, directive := complete(t, "add", "-p", "openrouter", "-m", "") + if len(got) != 0 { + t.Errorf("expected no model candidates, got %v", got) } if directive != directiveNoFile { t.Errorf("directive = %q, want %q", directive, directiveNoFile) } - // Without a family, every family's models are offered. - if got, _ := complete(t, "add", "--provider", "openrouter", "--model", ""); len(got) == 0 { - t.Error("no models offered for a provider with no family fixed") - } - // With no provider named at all, there is nothing to draw models from. - if got, _ := complete(t, "add", "-m", ""); len(got) != 0 { - t.Errorf("models offered with no provider: %v", got) - } - // An unknown provider yields no models rather than an error. - if got, _ := complete(t, "add", "-p", "nope", "-m", ""); len(got) != 0 { - t.Errorf("models offered for an unknown provider: %v", got) + // -m consumes its value: the harness flag after it still completes. + if got, _ := complete(t, "add", "-p", "openrouter", "-m", "some-model", "-H", ""); !hasAll(got, "opencode", "pi") { + t.Errorf("a flag after --model did not complete; -m may not be consuming its value: %v", got) } } diff --git a/cmd/outfit/harness_test.go b/cmd/outfit/harness_test.go index e1f4c8d8..f01f5799 100644 --- a/cmd/outfit/harness_test.go +++ b/cmd/outfit/harness_test.go @@ -401,7 +401,7 @@ func TestCmdShow(t *testing.T) { // After an add, show lists the provider, its models, their limits, and the // default model. captureStdout(t, func() { - if err := cmdAdd([]string{"-p", "openrouter", "-f", "deepseek-v4", "-c", "128k"}); err != nil { + if err := cmdAdd([]string{"-p", "openrouter", "-m", "deepseek/deepseek-v4-flash", "-c", "128k"}); err != nil { t.Fatalf("cmdAdd: %v", err) } }) @@ -489,7 +489,7 @@ func TestCmdShow_HarnessOverride(t *testing.T) { // The opencode default is configured... t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") captureStdout(t, func() { - if err := cmdAdd([]string{"-p", "openrouter", "-f", "deepseek-v4"}); err != nil { + if err := cmdAdd([]string{"-p", "openrouter", "-m", "deepseek/deepseek-v4-flash"}); err != nil { t.Fatalf("cmdAdd: %v", err) } }) @@ -521,7 +521,7 @@ func TestCmdShow_PiPopulated(t *testing.T) { // setting, so `show` must list the provider and its models without inventing // a "Default model:" line. captureStdout(t, func() { - if err := cmdAdd([]string{"-H", "pi", "-p", "ollama", "-f", "llama", "-c", "128k"}); err != nil { + if err := cmdAdd([]string{"-H", "pi", "-p", "ollama", "-m", "llama3.2", "-c", "128k"}); err != nil { t.Fatalf("cmdAdd -H pi: %v", err) } }) @@ -547,7 +547,7 @@ func TestCmdAdd_PiHarnessViaFlag(t *testing.T) { t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") out := captureStdout(t, func() { - if err := cmdAdd([]string{"-H", "pi", "-p", "openrouter", "-f", "deepseek-v4", "-c", "128k"}); err != nil { + if err := cmdAdd([]string{"-H", "pi", "-p", "openrouter", "-m", "deepseek/deepseek-v4-flash", "-c", "128k"}); err != nil { t.Fatalf("cmdAdd: %v", err) } }) @@ -584,7 +584,7 @@ func TestCmdAdd_PiHarnessViaEnvAndPreference(t *testing.T) { // Via OUTFIT_HARNESS. t.Setenv("OUTFIT_HARNESS", "pi") captureStdout(t, func() { - if err := cmdAdd([]string{"-p", "ollama", "-f", "llama"}); err != nil { + if err := cmdAdd([]string{"-p", "ollama", "-m", "llama3.2"}); err != nil { t.Fatalf("cmdAdd via env: %v", err) } }) @@ -609,7 +609,7 @@ func TestCmdAdd_PiHarnessViaEnvAndPreference(t *testing.T) { func TestCmdAdd_PiUnsupportedProvider(t *testing.T) { isolateConfig(t) - if err := cmdAdd([]string{"-H", "pi", "-p", "amazon-bedrock", "-f", "claude"}); err == nil { + if err := cmdAdd([]string{"-H", "pi", "-p", "amazon-bedrock", "-m", "anthropic.claude-3-5-sonnet"}); err == nil { t.Error("expected error adding a Pi-unsupported provider") } } @@ -619,7 +619,7 @@ func TestCmdExportRemove_PiRoundTrip(t *testing.T) { t.Setenv("OUTFIT_HARNESS", "pi") captureStdout(t, func() { - if err := cmdAdd([]string{"-p", "ollama", "-f", "llama", "-c", "200000"}); err != nil { + if err := cmdAdd([]string{"-p", "ollama", "-m", "llama3.2", "-c", "200000"}); err != nil { t.Fatalf("cmdAdd: %v", err) } }) @@ -629,7 +629,7 @@ func TestCmdExportRemove_PiRoundTrip(t *testing.T) { t.Fatalf("cmdExport: %v", err) } }) - if !strings.Contains(out, "PROVIDER ollama") || !strings.Contains(out, "FAMILY llama") { + if !strings.Contains(out, "PROVIDER ollama") || !strings.Contains(out, "MODEL llama3.2") { t.Errorf("unexpected Pi export:\n%s", out) } if !strings.Contains(out, "CONTEXT 200000") { diff --git a/cmd/outfit/main.go b/cmd/outfit/main.go index 4b47f74f..91493d23 100644 --- a/cmd/outfit/main.go +++ b/cmd/outfit/main.go @@ -4,7 +4,7 @@ // --harness/-H or OUTFIT_HARNESS, or a stored default set via `outfit harness // --set`, and defaults to opencode. // -// Providers and model families are defined in providers.yaml, which is embedded +// Providers are defined in providers.yaml, which is embedded // into the binary at build time. For opencode the config is parsed as JSONC so // comments and existing settings outside the managed provider block are // preserved; for Pi the managed provider is merged into ~/.pi/agent/models.json. @@ -13,8 +13,8 @@ // // outfit list // outfit show [--harness ] # show what the harness has configured -// outfit add --provider [--model-family ] [--model ] -// outfit remove --provider [--model-family ] [--model ] +// outfit add --provider [--model ] [--alias ] +// outfit remove --provider [--model ] [--alias ] // outfit apply [path] # apply an Outfit file (defaults to ./Outfit) // outfit unapply [path] # remove what an Outfit file selects // outfit alias [path] [-n ] # name an Outfit; -l lists them @@ -28,7 +28,7 @@ // outfit remote start|stop|status|deploy # control the remote GPU inference // # instance (deploy sets what it serves) // -// Short flags: -p (provider), -f (model-family), -m (model), -a (alias), +// Short flags: -p (provider), -m (model), -a (alias), // -c (context), -o (output), -u (base-url), -H (harness), -O (outfit). // // The API base URL can be overridden for any provider with --base-url/-u or the @@ -135,8 +135,8 @@ func usage() { Usage: outfit list outfit show [--harness ] (providers/models the harness has configured) - outfit add --provider [--model-family ] [--model ] [--context ] [--output ] - outfit remove --provider [--model-family ] [--model ] + outfit add --provider [--model ] [--alias ] [--context ] [--output ] + outfit remove --provider [--model ] [--alias ] outfit apply [path] [--output ] (defaults to ./Outfit) outfit unapply [path] (remove what an Outfit selects) outfit alias [path] [-n ] [-l] (name an Outfit; -l lists them) @@ -154,7 +154,6 @@ Usage: Flags: -p, --provider provider name (see `+"`outfit list`"+`) - -f, --model-family model family to add or remove -m, --model model id to set as default / to add or remove -a, --alias friendly name for the model (the harness key); for llama.cpp the server's reported model name under serve @@ -173,10 +172,10 @@ Flags: (or set OUTFIT_PROVIDERS) add: deep-merges the provider into the active harness's config, preserving - everything else. Specify a family and/or an explicit model. --context sets - the model's context window; --output sets the max output tokens (opencode - requires it alongside a context, defaulting to a quarter of the context). -remove: removes the provider, or just the named models when a family/model is + everything else. Specify a model (or an alias). --context sets the model's + context window; --output sets the max output tokens (opencode requires it + alongside a context, defaulting to a quarter of the context). +remove: removes the provider, or just the named model when a model/alias is given. apply: applies an Outfit file — a declarative, Dockerfile-style description of one provider selection — as if you had run the equivalent add. @@ -230,8 +229,6 @@ func parseSelection(name string, args []string) (outfit.Selection, string, error fs := flag.NewFlagSet(name, flag.ContinueOnError) fs.StringVar(&s.Provider, "provider", "", "provider name") fs.StringVar(&s.Provider, "p", "", "provider name (shorthand)") - fs.StringVar(&s.Family, "model-family", "", "model family") - 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.Alias, "alias", "", "friendly name for the model (overrides the harness key)") @@ -273,8 +270,8 @@ func cmdAdd(args []string) error { // a `.env` holding its API key belongs; it is empty when no Outfit is involved // (an `outfit add` from flags), leaving only the process environment. func applySelection(sel outfit.Selection, h harness.Harness, envDir string) error { - if sel.Family == "" && sel.Model == "" && sel.Alias == "" { - return fmt.Errorf("a provider selection needs a model family, a model, or an alias") + if sel.Model == "" && sel.Alias == "" { + return fmt.Errorf("a provider selection needs a model or an alias") } cat, err := catalog.LoadFrom(catalog.ResolveCatalogPath(sel.Providers)) @@ -314,11 +311,7 @@ func applySelection(sel outfit.Selection, h harness.Harness, envDir string) erro } fmt.Printf("Updated %s\n\n", summary.ConfigPath) - line := fmt.Sprintf("Configured provider %q", sel.Provider) - if sel.Family != "" { - line += fmt.Sprintf(" with family %q", sel.Family) - } - fmt.Println(line + ".") + fmt.Printf("Configured provider %q.\n", sel.Provider) if summary.DefaultModel != "" { fmt.Printf("Default model: %s\n", summary.DefaultModel) } @@ -854,27 +847,19 @@ func cmdExport(args []string) error { cat, catErr := catalog.LoadFrom(catalog.ResolveCatalogPath(providers)) - // Prefer naming a family when the configured models match one, and drop a - // MODEL line that would only restate that family's default. + // Drop a baseURL that only restates the catalogue's default — keep it only + // when it is a genuine override worth recording. if catErr == nil { if p, ok := cat.Providers[provider]; ok { - if fam := catalog.MatchFamily(p, st.ModelKeys); fam != "" { - sel.Family = fam - if p.Families[fam].DefaultModel == sel.Model { - sel.Model = "" - } - } - // Drop a baseURL that only restates the catalogue's default — keep it - // only when it is a genuine override worth recording. if def, _ := p.Options["baseURL"].(string); sel.BaseURL == def { sel.BaseURL = "" } } } - // Ensure the Outfit still selects something if we recognised neither a - // family nor a default model. - if sel.Family == "" && sel.Model == "" && len(st.ModelKeys) > 0 { + // Ensure the Outfit still selects a model when the default model did not + // name one for this provider. + if sel.Model == "" && len(st.ModelKeys) > 0 { sel.Model = st.ModelKeys[0] } @@ -894,10 +879,7 @@ func cmdExport(args []string) error { // to differ), so export never invents or guesses a value. func exportLimit(sel outfit.Selection, st harness.ProviderState, values map[string]int) string { var keys []string - switch { - case sel.Family != "": - keys = st.ModelKeys // a matched family covers exactly these models - case sel.Model != "": + if sel.Model != "" { keys = []string{sel.Model} } distinct := map[int]bool{} @@ -932,12 +914,7 @@ func cmdRemove(args []string) error { // selection (from flags or an Outfit file) and hand it here. It is the inverse // of applySelection. func removeSelection(sel outfit.Selection, h harness.Harness) error { - cat, err := catalog.LoadFrom(catalog.ResolveCatalogPath(sel.Providers)) - if err != nil { - return err - } - - // Resolve the model keys to remove. With no family/model, the whole + // Resolve the model keys to remove. With no model or alias, the whole // provider is removed. var modelKeys []string if sel.Alias != "" { @@ -946,17 +923,6 @@ func removeSelection(sel outfit.Selection, h harness.Harness) error { if sel.Model != "" { modelKeys = append(modelKeys, sel.Model) } - if sel.Family != "" { - p, ok := cat.Providers[sel.Provider] - if !ok { - return fmt.Errorf("unknown provider %q (see `outfit list`)", sel.Provider) - } - fam, ok := p.Families[sel.Family] - if !ok { - return fmt.Errorf("provider %q has no model family %q (see `outfit list`)", sel.Provider, sel.Family) - } - modelKeys = append(modelKeys, fam.ModelKeys()...) - } configFile, err := h.ConfigPath() if err != nil { @@ -1329,10 +1295,6 @@ func cmdList(args []string) error { harnesses = "opencode, pi" } fmt.Fprintf(&b, " harnesses: %s\n", harnesses) - for _, f := range p.SortedFamilyNames() { - fam := p.Families[f] - fmt.Fprintf(&b, " family %s — %s (default: %s)\n", f, fam.Description, fam.DefaultModel) - } } fmt.Print(b.String()) return nil diff --git a/cmd/outfit/main_test.go b/cmd/outfit/main_test.go index 4dbfdf30..85a46915 100644 --- a/cmd/outfit/main_test.go +++ b/cmd/outfit/main_test.go @@ -123,25 +123,25 @@ func TestVersionFlag(t *testing.T) { func TestParseSelection(t *testing.T) { // Long flags. - s, _, err := parseSelection("add", []string{"--provider", "openrouter", "--model-family", "deepseek-v4", "--model", "m"}) + s, _, err := parseSelection("add", []string{"--provider", "openrouter", "--model", "m", "--alias", "friendly"}) if err != nil { t.Fatal(err) } - if s.Provider != "openrouter" || s.Family != "deepseek-v4" || s.Model != "m" { + if s.Provider != "openrouter" || s.Model != "m" || s.Alias != "friendly" { t.Errorf("long flags parsed wrong: %+v", s) } // Short flags. - s, _, err = parseSelection("add", []string{"-p", "ollama", "-f", "llama", "-m", "x"}) + s, _, err = parseSelection("add", []string{"-p", "ollama", "-m", "x", "-a", "y"}) if err != nil { t.Fatal(err) } - if s.Provider != "ollama" || s.Family != "llama" || s.Model != "x" { + if s.Provider != "ollama" || s.Model != "x" || s.Alias != "y" { t.Errorf("short flags parsed wrong: %+v", s) } // Missing provider. - if _, _, err := parseSelection("add", []string{"-f", "llama"}); err == nil { + if _, _, err := parseSelection("add", []string{"-m", "llama3.2"}); err == nil { t.Error("expected error when --provider is missing") } @@ -195,8 +195,8 @@ func TestParseSelection(t *testing.T) { // Harness flag is returned separately and never leaks into the Selection. for _, args := range [][]string{ - {"-p", "ollama", "-f", "llama", "--harness", "pi"}, - {"-p", "ollama", "-f", "llama", "-H", "pi"}, + {"-p", "ollama", "-m", "llama3.2", "--harness", "pi"}, + {"-p", "ollama", "-m", "llama3.2", "-H", "pi"}, } { _, h, err := parseSelection("add", args) if err != nil { @@ -214,7 +214,7 @@ func TestCmdAdd_ContextSize(t *testing.T) { t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") out := captureStdout(t, func() { - if err := cmdAdd([]string{"-p", "openrouter", "-f", "deepseek-v4", "-c", "128k"}); err != nil { + if err := cmdAdd([]string{"-p", "openrouter", "-m", "deepseek/deepseek-v4-flash", "-c", "128k"}); err != nil { t.Fatalf("cmdAdd: %v", err) } }) @@ -252,7 +252,7 @@ func TestCmdAdd_OutputSize(t *testing.T) { t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") out := captureStdout(t, func() { - if err := cmdAdd([]string{"-p", "openrouter", "-f", "deepseek-v4", "-c", "128k", "-o", "64k"}); err != nil { + if err := cmdAdd([]string{"-p", "openrouter", "-m", "deepseek/deepseek-v4-flash", "-c", "128k", "-o", "64k"}); err != nil { t.Fatalf("cmdAdd: %v", err) } }) @@ -276,10 +276,10 @@ func TestCmdAdd_OutputErrors(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") - if err := cmdAdd([]string{"-p", "openrouter", "-f", "deepseek-v4", "-o", "32k"}); err == nil { + if err := cmdAdd([]string{"-p", "openrouter", "-m", "deepseek/deepseek-v4-flash", "-o", "32k"}); err == nil { t.Error("expected error for --output without --context") } - if err := cmdAdd([]string{"-p", "openrouter", "-f", "deepseek-v4", "-c", "8k", "-o", "32k"}); err == nil { + if err := cmdAdd([]string{"-p", "openrouter", "-m", "deepseek/deepseek-v4-flash", "-c", "8k", "-o", "32k"}); err == nil { t.Error("expected error for an output limit exceeding the context") } } @@ -287,7 +287,7 @@ func TestCmdAdd_OutputErrors(t *testing.T) { func TestCmdAdd_ContextSizeInvalid(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") - if err := cmdAdd([]string{"-p", "openrouter", "-f", "deepseek-v4", "-c", "not-a-size"}); err == nil { + if err := cmdAdd([]string{"-p", "openrouter", "-m", "deepseek/deepseek-v4-flash", "-c", "not-a-size"}); err == nil { t.Error("expected error for an unparseable context size") } } @@ -298,7 +298,7 @@ func TestCmdAdd_EndToEnd(t *testing.T) { t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") out := captureStdout(t, func() { - if err := cmdAdd([]string{"-p", "openrouter", "-f", "deepseek-v4"}); err != nil { + if err := cmdAdd([]string{"-p", "openrouter", "-m", "deepseek/deepseek-v4-flash"}); err != nil { t.Fatalf("cmdAdd: %v", err) } }) @@ -322,7 +322,7 @@ func TestCmdAdd_BaseURLOverride(t *testing.T) { t.Setenv("OPENAI_API_KEY", "sk-test") out := captureStdout(t, func() { - if err := cmdAdd([]string{"-p", "openai-compatible", "-f", "gpt", "-u", "https://proxy.example/v1"}); err != nil { + if err := cmdAdd([]string{"-p", "openai-compatible", "-m", "gpt-4o", "-u", "https://proxy.example/v1"}); err != nil { t.Fatalf("cmdAdd: %v", err) } }) @@ -343,9 +343,9 @@ func TestCmdAdd_Errors(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) if err := cmdAdd([]string{"-p", "openrouter"}); err == nil { - t.Error("expected error when neither family nor model given") + t.Error("expected error when neither model nor alias given") } - if err := cmdAdd([]string{"-p", "bogus", "-f", "x"}); err == nil { + if err := cmdAdd([]string{"-p", "bogus", "-m", "x"}); err == nil { t.Error("expected error for unknown provider") } } @@ -358,9 +358,9 @@ func TestCmdRemove_EndToEnd(t *testing.T) { t.Fatal(err) } - // Seed a family, then remove the whole provider via the CLI. + // Seed a model, then remove the whole provider via the CLI. cat, _ := catalog.Load() - block, dm, _ := catalog.BuildProviderBlock("openrouter", cat.Providers["openrouter"], "deepseek-v4", "", "", envMap(map[string]string{ + block, dm, _ := catalog.BuildProviderBlock("openrouter", cat.Providers["openrouter"], "deepseek/deepseek-v4-flash", "", envMap(map[string]string{ "DEEPSEEK_API_KEY": "sk-or-v1-x", })) if err := opencode.WriteConfig(path, "openrouter", block, dm); err != nil { @@ -381,44 +381,39 @@ func TestCmdRemove_EndToEnd(t *testing.T) { } } -func TestCmdRemove_FamilyAndNoOp(t *testing.T) { +func TestCmdRemove_ModelAndNoOp(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) path, _ := opencode.ResolveConfigFile() cat, _ := catalog.Load() - block, dm, _ := catalog.BuildProviderBlock("openrouter", cat.Providers["openrouter"], "deepseek-v4", "", "", envMap(map[string]string{ + block, dm, _ := catalog.BuildProviderBlock("openrouter", cat.Providers["openrouter"], "deepseek/deepseek-v4-flash", "", envMap(map[string]string{ "DEEPSEEK_API_KEY": "sk-or-v1-x", })) opencode.WriteConfig(path, "openrouter", block, dm) - // Removing the family should clear all its models (and the default model, - // which pointed at one of them). + // Removing the named model should clear it (and the default model, which + // pointed at it). captureStdout(t, func() { - if err := cmdRemove([]string{"-p", "openrouter", "-f", "deepseek-v4"}); err != nil { - t.Fatalf("cmdRemove family: %v", err) + if err := cmdRemove([]string{"-p", "openrouter", "-m", "deepseek/deepseek-v4-flash"}); err != nil { + t.Fatalf("cmdRemove model: %v", err) } }) m := readConfigMap(t, path) or := m["provider"].(map[string]any)["openrouter"].(map[string]any) if models, ok := or["models"].(map[string]any); ok && len(models) != 0 { - t.Errorf("family models not removed: %v", models) + t.Errorf("model not removed: %v", models) } // A second removal is a no-op. out := captureStdout(t, func() { - if err := cmdRemove([]string{"-p", "openrouter", "-f", "deepseek-v4"}); err != nil { + if err := cmdRemove([]string{"-p", "openrouter", "-m", "deepseek/deepseek-v4-flash"}); err != nil { t.Fatalf("cmdRemove no-op: %v", err) } }) if !strings.Contains(out, "Nothing to remove") { t.Errorf("expected no-op message, got:\n%s", out) } - - // Unknown family is an error. - if err := cmdRemove([]string{"-p", "openrouter", "-f", "nope"}); err == nil { - t.Error("expected error for unknown family") - } } func TestCmdList_ProvidersOverride(t *testing.T) { @@ -427,11 +422,9 @@ func TestCmdList_ProvidersOverride(t *testing.T) { os.WriteFile(path, []byte(`providers: mine: description: My custom provider - families: - base: - defaultModel: m1 - models: - m1: {name: Model One} + npm: "@ai-sdk/openai-compatible" + options: + baseURL: http://localhost:9999/v1 `), 0o600) // Via the --providers flag. @@ -462,11 +455,15 @@ func TestCmdList(t *testing.T) { t.Fatalf("cmdList: %v", err) } }) - for _, want := range []string{"openrouter", "amazon-bedrock", "family", "default:"} { + for _, want := range []string{"openrouter", "amazon-bedrock", "api key", "harnesses"} { if !strings.Contains(out, want) { t.Errorf("list output missing %q:\n%s", want, out) } } + // The catalogue no longer enumerates models, so no family lines appear. + if strings.Contains(out, "family ") { + t.Errorf("list should not print family lines:\n%s", out) + } } func TestCmdInitProviders_EndToEnd(t *testing.T) { diff --git a/cmd/outfit/serve_test.go b/cmd/outfit/serve_test.go index 86c327cf..5c41ca7b 100644 --- a/cmd/outfit/serve_test.go +++ b/cmd/outfit/serve_test.go @@ -204,7 +204,7 @@ func TestCmdServe_DerivesBadContext(t *testing.T) { func TestCmdServe_NoPresetNoModel(t *testing.T) { dir := t.TempDir() outfitPath := filepath.Join(dir, "Outfit") - mustWrite(t, outfitPath, "PROVIDER llamacpp\nFAMILY local\n") + mustWrite(t, outfitPath, "PROVIDER llamacpp\n") if err := cmdServe([]string{"--dry-run", outfitPath}); err == nil { t.Error("expected error when there is neither a PRESET nor a MODEL") } diff --git a/docs/README.md b/docs/README.md index 0c2f0405..306dd105 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,9 +14,9 @@ Four words carry the whole tool: - **Harness** — the coding agent being configured. opencode is the default; Pi is also supported. Chosen at runtime, so the same selection works for either. See [`outfit harness`](commands/harness.md). -- **Provider and family** — what `outfit` can configure, from a built-in +- **Provider** — what `outfit` can configure, from a built-in catalogue: OpenRouter, AWS Bedrock, Ollama, llama.cpp, vLLM, or any - OpenAI-compatible endpoint, each with named model families. See + OpenAI-compatible endpoint. See [`outfit list`](commands/list.md). - **Outfit file** — a small, declarative file (like a `Dockerfile`, but for your agent's model) that captures one selection so you can commit it and diff --git a/docs/commands/add.md b/docs/commands/add.md index e0dfdffc..2b51e30d 100644 --- a/docs/commands/add.md +++ b/docs/commands/add.md @@ -5,29 +5,28 @@ the agent's config — other providers, your theme, even your comments stay exactly where you left them. ```sh -outfit add --provider [--model-family ] [--model ] - [--alias ] [--context ] [--output ] - [--base-url ] +outfit add --provider [--model ] [--alias ] + [--context ] [--output ] [--base-url ] ``` ## Examples ```sh -# A whole family from OpenRouter (key from .env or the environment) -outfit add -p openrouter -f deepseek-v4 +# A model from OpenRouter (key from .env or the environment) +outfit add -p openrouter -m deepseek/deepseek-v4-flash # A local Ollama model (no key required) -outfit add -p ollama -f llama +outfit add -p ollama -m llama3.2 # Claude on AWS Bedrock (uses your AWS credentials) -outfit add -p amazon-bedrock -f claude +outfit add -p amazon-bedrock -m anthropic.claude-3-5-sonnet # Any OpenAI-compatible endpoint OPENAI_API_KEY=sk-... \ outfit add -p openai-compatible -m my-model --base-url https://my-endpoint/v1 -# Pin a specific default model within a family -outfit add -p openrouter -f deepseek-v4 -m deepseek/deepseek-v4-pro +# Pin a specific default model +outfit add -p openrouter -m deepseek/deepseek-v4-pro # Record the context window and cap the output tokens outfit add -p llamacpp -m my-model -c 128k -o 32k @@ -38,7 +37,6 @@ outfit add -p llamacpp -m my-model -c 128k -o 32k | Flag | Meaning | | ---- | ------- | | `-p`, `--provider` | Provider name — see [`outfit list`](list.md). Required. | -| `-f`, `--model-family` | Family to add; brings all its models, its default becomes the default model | | `-m`, `--model` | The provider-native model id to add or pin as the default | | `-a`, `--alias` | Friendly name for the model — the key your agent shows | | `-c`, `--context` | Context window; `128k`, `1m`, `200000`, even `128 K tokens` all work | @@ -49,8 +47,7 @@ outfit add -p llamacpp -m my-model -c 128k -o 32k ## Notes -- You need at least one of `--model-family`, `--model`, or `--alias` alongside - the provider. +- You need at least one of `--model` or `--alias` alongside the provider. - API keys are read from a `.env` beside the `Outfit` — or, for `outfit add`, which has no Outfit, from a `.env` in the current directory — then your environment, and diff --git a/docs/commands/completion.md b/docs/commands/completion.md index 62b29d27..4e29ab08 100644 --- a/docs/commands/completion.md +++ b/docs/commands/completion.md @@ -16,7 +16,6 @@ TAB completes commands, flags, and — context-aware — the values that follow them: - provider names after `-p` (honouring a `--providers` override on the line) -- family and model names, scoped to the provider you've already typed - harness names after `-H`, `--harness`, or `--set` - your [registered aliases](alias.md) wherever an Outfit path goes — `outfit unalias ` offers exactly the names you have diff --git a/docs/commands/export.md b/docs/commands/export.md index 041f9e4d..6f34fdc4 100644 --- a/docs/commands/export.md +++ b/docs/commands/export.md @@ -25,8 +25,7 @@ outfit export -p openrouter > Outfit ## Notes -- Where the configured models match a known family, export names the `FAMILY`; - otherwise it writes the specific `MODEL`. +- Export names the configured `MODEL` directly. - It writes canonical UPPERCASE keywords, and records `CONTEXT`/`OUTPUT` only when the exported models agree on a value — it never guesses. - Secrets are never exported; keys stay in your `.env` or environment. diff --git a/docs/commands/init-providers.md b/docs/commands/init-providers.md index 01ccfb64..20efd7ef 100644 --- a/docs/commands/init-providers.md +++ b/docs/commands/init-providers.md @@ -1,7 +1,7 @@ # outfit init-providers Write the built-in provider catalogue out as a file you can edit — the starting -point for adding your own providers, families, or models without rebuilding. +point for adding your own providers without rebuilding. ```sh outfit init-providers # writes ./providers.yaml @@ -26,8 +26,8 @@ OUTFIT_PROVIDERS=./providers.yaml outfit list - It refuses to overwrite an existing file unless `--force` is given, so a stray run can't destroy a catalogue you've been editing. -- The written file is commented with the schema — providers, families, key - environment variables, and per-harness settings are all data, not code. +- The written file is commented with the schema — providers, key environment + variables, endpoints, and per-harness settings are all data, not code. ## See also diff --git a/docs/commands/list.md b/docs/commands/list.md index 7b3b4fcf..f38f7afd 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -1,8 +1,7 @@ # outfit list Show the catalogue: every provider `outfit` can configure, the API key each one -needs (if any), which harnesses support it, and its model families with their -default models. +needs (if any), and which harnesses support it. ```sh outfit list diff --git a/docs/commands/remove.md b/docs/commands/remove.md index 1b6b9780..ad91003b 100644 --- a/docs/commands/remove.md +++ b/docs/commands/remove.md @@ -4,7 +4,7 @@ Take a provider back out of your agent's config — or just some of its models. The inverse of [`outfit add`](add.md); everything else in the config stays put. ```sh -outfit remove --provider [--model-family ] [--model ] [--alias ] +outfit remove --provider [--model ] [--alias ] ``` ## Examples @@ -13,11 +13,8 @@ outfit remove --provider [--model-family ] [--model ] [--alia # Remove a provider entirely outfit remove -p ollama -# Drop one family's models, keep the provider's others -outfit remove -p openrouter -f deepseek-v4 - -# Remove a single model -outfit remove -p openrouter -m deepseek/deepseek-v4-pro +# Drop one model, keep the provider's others +outfit remove -p openrouter -m deepseek/deepseek-v4-flash ``` ## Flags @@ -25,7 +22,6 @@ outfit remove -p openrouter -m deepseek/deepseek-v4-pro | Flag | Meaning | | ---- | ------- | | `-p`, `--provider` | Provider to remove from. Required. | -| `-f`, `--model-family` | Remove this family's models | | `-m`, `--model` | Remove this model | | `-a`, `--alias` | Remove the model stored under this alias | | `-H`, `--harness` | Which harness to configure (or set `OUTFIT_HARNESS`) | @@ -33,7 +29,7 @@ outfit remove -p openrouter -m deepseek/deepseek-v4-pro ## Notes -- With no family, model, or alias, the whole provider goes. +- With no model or alias, the whole provider goes. - If the agent's default model pointed at something you removed, it is cleared too. - Removing something that isn't there is not an error — `outfit` just tells you diff --git a/docs/getting-started.md b/docs/getting-started.md index d3db26be..c7fbc19f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,23 +16,23 @@ brew install lucinate-ai/tap/outfit outfit list ``` -That's the catalogue: every provider, the API key it needs (if any), and its -model families. +That's the catalogue: every provider, the API key it needs (if any), and which +harnesses support it. ## 3. Dress your agent -Pick a provider and family. For a hosted one, drop the key in a `.env` or +Pick a provider and model. For a hosted one, drop the key in a `.env` or export it first: ```sh echo 'DEEPSEEK_API_KEY=sk-or-v1-...' > .env -outfit add -p openrouter -f deepseek-v4 +outfit add -p openrouter -m deepseek/deepseek-v4-flash ``` Or go local — no key needed: ```sh -outfit add -p ollama -f llama +outfit add -p ollama -m llama3.2 ``` Your agent's existing config survives — `outfit` merges the provider in, @@ -56,7 +56,7 @@ in your project: ```dockerfile # Outfit PROVIDER openrouter -FAMILY deepseek-v4 +MODEL deepseek/deepseek-v4-pro ``` Then: diff --git a/docs/outfit-file.md b/docs/outfit-file.md index 1bf59d22..5e9a6d17 100644 --- a/docs/outfit-file.md +++ b/docs/outfit-file.md @@ -1,15 +1,14 @@ # The `Outfit` file An **Outfit** is a small, declarative file that captures one provider -selection — which provider, and which model family and/or model — so you can +selection — which provider, and which model — so you can apply it with a single command instead of remembering flags. Think of it like a `Dockerfile`, but for pointing your coding agent at a model. ```dockerfile # Outfit — point your coding agent at one provider PROVIDER openrouter -FAMILY deepseek-v4 -MODEL deepseek/deepseek-v4-pro # optional; the provider-native model ref +MODEL deepseek/deepseek-v4-pro # the provider-native model ref ALIAS deepseek # optional; friendly name for the model CONTEXT 128k # optional; context window OUTPUT 32k # optional; max output tokens @@ -75,9 +74,8 @@ One instruction per line: a keyword followed by a single value. | Keyword | Required? | Maps to | Example | | ---------- | -------------------------- | -------------- | ------------------------------ | | `PROVIDER` | yes | `--provider` | `PROVIDER openrouter` | -| `FAMILY` | one of `FAMILY`/`MODEL`/`ALIAS` | `--model-family` | `FAMILY deepseek-v4` | -| `MODEL` | one of `FAMILY`/`MODEL`/`ALIAS` | `--model` | `MODEL deepseek/deepseek-v4-pro` | -| `ALIAS` | one of `FAMILY`/`MODEL`/`ALIAS` | `--alias` | `ALIAS deepseek` | +| `MODEL` | one of `MODEL`/`ALIAS` | `--model` | `MODEL deepseek/deepseek-v4-pro` | +| `ALIAS` | one of `MODEL`/`ALIAS` | `--alias` | `ALIAS deepseek` | | `CONTEXT` | no | `--context` | `CONTEXT 128k` | | `OUTPUT` | no | `--output` | `OUTPUT 32k` | | `BASEURL` | no | `--base-url` | `BASEURL https://gateway/v1` | @@ -88,9 +86,8 @@ Rules: - An Outfit describes **exactly one provider**. `PROVIDER` is required and may appear only once; so may every other keyword. -- You need **at least one** of `FAMILY`, `MODEL`, or `ALIAS`. Give a `FAMILY` to - add all of that family's models; give a `MODEL` to add or pin a specific one; - give both to add the family but make `MODEL` the default. +- You need **at least one** of `MODEL` or `ALIAS`. Give a `MODEL` to add a + specific model; give an `ALIAS` to name it. - `MODEL` is the reference the **provider itself** understands: an OpenRouter/Bedrock model id, an Ollama name, or — for llama.cpp — a Hugging Face repo (`org/model:quant`) or a path to a `.gguf`. @@ -115,7 +112,7 @@ Rules: - **Comments** start with `#`, either on their own line or at the end of a line. Blank lines are ignored. -To see the available providers, families, and models, run `outfit list`. +To see the available providers, run `outfit list`. ## Examples @@ -128,12 +125,12 @@ PROVIDER llamacpp ALIAS qwen3.6-35b-a3b ``` -A whole model family from OpenRouter (its key comes from your `.env` or +A single model from OpenRouter (its key comes from your `.env` or environment, exactly as with `outfit add`): ```dockerfile PROVIDER openrouter -FAMILY deepseek-v4 +MODEL deepseek/deepseek-v4-pro ``` Any OpenAI-compatible endpoint, with a single pinned model: diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 35df652b..a20eb5d4 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -43,11 +43,10 @@ type Provider struct { APIKeyRequired bool `yaml:"apiKeyRequired"` // APIKeyOptional marks a provider that also works unauthenticated (a local // server). It only affects the Pi harness: see BuildPiProvider. - APIKeyOptional bool `yaml:"apiKeyOptional"` - APIKeyPrefix string `yaml:"apiKeyPrefix"` - Options map[string]any `yaml:"options"` - OptionsFromEnv map[string]string `yaml:"optionsFromEnv"` - Families map[string]*Family `yaml:"families"` + APIKeyOptional bool `yaml:"apiKeyOptional"` + APIKeyPrefix string `yaml:"apiKeyPrefix"` + Options map[string]any `yaml:"options"` + OptionsFromEnv map[string]string `yaml:"optionsFromEnv"` // Pi marks the provider as usable by the Pi harness and carries its // Pi-specific settings. Nil when the provider has no `pi:` block, in which // case BuildPiProvider reports it as unsupported. @@ -63,13 +62,6 @@ type PiConfig struct { BaseURL string `yaml:"baseUrl"` } -// Family is a named set of models within a provider. -type Family struct { - Description string `yaml:"description"` - DefaultModel string `yaml:"defaultModel"` - Models map[string]map[string]any `yaml:"models"` -} - // ResolveCatalogPath determines which catalogue file to use: the flag value if // given, otherwise the OUTFIT_PROVIDERS env var, otherwise "" (embedded). func ResolveCatalogPath(flagPath string) string { @@ -121,63 +113,16 @@ func (c *Catalog) SortedProviderNames() []string { return names } -// SortedFamilyNames returns a provider's family names in stable order. -func (p *Provider) SortedFamilyNames() []string { - names := make([]string, 0, len(p.Families)) - for n := range p.Families { - names = append(names, n) - } - sort.Strings(names) - return names -} - -// ModelKeys returns a family's model keys in stable order. -func (f *Family) ModelKeys() []string { - keys := make([]string, 0, len(f.Models)) - for k := range f.Models { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -// MatchFamily returns the name of the provider family whose model set exactly -// matches keys, or "" if none does. It lets `outfit export` name a family -// instead of listing the individual models it expands to. -func MatchFamily(p *Provider, keys []string) string { - want := make(map[string]bool, len(keys)) - for _, k := range keys { - want[k] = true - } - for _, name := range p.SortedFamilyNames() { - fk := p.Families[name].ModelKeys() - if len(fk) != len(want) { - continue - } - matched := true - for _, k := range fk { - if !want[k] { - matched = false - break - } - } - if matched { - return name - } - } - return "" -} - -// BuildProviderBlock turns a provider plus an optional family and/or explicit -// 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). +// BuildProviderBlock turns a provider plus an explicit 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). // // baseURLOverride, when non-empty, sets options.baseURL for any provider. It // comes from the --base-url flag; when empty, the OUTFIT_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) { +func BuildProviderBlock(id string, p *Provider, 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 @@ -226,20 +171,8 @@ func BuildProviderBlock(id string, p *Provider, familyName, modelOverride, baseU models := map[string]any{} var defaultModelKey string - if familyName != "" { - fam, ok := p.Families[familyName] - if !ok { - return nil, "", fmt.Errorf("provider %q has no model family %q (see `outfit list`)", id, familyName) - } - for k, v := range fam.Models { - models[k] = v - } - defaultModelKey = fam.DefaultModel - } if modelOverride != "" { - if _, ok := models[modelOverride]; !ok { - models[modelOverride] = map[string]any{"name": modelOverride} - } + models[modelOverride] = map[string]any{"name": modelOverride} defaultModelKey = modelOverride } if len(models) > 0 { @@ -276,8 +209,8 @@ type PiModel struct { MaxTokens int `json:"maxTokens,omitempty"` } -// BuildPiProvider turns a provider plus an optional family and/or explicit model -// into a Pi provider entry, returning the entry and the chosen default model key +// BuildPiProvider turns a provider plus an explicit model into a Pi provider +// entry, returning the entry and the chosen default model key // (provider-relative), or "" if none was selected. resolve looks up env vars // (used only for the OUTFIT_BASE_URL override). // @@ -288,7 +221,7 @@ type PiModel struct { // baseURLOverride mirrors BuildProviderBlock: when non-empty it wins; otherwise // OUTFIT_BASE_URL is consulted, then the catalogue's pi.baseUrl, then // options.baseURL. -func BuildPiProvider(id string, p *Provider, familyName, modelOverride, baseURLOverride string, resolve func(string) string) (PiProvider, string, error) { +func BuildPiProvider(id string, p *Provider, modelOverride, baseURLOverride string, resolve func(string) string) (PiProvider, string, error) { if p.Pi == nil { return PiProvider{}, "", fmt.Errorf("provider %q is not supported by the pi harness (no pi config in the catalogue)", id) } @@ -332,41 +265,11 @@ func BuildPiProvider(id string, p *Provider, familyName, modelOverride, baseURLO prov.APIKey = piPlaceholderAPIKey } - // Collect models in stable order so the written file is deterministic. - names := map[string]string{} - var keys []string var defaultModelKey string - if familyName != "" { - fam, ok := p.Families[familyName] - if !ok { - return PiProvider{}, "", fmt.Errorf("provider %q has no model family %q (see `outfit list`)", id, familyName) - } - for _, k := range fam.ModelKeys() { - keys = append(keys, k) - if n, _ := fam.Models[k]["name"].(string); n != "" { - names[k] = n - } - } - defaultModelKey = fam.DefaultModel - } if modelOverride != "" { - if _, seen := names[modelOverride]; !seen { - found := false - for _, k := range keys { - if k == modelOverride { - found = true - break - } - } - if !found { - keys = append(keys, modelOverride) - } - } + prov.Models = append(prov.Models, PiModel{ID: modelOverride}) defaultModelKey = modelOverride } - for _, k := range keys { - prov.Models = append(prov.Models, PiModel{ID: k, Name: names[k]}) - } return prov, defaultModelKey, nil } diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index 83f9f969..fcdea887 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -16,10 +16,8 @@ func envMap(m map[string]string) func(string) string { } // TestCatalogIntegrity guards the embedded providers.yaml against typos and -// drift from the opencode config schema (https://opencode.ai/docs/config/): -// the top-level model is "provider/model", so every family's defaultModel must -// be a real model key, custom providers must declare an npm package, and an -// apiKeyPrefix is meaningless without an apiKeyEnv. +// drift: a custom provider supplying a baseURL must declare an npm package, and +// an apiKeyPrefix is meaningless without an apiKeyEnv. func TestCatalogIntegrity(t *testing.T) { cat, err := Load() if err != nil { @@ -44,50 +42,6 @@ func TestCatalogIntegrity(t *testing.T) { if _, hasBaseURL := p.Options["baseURL"]; hasBaseURL && p.NPM == "" { t.Errorf("provider %q: baseURL set without npm package", name) } - if len(p.Families) == 0 { - t.Errorf("provider %q: no model families", name) - } - for fname, fam := range p.Families { - if len(fam.Models) == 0 { - t.Errorf("provider %q family %q: no models", name, fname) - } - if fam.DefaultModel == "" { - t.Errorf("provider %q family %q: no defaultModel", name, fname) - continue - } - if _, ok := fam.Models[fam.DefaultModel]; !ok { - t.Errorf("provider %q family %q: defaultModel %q is not one of its models", name, fname, fam.DefaultModel) - } - } - } -} - -// TestMatchFamily checks the reverse lookup used by `outfit export`: a set of -// configured model keys maps back to a family only when it matches exactly. -func TestMatchFamily(t *testing.T) { - cat, _ := Load() - p := cat.Providers["openrouter"] - famKeys := p.Families["deepseek-v4"].ModelKeys() - - if got := MatchFamily(p, famKeys); got != "deepseek-v4" { - t.Errorf("exact match = %q, want deepseek-v4", got) - } - - // A superset (all the family's models plus a stray) is not a match. - if got := MatchFamily(p, append(append([]string{}, famKeys...), "stray-model")); got != "" { - t.Errorf("superset matched %q, want no match", got) - } - - // A subset (one model short) is not a match either. - if len(famKeys) > 1 { - if got := MatchFamily(p, famKeys[:len(famKeys)-1]); got != "" { - t.Errorf("subset matched %q, want no match", got) - } - } - - // Unrelated keys match nothing. - if got := MatchFamily(p, []string{"something-else"}); got != "" { - t.Errorf("unrelated keys matched %q, want no match", got) } } @@ -126,11 +80,11 @@ func TestBuildPiProvider_OpenRouter(t *testing.T) { cat, _ := Load() p := cat.Providers["openrouter"] - prov, model, err := BuildPiProvider("openrouter", p, "deepseek-v4", "", "", noEnv) + prov, model, err := BuildPiProvider("openrouter", p, "deepseek/deepseek-v4-pro", "", noEnv) if err != nil { t.Fatalf("BuildPiProvider: %v", err) } - if model != "deepseek/deepseek-v4-flash" { + if model != "deepseek/deepseek-v4-pro" { t.Errorf("default model = %q", model) } if prov.API != "openai-completions" { @@ -143,13 +97,8 @@ func TestBuildPiProvider_OpenRouter(t *testing.T) { if prov.APIKey != "$DEEPSEEK_API_KEY" { t.Errorf("apiKey = %q, want $DEEPSEEK_API_KEY interpolation", prov.APIKey) } - if len(prov.Models) != 2 { - t.Fatalf("got %d models, want 2", len(prov.Models)) - } - for _, m := range prov.Models { - if m.ID == "" || m.Name == "" { - t.Errorf("model missing id/name: %+v", m) - } + if len(prov.Models) != 1 || prov.Models[0].ID != "deepseek/deepseek-v4-pro" { + t.Fatalf("models = %+v, want a single deepseek/deepseek-v4-pro entry", prov.Models) } } @@ -157,7 +106,7 @@ func TestBuildPiProvider_BaseURLFallbackAndOverride(t *testing.T) { cat, _ := Load() p := cat.Providers["ollama"] // pi block has no baseUrl; falls back to options.baseURL - prov, _, err := BuildPiProvider("ollama", p, "llama", "", "", noEnv) + prov, _, err := BuildPiProvider("ollama", p, "llama3.2", "", noEnv) if err != nil { t.Fatalf("BuildPiProvider: %v", err) } @@ -166,7 +115,7 @@ func TestBuildPiProvider_BaseURLFallbackAndOverride(t *testing.T) { } // Flag override wins over everything. - prov, _, err = BuildPiProvider("ollama", p, "llama", "", "https://flag.example/v1", noEnv) + prov, _, err = BuildPiProvider("ollama", p, "llama3.2", "https://flag.example/v1", noEnv) if err != nil { t.Fatalf("BuildPiProvider: %v", err) } @@ -175,7 +124,7 @@ func TestBuildPiProvider_BaseURLFallbackAndOverride(t *testing.T) { } // OUTFIT_BASE_URL wins when no flag is given. - prov, _, err = BuildPiProvider("ollama", p, "llama", "", "", envMap(map[string]string{ + prov, _, err = BuildPiProvider("ollama", p, "llama3.2", "", envMap(map[string]string{ baseURLEnv: "https://from-env.example/v1", })) if err != nil { @@ -190,7 +139,7 @@ func TestBuildPiProvider_ModelOverrideNoKey(t *testing.T) { cat, _ := Load() p := cat.Providers["llamacpp"] // apiKeyOptional, and the var is unset here - prov, model, err := BuildPiProvider("llamacpp", p, "", "my-local", "", noEnv) + prov, model, err := BuildPiProvider("llamacpp", p, "my-local", "", noEnv) if err != nil { t.Fatalf("BuildPiProvider: %v", err) } @@ -210,7 +159,7 @@ func TestBuildPiProvider_ModelOverrideNoKey(t *testing.T) { func TestBuildPiProvider_UnsupportedProvider(t *testing.T) { cat, _ := Load() p := cat.Providers["amazon-bedrock"] // no pi block - if _, _, err := BuildPiProvider("amazon-bedrock", p, "claude", "", "", noEnv); err == nil { + if _, _, err := BuildPiProvider("amazon-bedrock", p, "some-model", "", noEnv); err == nil { t.Fatal("expected error for a provider with no pi config") } } @@ -241,12 +190,9 @@ func TestLoadCatalogFrom(t *testing.T) { os.WriteFile(path, []byte(`providers: mine: description: My custom provider - families: - base: - defaultModel: m1 - models: - m1: - name: Model One + npm: "@ai-sdk/openai-compatible" + options: + baseURL: http://localhost:9999/v1 `), 0o600) cat, err := LoadFrom(path) @@ -270,7 +216,7 @@ func TestBuildProviderBlock_OpenRouterKeyReferenced(t *testing.T) { cat, _ := Load() p := cat.Providers["openrouter"] - block, model, err := BuildProviderBlock("openrouter", p, "deepseek-v4", "", "", envMap(map[string]string{ + block, model, err := BuildProviderBlock("openrouter", p, "deepseek/deepseek-v4-flash", "", envMap(map[string]string{ "DEEPSEEK_API_KEY": "sk-or-v1-abc", })) if err != nil { @@ -288,15 +234,15 @@ func TestBuildProviderBlock_OpenRouterKeyReferenced(t *testing.T) { if opts["apiKey"] == "sk-or-v1-abc" { t.Error("the resolved secret must not be written to the config") } - if models := block["models"].(map[string]any); len(models) != 2 { - t.Errorf("got %d models, want 2", len(models)) + if models := block["models"].(map[string]any); len(models) != 1 { + t.Errorf("got %d models, want 1", len(models)) } } func TestBuildProviderBlock_RequiredKeyMissing(t *testing.T) { cat, _ := Load() p := cat.Providers["openrouter"] - if _, _, err := BuildProviderBlock("openrouter", p, "deepseek-v4", "", "", noEnv); err == nil { + if _, _, err := BuildProviderBlock("openrouter", p, "deepseek/deepseek-v4-flash", "", noEnv); err == nil { t.Fatal("expected error when required key is missing") } } @@ -304,7 +250,7 @@ func TestBuildProviderBlock_RequiredKeyMissing(t *testing.T) { func TestBuildProviderBlock_KeyPrefixMismatch(t *testing.T) { cat, _ := Load() p := cat.Providers["openrouter"] - _, _, err := BuildProviderBlock("openrouter", p, "deepseek-v4", "", "", envMap(map[string]string{ + _, _, err := BuildProviderBlock("openrouter", p, "deepseek/deepseek-v4-flash", "", envMap(map[string]string{ "DEEPSEEK_API_KEY": "wrong-prefix-key", })) if err == nil || !strings.Contains(err.Error(), "start with") { @@ -316,7 +262,7 @@ func TestBuildProviderBlock_BedrockNoKeyRegionFromEnv(t *testing.T) { cat, _ := Load() p := cat.Providers["amazon-bedrock"] - block, model, err := BuildProviderBlock("amazon-bedrock", p, "claude", "", "", envMap(map[string]string{ + block, model, err := BuildProviderBlock("amazon-bedrock", p, "anthropic.claude-3-5-sonnet", "", envMap(map[string]string{ "AWS_REGION": "eu-west-2", })) if err != nil { @@ -338,7 +284,7 @@ func TestBuildProviderBlock_CustomProviderDefaults(t *testing.T) { cat, _ := Load() p := cat.Providers["ollama"] - block, model, err := BuildProviderBlock("ollama", p, "llama", "", "", noEnv) + block, model, err := BuildProviderBlock("ollama", p, "llama3.2", "", noEnv) if err != nil { t.Fatalf("BuildProviderBlock: %v", err) } @@ -358,7 +304,7 @@ func TestBuildProviderBlock_ModelOverrideAddsEntry(t *testing.T) { cat, _ := Load() 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) } @@ -372,11 +318,19 @@ func TestBuildProviderBlock_ModelOverrideAddsEntry(t *testing.T) { } } -func TestBuildProviderBlock_UnknownFamily(t *testing.T) { +func TestBuildProviderBlock_NoModelIsNoDefault(t *testing.T) { cat, _ := Load() - p := cat.Providers["openrouter"] - if _, _, err := BuildProviderBlock("openrouter", p, "nope", "", "", noEnv); err == nil { - t.Fatal("expected error for unknown family") + p := cat.Providers["llamacpp"] + + block, model, err := BuildProviderBlock("llamacpp", p, "", "", noEnv) + if err != nil { + t.Fatalf("BuildProviderBlock: %v", err) + } + if model != "" { + t.Errorf("default model = %q, want empty when no model is named", model) + } + if _, ok := block["models"]; ok { + t.Errorf("block should carry no models when none is named, got %v", block["models"]) } } @@ -387,7 +341,7 @@ func TestBuildProviderBlock_BaseURLFlagOverrides(t *testing.T) { cat, _ := Load() p := cat.Providers["ollama"] - block, _, err := BuildProviderBlock("ollama", p, "llama", "", "https://flag.example/v1", envMap(map[string]string{ + block, _, err := BuildProviderBlock("ollama", p, "llama3.2", "https://flag.example/v1", envMap(map[string]string{ "OLLAMA_BASE_URL": "https://per-provider.example/v1", })) if err != nil { @@ -405,7 +359,7 @@ func TestBuildProviderBlock_BaseURLFromEnv(t *testing.T) { cat, _ := Load() p := cat.Providers["ollama"] - block, _, err := BuildProviderBlock("ollama", p, "llama", "", "", envMap(map[string]string{ + block, _, err := BuildProviderBlock("ollama", p, "llama3.2", "", envMap(map[string]string{ baseURLEnv: "https://from-env.example/v1", })) if err != nil { @@ -423,7 +377,7 @@ func TestBuildProviderBlock_BaseURLFlagBeatsEnv(t *testing.T) { cat, _ := Load() p := cat.Providers["ollama"] - block, _, err := BuildProviderBlock("ollama", p, "llama", "", "https://flag.example/v1", envMap(map[string]string{ + block, _, err := BuildProviderBlock("ollama", p, "llama3.2", "https://flag.example/v1", envMap(map[string]string{ baseURLEnv: "https://from-env.example/v1", })) if err != nil { @@ -442,7 +396,7 @@ func TestBuildProviderBlock_BaseURLOnPlainProvider(t *testing.T) { cat, _ := Load() p := cat.Providers["openrouter"] - block, _, err := BuildProviderBlock("openrouter", p, "deepseek-v4", "", "https://gateway.example/v1", envMap(map[string]string{ + block, _, err := BuildProviderBlock("openrouter", p, "deepseek/deepseek-v4-flash", "https://gateway.example/v1", envMap(map[string]string{ "DEEPSEEK_API_KEY": "sk-or-v1-abc", })) if err != nil { @@ -460,7 +414,7 @@ func TestBuildProviderBlock_NoBaseURLOverride(t *testing.T) { cat, _ := Load() p := cat.Providers["ollama"] - block, _, err := BuildProviderBlock("ollama", p, "llama", "", "", noEnv) + block, _, err := BuildProviderBlock("ollama", p, "llama3.2", "", noEnv) if err != nil { t.Fatalf("BuildProviderBlock: %v", err) } @@ -502,7 +456,7 @@ func TestBuildPiProvider_OptionalKey(t *testing.T) { } return "" } - prov, _, err := BuildPiProvider("llamacpp", p, "local", "", "", withKey) + prov, _, err := BuildPiProvider("llamacpp", p, "local-model", "", withKey) if err != nil { t.Fatalf("BuildPiProvider: %v", err) } @@ -510,7 +464,7 @@ func TestBuildPiProvider_OptionalKey(t *testing.T) { t.Errorf("apiKey = %q, want the $VAR reference when the key is set", prov.APIKey) } - prov, _, err = BuildPiProvider("llamacpp", p, "local", "", "", noEnv) + prov, _, err = BuildPiProvider("llamacpp", p, "local-model", "", noEnv) if err != nil { t.Fatalf("BuildPiProvider: %v", err) } @@ -523,7 +477,7 @@ func TestBuildPiProvider_OptionalKey(t *testing.T) { // the key can be exported after the Outfit is applied. func TestBuildPiProvider_RequiredKeyKeepsReference(t *testing.T) { cat, _ := Load() - prov, _, err := BuildPiProvider("openai-compatible", cat.Providers["openai-compatible"], "gpt", "", "", noEnv) + prov, _, err := BuildPiProvider("openai-compatible", cat.Providers["openai-compatible"], "gpt-4o", "", noEnv) if err != nil { t.Fatalf("BuildPiProvider: %v", err) } @@ -542,7 +496,7 @@ func TestBuildProviderBlock_LlamacppReferencesKeyForRemote(t *testing.T) { } return "" } - block, _, err := BuildProviderBlock("llamacpp", cat.Providers["llamacpp"], "local", "", "http://198.51.100.1:8000/v1", resolve) + block, _, err := BuildProviderBlock("llamacpp", cat.Providers["llamacpp"], "local-model", "http://198.51.100.1:8000/v1", resolve) if err != nil { t.Fatalf("BuildProviderBlock: %v", err) } @@ -551,7 +505,7 @@ func TestBuildProviderBlock_LlamacppReferencesKeyForRemote(t *testing.T) { t.Errorf("apiKey = %v, want the env reference", options["apiKey"]) } - block, _, err = BuildProviderBlock("llamacpp", cat.Providers["llamacpp"], "local", "", "", noEnv) + block, _, err = BuildProviderBlock("llamacpp", cat.Providers["llamacpp"], "local-model", "", noEnv) if err != nil { t.Fatalf("BuildProviderBlock: %v", err) } @@ -562,7 +516,7 @@ func TestBuildProviderBlock_LlamacppReferencesKeyForRemote(t *testing.T) { // A remote endpoint keeps the reference even with the key unset, so setting // it before the agent runs is enough. - block, _, err = BuildProviderBlock("llamacpp", cat.Providers["llamacpp"], "local", "", "http://198.51.100.1:8000/v1", noEnv) + block, _, err = BuildProviderBlock("llamacpp", cat.Providers["llamacpp"], "local-model", "http://198.51.100.1:8000/v1", noEnv) if err != nil { t.Fatalf("BuildProviderBlock: %v", err) } @@ -595,7 +549,7 @@ func TestBuildPiProvider_RemoteOptionalKeyKeepsReference(t *testing.T) { cat, _ := Load() p := cat.Providers["llamacpp"] // apiKeyOptional - prov, _, err := BuildPiProvider("llamacpp", p, "local", "", "http://198.51.100.1:8000/v1", noEnv) + prov, _, err := BuildPiProvider("llamacpp", p, "local-model", "http://198.51.100.1:8000/v1", noEnv) if err != nil { t.Fatalf("BuildPiProvider: %v", err) } @@ -605,7 +559,7 @@ func TestBuildPiProvider_RemoteOptionalKeyKeepsReference(t *testing.T) { // The local server keeps the placeholder: it needs no key, and a reference // to a variable set nowhere would hide its models in /model. - prov, _, err = BuildPiProvider("llamacpp", p, "local", "", "http://127.0.0.1:8080/v1", noEnv) + prov, _, err = BuildPiProvider("llamacpp", p, "local-model", "http://127.0.0.1:8080/v1", noEnv) if err != nil { t.Fatalf("BuildPiProvider: %v", err) } diff --git a/internal/catalog/providers.yaml b/internal/catalog/providers.yaml index 8ae1609c..8108b1e2 100644 --- a/internal/catalog/providers.yaml +++ b/internal/catalog/providers.yaml @@ -1,8 +1,12 @@ -# Provider and model-family catalogue for outfit. +# Provider catalogue for outfit. # # This file is embedded into the binary at build time (see //go:embed in -# catalog.go) but kept external so providers, model families, and endpoints can -# be maintained without touching the Go code. +# catalog.go) but kept external so providers and endpoints can be maintained +# without touching the Go code. +# +# The catalogue is provider *plumbing* only — it does not enumerate models. The +# model a provider serves is named by the selection (MODEL/ALIAS in an Outfit, +# or --model/--alias on the command line); any model id passes through. # # Per provider: # name display name written into the opencode config (optional) @@ -17,7 +21,6 @@ # apiKeyPrefix sanity-check that the key starts with this # options static options merged into the provider block # optionsFromEnv option -> env var; injected into options when the var is set -# families named model sets; each has a defaultModel and a models map # pi optional block marking the provider as usable by the Pi # harness (~/.pi/agent/models.json). Holds: # api Pi protocol: openai-completions | openai-responses @@ -35,15 +38,6 @@ providers: pi: api: openai-completions baseUrl: https://openrouter.ai/api/v1 - families: - deepseek-v4: - description: DeepSeek V4 (Flash and Pro) - defaultModel: deepseek/deepseek-v4-flash - models: - deepseek/deepseek-v4-flash: - name: DeepSeek V4 Flash - deepseek/deepseek-v4-pro: - name: DeepSeek V4 Pro amazon-bedrock: description: AWS Bedrock (authenticates via your AWS credentials) @@ -53,15 +47,6 @@ providers: region: us-east-1 optionsFromEnv: region: AWS_REGION - families: - claude: - description: Anthropic Claude models on Bedrock - defaultModel: anthropic.claude-3-5-sonnet-20241022-v2:0 - models: - "anthropic.claude-3-5-sonnet-20241022-v2:0": - name: Claude 3.5 Sonnet - "anthropic.claude-3-5-haiku-20241022-v1:0": - name: Claude 3.5 Haiku ollama: description: Local Ollama server (OpenAI-compatible API) @@ -73,15 +58,6 @@ providers: baseURL: OLLAMA_BASE_URL pi: api: openai-completions - families: - llama: - description: Meta Llama models served by Ollama - defaultModel: llama3.2 - models: - llama3.2: - name: Llama 3.2 - llama3.1: - name: Llama 3.1 llamacpp: description: llama.cpp server (OpenAI-compatible API) @@ -99,13 +75,6 @@ providers: baseURL: LLAMACPP_BASE_URL pi: api: openai-completions - families: - local: - description: Model loaded by your llama.cpp server - defaultModel: local-model - models: - local-model: - name: llama.cpp model vllm: description: vLLM server (OpenAI-compatible API) @@ -120,13 +89,6 @@ providers: baseURL: VLLM_BASE_URL pi: api: openai-completions - families: - served: - description: Model loaded by your vLLM server - defaultModel: served-model - models: - served-model: - name: vLLM model openai-compatible: description: Generic OpenAI-compatible endpoint (set base URL and key via env) @@ -139,12 +101,3 @@ providers: baseURL: OPENAI_BASE_URL pi: api: openai-completions - families: - gpt: - description: OpenAI GPT models (or your endpoint's equivalents) - defaultModel: gpt-4o - models: - gpt-4o: - name: GPT-4o - gpt-4o-mini: - name: GPT-4o mini diff --git a/internal/harness/adapters.go b/internal/harness/adapters.go index b2d3f8ed..3c151779 100644 --- a/internal/harness/adapters.go +++ b/internal/harness/adapters.go @@ -31,7 +31,7 @@ func (opencodeHarness) Command() string { return "opencode" } func (opencodeHarness) ConfigPath() (string, error) { return opencode.ResolveConfigFile() } func (opencodeHarness) Apply(p *catalog.Provider, sel outfit.Selection, contextWindow, outputTokens int, resolve func(string) string) (Summary, error) { - block, defaultModel, err := catalog.BuildProviderBlock(sel.Provider, p, sel.Family, modelKey(sel), sel.BaseURL, resolve) + block, defaultModel, err := catalog.BuildProviderBlock(sel.Provider, p, modelKey(sel), sel.BaseURL, resolve) if err != nil { return Summary{}, err } @@ -100,7 +100,7 @@ func (piHarness) Command() string { return "pi" } func (piHarness) ConfigPath() (string, error) { return pi.ConfigPath() } func (piHarness) Apply(p *catalog.Provider, sel outfit.Selection, contextWindow, outputTokens int, resolve func(string) string) (Summary, error) { - prov, defaultModel, err := catalog.BuildPiProvider(sel.Provider, p, sel.Family, modelKey(sel), sel.BaseURL, resolve) + prov, defaultModel, err := catalog.BuildPiProvider(sel.Provider, p, modelKey(sel), sel.BaseURL, resolve) if err != nil { return Summary{}, err } diff --git a/internal/outfit/outfit.go b/internal/outfit/outfit.go index bc00b6a3..418130ed 100644 --- a/internal/outfit/outfit.go +++ b/internal/outfit/outfit.go @@ -1,14 +1,13 @@ // Package outfit defines a provider Selection and the declarative, Dockerfile- // style Outfit file that describes one. // -// An Outfit is a declarative description of a single opencode provider plus an -// optional model family and/or model — the file equivalent of one `outfit -// add` invocation. It uses a flat, Dockerfile-style syntax: +// An Outfit is a declarative description of a single opencode provider plus a +// model — the file equivalent of one `outfit add` invocation. It uses a flat, +// Dockerfile-style syntax: // // # point opencode at one provider // PROVIDER openrouter -// FAMILY deepseek-v4 -// MODEL deepseek/deepseek-v4-pro # optional; the provider-native model ref +// MODEL deepseek/deepseek-v4-pro # the provider-native model ref // ALIAS deepseek # optional; friendly name for the model // CONTEXT 128k # optional; context window // OUTPUT 32k # optional; max output tokens @@ -33,12 +32,11 @@ import ( "strings" ) -// Selection holds the provider, model family and/or model, and optional -// overrides that describe one opencode provider configuration. It is the shared -// currency between the CLI flags, the Outfit file, and the apply/export paths. +// Selection holds the provider, model and/or alias, and optional overrides that +// describe one opencode provider configuration. It is the shared currency +// between the CLI flags, the Outfit file, and the apply/export paths. type Selection struct { Provider string - Family string Model string Alias string Context string @@ -52,7 +50,6 @@ type Selection struct { // Outfit keywords, in their canonical (lower-cased) form for matching. const ( kwProvider = "provider" - kwFamily = "family" kwModel = "model" kwAlias = "alias" kwContext = "context" @@ -67,7 +64,7 @@ const ( // "" for an unrecognised keyword. func canonicalKeyword(kw string) string { switch kw { - case kwProvider, kwFamily, kwModel, kwAlias, kwContext, kwOutput, kwPreset, kwRemote: + case kwProvider, kwModel, kwAlias, kwContext, kwOutput, kwPreset, kwRemote: return kw case kwBaseURL, "base-url", "base_url", "url": return kwBaseURL @@ -95,7 +92,7 @@ func Parse(data []byte) (Selection, error) { fields := strings.Fields(text) canon := canonicalKeyword(strings.ToLower(fields[0])) if canon == "" { - return Selection{}, fmt.Errorf("line %d: unknown keyword %q (expected PROVIDER, FAMILY, MODEL, ALIAS, CONTEXT, OUTPUT, BASEURL, PRESET, or REMOTE)", line, fields[0]) + return Selection{}, fmt.Errorf("line %d: unknown keyword %q (expected PROVIDER, MODEL, ALIAS, CONTEXT, OUTPUT, BASEURL, PRESET, or REMOTE)", line, fields[0]) } switch { case len(fields) < 2: @@ -113,8 +110,6 @@ func Parse(data []byte) (Selection, error) { switch canon { case kwProvider: sel.Provider = value - case kwFamily: - sel.Family = value case kwModel: sel.Model = value case kwAlias: @@ -167,7 +162,6 @@ func Format(sel Selection) string { } } line("PROVIDER", sel.Provider) - line("FAMILY", sel.Family) line("MODEL", sel.Model) line("ALIAS", sel.Alias) line("CONTEXT", sel.Context) diff --git a/internal/outfit/outfit_test.go b/internal/outfit/outfit_test.go index 7f325865..411042b8 100644 --- a/internal/outfit/outfit_test.go +++ b/internal/outfit/outfit_test.go @@ -12,14 +12,14 @@ func TestParse(t *testing.T) { want Selection }{ { - name: "provider and family", - in: "PROVIDER openrouter\nFAMILY deepseek-v4\n", - want: Selection{Provider: "openrouter", Family: "deepseek-v4"}, + name: "provider and model", + in: "PROVIDER openrouter\nMODEL deepseek/deepseek-v4-pro\n", + want: Selection{Provider: "openrouter", Model: "deepseek/deepseek-v4-pro"}, }, { name: "case-insensitive keywords", - in: "provider ollama\nFamily llama\n", - want: Selection{Provider: "ollama", Family: "llama"}, + in: "provider ollama\nModel llama3.2\n", + want: Selection{Provider: "ollama", Model: "llama3.2"}, }, { name: "model only", @@ -33,8 +33,8 @@ func TestParse(t *testing.T) { }, { name: "extra whitespace and tabs as separator", - in: "PROVIDER\tollama\nFAMILY llama\n", - want: Selection{Provider: "ollama", Family: "llama"}, + in: "PROVIDER\tollama\nMODEL llama3.2\n", + want: Selection{Provider: "ollama", Model: "llama3.2"}, }, { name: "context, output, and base url", @@ -67,8 +67,9 @@ func TestParse(t *testing.T) { func TestParse_Errors(t *testing.T) { cases := map[string]string{ - "missing provider": "FAMILY llama\n", + "missing provider": "MODEL llama3.2\n", "unknown keyword": "PROVIDER ollama\nFLAVOUR vanilla\n", + "family retired": "PROVIDER openrouter\nFAMILY deepseek-v4\n", "keyword no value": "PROVIDER\n", "too many values": "PROVIDER a b\n", "duplicate keyword": "PROVIDER a\nPROVIDER b\n", @@ -86,7 +87,6 @@ func TestParse_Errors(t *testing.T) { func TestFormatRoundTrip(t *testing.T) { sel := Selection{ Provider: "openrouter", - Family: "deepseek-v4", Model: "deepseek/deepseek-v4-pro", Alias: "deepseek", Context: "128000", diff --git a/openspec/changes/archive/2026-07-29-retire-model-families/.openspec.yaml b/openspec/changes/archive/2026-07-29-retire-model-families/.openspec.yaml new file mode 100644 index 00000000..f205fc72 --- /dev/null +++ b/openspec/changes/archive/2026-07-29-retire-model-families/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-29 diff --git a/openspec/changes/archive/2026-07-29-retire-model-families/design.md b/openspec/changes/archive/2026-07-29-retire-model-families/design.md new file mode 100644 index 00000000..0f31f864 --- /dev/null +++ b/openspec/changes/archive/2026-07-29-retire-model-families/design.md @@ -0,0 +1,68 @@ +## Context + +The catalogue's `Family` layer (`internal/catalog/catalog.go`) groups a provider's +example models under a name, with a `defaultModel`. It touches five call sites: the two +block builders (`BuildProviderBlock`, `BuildPiProvider`), the Outfit format +(`Selection.Family`, `FAMILY` keyword), the CLI flag `--model-family`/`-f`, `outfit list` +/ `outfit export` / `outfit remove`, and shell completion. The behaviour that must survive +the removal is: a selection still configures one model (from `MODEL`/`ALIAS`), that model +becomes the harness default, and an `ALIAS` still keys the model. `BuildProviderBlock` +already synthesises a model entry from `modelOverride` when it is not otherwise present, +so the single-model path already exists and is exercised by every shipped example. + +## Goals / Non-Goals + +**Goals:** +- Remove families and per-family default models from the catalogue data and API. +- Remove the `FAMILY` keyword and `--model-family`/`-f` flag. +- Preserve single-model selection, default-model resolution, and alias-keying unchanged. +- Keep the public builder functions coherent (drop the now-unused `familyName` parameter). +- Keep test coverage ≥ 80% and all docs consistent. + +**Non-Goals:** +- Any live/dynamic model discovery source (separate change). +- Changing context/output limit handling, API-key resolution, base-URL precedence, or the + opencode/Pi config-merge behaviour. +- A deprecation shim for `FAMILY` — it is removed outright (no shipped Outfit uses it). + +## Decisions + +**Drop `familyName` from the builder signatures** rather than pass `""`. Leaving a dead +parameter invites confusion; the builders become +`BuildProviderBlock(id, p, modelOverride, baseURLOverride, resolve)` and the Pi analogue. +Callers in `internal/harness/adapters.go` pass `modelKey(sel)` as before. + +**Remove `MatchFamily` and let `export` name the model directly.** Export already falls +back to `st.ModelKeys[0]` when no family matches (`cmd/outfit/main.go`); with families +gone, that fallback becomes the only path. A config with several models under one provider +still exports the default model (or the first) as `MODEL` — export represents one model per +Outfit, which is the format's existing constraint. + +**`outfit remove -p ` with no model still removes the whole provider**; naming a +model or alias removes that one key. The family-expansion branch is deleted. + +**`outfit list` shows providers + plumbing only.** Losing the model hint is accepted here +and addressed by the separate live-discovery change; the test assertions for `family`/ +`default:` lines are removed. + +Alternatives considered: keeping single-model families as thin wrappers (rejected — still +curation and still a `families:` block to maintain); keeping `FAMILY` as a deprecated +no-op alias for `MODEL` (rejected — no users to protect, and it would keep the parser and +completion complexity). + +## Risks / Trade-offs + +- [Breaking change: an Outfit or script using `FAMILY`/`-f` stops working] → The proposal + and docs state the migration (`FAMILY x` → `MODEL x` or `ALIAS x`); no shipped example + uses it; the parser error already names the accepted keywords, so the failure is legible. +- [`outfit list` loses model suggestions] → Intentional; the follow-up live-discovery + change restores a browsable, self-updating list without curation. +- [Coverage dip from deleting family tests] → Net code shrinks too; verify with + `go test ./... -cover` and add a small `export`/`remove` single-model test if a package + dips below 80%. + +## Migration Plan + +Single atomic change, no runtime state. Ship removes the keyword/flag and the family data +together so the parser, builders, and catalogue stay consistent. Rollback is a revert of +the change; no data migration is involved. diff --git a/openspec/changes/archive/2026-07-29-retire-model-families/proposal.md b/openspec/changes/archive/2026-07-29-retire-model-families/proposal.md new file mode 100644 index 00000000..a9097b04 --- /dev/null +++ b/openspec/changes/archive/2026-07-29-retire-model-families/proposal.md @@ -0,0 +1,63 @@ +## Why + +The provider catalogue (`internal/catalog/providers.yaml`) doubles as a hand-curated +registry of example models, grouped into `families`. Models change constantly, so those +lists are an unbounded maintenance burden — yet they carry no context size, pricing, or +alias data, and `BuildProviderBlock` already synthesises a model entry for any id the +user names, so an arbitrary model already works without being listed. The `family` layer +exists only to bundle several models under one name; no shipped Outfit uses `FAMILY`, and +the whole local-server story selects with `ALIAS`/`MODEL`. The curation earns nothing it +costs. + +## What Changes + +- **BREAKING**: Remove the `FAMILY` Outfit keyword and the `--model-family`/`-f` flag. A + selection is now a provider plus a `MODEL` and/or `ALIAS` (still at least one required). +- Collapse `providers.yaml` to provider *plumbing* only: description, name, npm, API-key + env var (+ required/optional/prefix), options, optionsFromEnv, and the `pi` block. Drop + every `families:` block, its `models`, and its per-family `defaultModel`. +- Remove the family machinery from the catalogue API: the `Family` type, `Provider.Families`, + `MatchFamily`, `SortedFamilyNames`, and `Family.ModelKeys`; drop the `familyName` parameter + from `BuildProviderBlock` and `BuildPiProvider`. The model comes solely from the user's + `MODEL`/`ALIAS`. +- Simplify the commands that leaned on families: `outfit list` no longer prints family + lines (shows providers + auth/endpoint/harness support); `outfit export` no longer names + a family (it exports the configured model directly); `outfit remove` drops family + expansion. `outfit init-providers` keeps dumping the (now smaller) embedded catalogue. +- Update shell completion to stop offering family names and to complete models without + family scoping. + +Non-goal: this change does not add any live/dynamic model source. Losing the browsable +model list from `outfit list` is intentional here and is addressed separately. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `provider-catalog`: providers no longer declare model families or a family default + model; the embedded catalogue is provider plumbing only, and `outfit list` no longer + lists families/models. +- `provider-selection`: a selection is validated as a provider plus a model and/or alias + (no family); the "family expansion and default model" behaviour is removed, and removal + no longer expands a family. +- `outfit-files`: the `FAMILY` keyword is removed from the Outfit format, and `export` no + longer emits `FAMILY`. +- `shell-completion`: completion no longer offers family names, and model completion is no + longer scoped by `--model-family`. + +## Impact + +- Data: `internal/catalog/providers.yaml`. +- Code: `internal/catalog/catalog.go`, `internal/outfit/outfit.go`, + `internal/harness/adapters.go`, `cmd/outfit/main.go`, `cmd/outfit/complete.go`. +- Tests: `internal/catalog/catalog_test.go`, `internal/outfit/outfit_test.go`, + `cmd/outfit/main_test.go`, `cmd/outfit/complete_test.go`, and any harness tests that + pass a family. +- Docs: `docs/outfit-file.md`, `docs/commands/list.md`, `docs/commands/export.md`, + `docs/commands/add.md`/`remove.md` where `FAMILY`/`-f` appear, `AGENTS.md`. +- Backward compatibility: existing Outfit files or scripts using `FAMILY`/`--model-family` + will fail to parse and must switch to `MODEL`/`ALIAS`. No shipped example uses `FAMILY`. diff --git a/openspec/changes/archive/2026-07-29-retire-model-families/specs/outfit-files/spec.md b/openspec/changes/archive/2026-07-29-retire-model-families/specs/outfit-files/spec.md new file mode 100644 index 00000000..85846ab0 --- /dev/null +++ b/openspec/changes/archive/2026-07-29-retire-model-families/specs/outfit-files/spec.md @@ -0,0 +1,93 @@ +## MODIFIED Requirements + +### Requirement: Outfit file format + +An Outfit SHALL be a flat, line-oriented text file of `KEYWORD value` +instructions. The keywords are `PROVIDER`, `MODEL`, `ALIAS`, `CONTEXT`, +`OUTPUT`, `BASEURL` (also accepted as `BASE-URL`, `BASE_URL`, or `URL`), +`PRESET`, and `REMOTE`. Keywords SHALL match case-insensitively, with UPPERCASE +as the canonical form. Blank lines, full-line `#` comments, and trailing +comments introduced by whitespace-then-`#` SHALL be ignored. Each instruction +SHALL take exactly one value and SHALL appear at most once. `PROVIDER` is +required. Parse errors SHALL name the offending line. + +#### Scenario: A minimal Outfit + +- **WHEN** a file containing only `PROVIDER openrouter` and + `MODEL deepseek/deepseek-v4-pro` is parsed +- **THEN** it yields a selection of that provider and model + +#### Scenario: Duplicate instruction + +- **WHEN** an Outfit sets `MODEL` on two lines +- **THEN** parsing fails, citing both line numbers + +#### Scenario: Unknown keyword + +- **WHEN** an Outfit contains `HARNESS pi` +- **THEN** parsing fails listing the accepted keywords + +#### Scenario: Missing provider + +- **WHEN** an Outfit has no `PROVIDER` instruction +- **THEN** parsing fails saying the PROVIDER instruction is missing + +#### Scenario: Naming a remote endpoint + +- **WHEN** an Outfit contains `REMOTE ./remote.json` +- **THEN** it parses, and the value is available to the `remote` command group + +### Requirement: Applying and unapplying an Outfit + +`outfit apply` SHALL apply the Outfit's selection exactly as the equivalent +`outfit add` would, and `outfit unapply` SHALL remove what the Outfit selects +exactly as the equivalent `outfit remove` would. A command-line `--output`/`-o` +on `apply` SHALL override the Outfit's `OUTPUT` instruction, and `--providers` +SHALL override the catalogue it resolves against (an Outfit never names a +catalogue). `apply` SHALL ignore a `PRESET` instruction — it is consumed only +by `outfit serve`. + +#### Scenario: Apply equals add + +- **WHEN** an Outfit with `PROVIDER ollama` and `MODEL llama3.2` is applied +- **THEN** the harness config matches what `outfit add -p ollama -m llama3.2` + would have produced + +#### Scenario: Output override + +- **WHEN** an Outfit sets `OUTPUT 32k` and the user runs + `outfit apply --output 16k` +- **THEN** the applied output limit is 16000 tokens + +#### Scenario: Preset is not apply's business + +- **WHEN** an Outfit with a `PRESET` instruction is applied +- **THEN** the harness config is written as if the instruction were absent + +### Requirement: Exporting the current config + +`outfit export` SHALL reconstruct a canonical Outfit from the active harness's +config and print it to stdout. The provider exported is chosen by the +`--provider`/`-p` flag, else the default model's provider, else the sole +configured provider; with several providers and no way to choose, the command +SHALL fail listing them. The output SHALL name the configured model with a +`MODEL` instruction, SHALL omit a `BASEURL` that only restates the catalogue's +default, and SHALL record `CONTEXT`/`OUTPUT` only when the exported models agree +on a single value — never inventing one. Rendered output SHALL use canonical +UPPERCASE keywords with aligned values, so `outfit export > Outfit` round-trips. + +#### Scenario: Round-trip through export + +- **WHEN** the user applies an Outfit and then runs `outfit export` +- **THEN** the printed Outfit selects the same provider, model, and limits + +#### Scenario: Ambiguous provider + +- **WHEN** several providers are configured, none is the default model's, and + no `-p` is given +- **THEN** the command fails listing the configured providers to choose from + +#### Scenario: Nothing configured + +- **WHEN** the harness config has no providers +- **THEN** the command fails naming the config file it read diff --git a/openspec/changes/archive/2026-07-29-retire-model-families/specs/provider-catalog/spec.md b/openspec/changes/archive/2026-07-29-retire-model-families/specs/provider-catalog/spec.md new file mode 100644 index 00000000..91e762fe --- /dev/null +++ b/openspec/changes/archive/2026-07-29-retire-model-families/specs/provider-catalog/spec.md @@ -0,0 +1,65 @@ +## MODIFIED Requirements + +### Requirement: Catalogue listing + +`outfit list` SHALL print every provider in the catalogue in stable +(alphabetical) order, showing for each: its id and description, its API key +environment variable (marked `(required)` when the key is mandatory), and the +harnesses that support it (`opencode`, plus `pi` when the provider has a `pi` +block). + +#### Scenario: Listing the built-in catalogue + +- **WHEN** the user runs `outfit list` +- **THEN** every embedded provider is printed with its key requirements and + supported harnesses + +## REMOVED Requirements + +### Requirement: Embedded provider catalogue + +**Reason**: This requirement declared named model families and a per-family +default model, and carried the invariant that a family's `defaultModel` is one +of its `models`. Families are removed from the catalogue, so that invariant no +longer holds and the scenario asserting it must go. The family-free +provider-plumbing rules are restated by the "Embedded provider definitions" +requirement added below. + +**Migration**: The catalogue no longer declares families or models; the user +names the model directly in the selection (`MODEL`/`ALIAS`), which already passes +through the block builders. + +## ADDED Requirements + +### Requirement: Embedded provider definitions + +The system SHALL ship with a built-in catalogue of providers, defined in a YAML +file (`providers.yaml`) embedded into the binary at build time. Each provider +entry declares a description and MAY declare a display name, an npm package, an +API key environment variable (with optional required flag, optional flag, and +expected key prefix), static options (such as `baseURL`), options resolved from +environment variables, and a `pi` block marking the provider as usable by the Pi +harness. The catalogue SHALL NOT enumerate models: the model a provider serves is +named by the user's selection, not stored in the catalogue. + +A provider whose API key is declared optional is one that also works +unauthenticated — the same engine run as a local server and as an +authenticated remote endpoint — so an unset key variable SHALL mean "no key", +not "a key that is missing". + +#### Scenario: Catalogue loads without external files + +- **WHEN** any command that needs the catalogue runs with no `--providers` flag + and no `OUTFIT_PROVIDERS` environment variable +- **THEN** the embedded catalogue is used, with no file read from disk + +#### Scenario: An optional key is injected only when set + +- **WHEN** a provider whose API key is optional is applied, with its key + variable set +- **THEN** the key is injected into the harness config + +#### Scenario: An optional key that is unset is not an error + +- **WHEN** the same provider is applied with the key variable unset +- **THEN** the configuration is written with no key, and the command succeeds diff --git a/openspec/changes/archive/2026-07-29-retire-model-families/specs/provider-selection/spec.md b/openspec/changes/archive/2026-07-29-retire-model-families/specs/provider-selection/spec.md new file mode 100644 index 00000000..23a84284 --- /dev/null +++ b/openspec/changes/archive/2026-07-29-retire-model-families/specs/provider-selection/spec.md @@ -0,0 +1,112 @@ +## MODIFIED Requirements + +### Requirement: Apply feedback + +After applying a selection the system SHALL report the config file written, the +provider configured, the default model when one was set, the resolved context +and output limits when set, and any harness-specific notes (key injection, base +URL, next steps). + +#### Scenario: Successful add + +- **WHEN** `outfit add -p openrouter -m deepseek/deepseek-v4-pro -c 128k` + succeeds +- **THEN** the output names the config path, the provider, the default model, + and the 128000/32000 token limits + +## REMOVED Requirements + +### Requirement: Selection validation + +**Reason**: The requirement admitted a model family as a selector and carried a +scenario about an unknown provider *or family*. Families are removed, so the +selector set and that scenario change; the family-free rules are restated by the +"Validating a selection" requirement below. + +**Migration**: Select with `--provider` plus a `--model` and/or `--alias`; there +is no `--model-family`. + +### Requirement: Family expansion and default model + +**Reason**: Model families are removed. A selection now configures exactly the +one model the user names (`MODEL`/`ALIAS`); there is no family to expand and no +per-family default to pick. + +**Migration**: Replace `-f ` with an explicit `-m ` (or `ALIAS`). +The pinned model becomes the default, exactly as before when both a family and a +`--model` were given. The alias-as-model-key behaviour is preserved by the +"Selection model key" requirement below. + +### Requirement: Removing a selection + +**Reason**: The requirement expanded a named family to its catalogue models when +removing, and carried a scenario about removing one family's models. Families are +removed, so removal no longer expands anything; the family-free rules are +restated by the "Removing a provider or model" requirement below. + +**Migration**: `outfit remove -p ` still removes the whole provider; +name a `--model` or `--alias` to remove exactly that one key. + +## ADDED Requirements + +### Requirement: Validating a selection + +A selection SHALL name a provider (`--provider`/`-p`), and applying one SHALL +additionally require at least one of a model or an alias. The named provider +MUST exist in the resolved catalogue. + +#### Scenario: Missing provider + +- **WHEN** the user runs `outfit add` without `--provider` +- **THEN** the command fails, pointing at `outfit list` + +#### Scenario: Provider alone is not enough to apply + +- **WHEN** the user runs `outfit add -p openrouter` with no model or alias +- **THEN** the command fails explaining a selection needs a model or an alias + +#### Scenario: Unknown provider + +- **WHEN** the selection names a provider not in the catalogue +- **THEN** the command fails naming the unknown id and pointing at + `outfit list` + +### Requirement: Selection model key + +The model key a harness stores a selection under SHALL be the alias when one is +given, otherwise the provider-native model id. An explicit `--model` SHALL be +configured and SHALL become the selection's default model. + +#### Scenario: Model becomes the default + +- **WHEN** the user runs `outfit add -p openrouter -m deepseek/deepseek-v4-pro` +- **THEN** `deepseek/deepseek-v4-pro` is configured and becomes the default + model + +#### Scenario: Alias keys the model + +- **WHEN** a selection includes `ALIAS qwen` for model `org/model:quant` +- **THEN** the harness stores the model under the key `qwen` + +### Requirement: Removing a provider or model + +`outfit remove` (and `unapply`) SHALL remove the whole provider when no model or +alias is given, and otherwise SHALL remove exactly the named model — an alias or +model id each name one key. The command SHALL report how many entries were +removed, and SHALL report "nothing to remove" (not an error) when none matched. + +#### Scenario: Removing a whole provider + +- **WHEN** the user runs `outfit remove -p ollama` +- **THEN** the provider block is removed from the harness config + +#### Scenario: Removing one model + +- **WHEN** the user runs `outfit remove -p openrouter -m deepseek/deepseek-v4-pro` +- **THEN** only that model is removed and the provider's other models survive + +#### Scenario: Nothing matched + +- **WHEN** the removal matches nothing in the harness config +- **THEN** the command reports there was nothing to remove and exits + successfully diff --git a/openspec/changes/archive/2026-07-29-retire-model-families/specs/shell-completion/spec.md b/openspec/changes/archive/2026-07-29-retire-model-families/specs/shell-completion/spec.md new file mode 100644 index 00000000..f52d5f60 --- /dev/null +++ b/openspec/changes/archive/2026-07-29-retire-model-families/specs/shell-completion/spec.md @@ -0,0 +1,60 @@ +## REMOVED Requirements + +### Requirement: Completion coverage + +**Reason**: The requirement listed family names among the completable values and +carried a scenario scoping model completion to a typed family. Families are +removed, so that value and scenario go; the family-free coverage rules are +restated by the "Completion surface coverage" requirement below. + +**Migration**: Completion offers provider, harness, alias, and shell values; +`--model`/`-m` has no static candidate source now (a follow-up change sources it +live from each provider). + +## ADDED Requirements + +### Requirement: Completion surface coverage + +Completion SHALL cover the full visible command surface: command names (the +hidden `__complete` excluded), each command's flags, its subcommands where it +has them, and context-aware values — provider names from the resolved catalogue +(honouring a `--providers` override already on the line), harness names, +registered alias names where an Outfit path is accepted, and the supported +shells for `completion`. The catalogue no longer enumerates models, so +`--model`/`-m` has no static candidate source; it SHALL still consume its value +so a following flag completes normally. For a command with subcommands, the +first positional slot SHALL offer those subcommands and any later slot SHALL fall +through to what the command otherwise accepts. Positional slots beyond a +command's arity SHALL offer nothing. + +#### Scenario: Unalias offers exactly the registered names + +- **WHEN** the user completes `outfit unalias ` +- **THEN** the registered alias names are offered with no file paths + +#### Scenario: New commands cannot be forgotten + +- **WHEN** a new subcommand is added to the CLI's dispatch +- **THEN** the completion surface must list it too (enforced by a test that + scans the dispatch) + +#### Scenario: A nested command offers its subcommands + +- **WHEN** the user completes `outfit remote ` +- **THEN** its subcommands are offered, with no file paths + +#### Scenario: After a subcommand, the Outfit slot completes + +- **WHEN** the user completes `outfit remote deploy ` +- **THEN** registered alias names and paths are offered + +#### Scenario: Providers complete from the catalogue + +- **WHEN** the user completes `outfit add -p ` +- **THEN** the catalogue's provider names are offered + +#### Scenario: The model flag has no static candidates but consumes its value + +- **WHEN** the user completes `outfit add -p openrouter -m ` +- **THEN** no model candidates are offered and no error occurs +- **AND** a flag typed after `--model ` still completes normally diff --git a/openspec/changes/archive/2026-07-29-retire-model-families/tasks.md b/openspec/changes/archive/2026-07-29-retire-model-families/tasks.md new file mode 100644 index 00000000..a16fc9a9 --- /dev/null +++ b/openspec/changes/archive/2026-07-29-retire-model-families/tasks.md @@ -0,0 +1,41 @@ +## 1. Catalogue data and API + +- [x] 1.1 Rewrite `internal/catalog/providers.yaml`: drop every `families:` block (models, defaultModel); keep only provider plumbing (description, name, npm, apiKeyEnv + required/optional/prefix, options, optionsFromEnv, pi). Update the file header comment to match. +- [x] 1.2 In `internal/catalog/catalog.go`, remove the `Family` type, `Provider.Families`, `MatchFamily`, `SortedFamilyNames`, and `Family.ModelKeys`. +- [x] 1.3 Drop the `familyName` parameter from `BuildProviderBlock` and `BuildPiProvider`; the model comes solely from `modelOverride`. Keep default-model resolution and alias-keying behaviour identical. + +## 2. Outfit format and selection + +- [x] 2.1 In `internal/outfit/outfit.go`, remove `kwFamily`, `Selection.Family`, the parse case, the `canonicalKeyword` entry, and the `FAMILY` line in `Format`. +- [x] 2.2 In `cmd/outfit/main.go`, remove the `--model-family`/`-f` flag binding and any use of `sel.Family`. + +## 3. Commands + +- [x] 3.1 Simplify `cmdList` (`cmd/outfit/main.go`) to print providers + description, api-key requirement, and supported harnesses — no family lines. +- [x] 3.2 Simplify `cmdExport` to name the configured model with a `MODEL` line (drop the `MatchFamily` branch; keep the `ModelKeys[0]` fallback as the sole path). +- [x] 3.3 Update the remove path so `-p` alone removes the whole provider and a model/alias removes one key; delete the family-expansion branch. +- [x] 3.4 Update `internal/harness/adapters.go` call sites to the new builder signatures. + +## 4. Completion + +- [x] 4.1 In `cmd/outfit/complete.go`, remove `kindFamily`, family completion, and the `--model-family` scoping in `modelNames`; complete models by `--provider` only (or drop model completion if no static source remains). + +## 5. Tests + +- [x] 5.1 Remove `TestMatchFamily`; update `catalog_test.go` for the new builder signatures. +- [x] 5.2 Update `outfit_test.go` (remove `FAMILY` parse/format cases; add a `MODEL`-based minimal-Outfit case). +- [x] 5.3 Update `main_test.go` (`TestCmdList` no longer asserts `family`/`default:`; export/remove tests use `-m`/`ALIAS`). +- [x] 5.4 Update `complete_test.go` to drop family-completion assertions. +- [x] 5.5 Run `go test ./... -cover`; confirm every package stays ≥ 80% and add a targeted single-model export/remove test if any dips. + +## 6. Docs + +- [x] 6.1 Update `docs/outfit-file.md` (remove `FAMILY` from the keyword table, rules, and examples; update the "at least one of" rule to `MODEL`/`ALIAS`). +- [x] 6.2 Update `docs/commands/list.md`, `docs/commands/export.md`, `docs/commands/add.md`, and `docs/commands/remove.md` where `FAMILY`/`--model-family`/`-f` appear. +- [x] 6.3 Update `AGENTS.md` if it documents families or the catalogue's model lists. + +## 7. Verify + +- [x] 7.1 `go build ./...`, `gofmt -l` clean, `go test ./... -cover` green. +- [x] 7.2 Round-trip check: apply `examples/llamacpp/qwen3.6/Outfit`, run `outfit export`, confirm it reproduces the selection with no `FAMILY`. +- [x] 7.3 `openspec validate retire-model-families` passes. diff --git a/openspec/specs/outfit-files/spec.md b/openspec/specs/outfit-files/spec.md index c66c6b52..f1af9c76 100644 --- a/openspec/specs/outfit-files/spec.md +++ b/openspec/specs/outfit-files/spec.md @@ -5,25 +5,23 @@ Define the `Outfit` file — a declarative, Dockerfile-style description of one provider selection — and the commands that consume and produce it: `outfit apply`, `outfit unapply`, and `outfit export`. - ## Requirements - ### Requirement: Outfit file format An Outfit SHALL be a flat, line-oriented text file of `KEYWORD value` -instructions. The keywords are `PROVIDER`, `FAMILY`, `MODEL`, `ALIAS`, -`CONTEXT`, `OUTPUT`, `BASEURL` (also accepted as `BASE-URL`, `BASE_URL`, or -`URL`), `PRESET`, and `REMOTE`. Keywords SHALL match case-insensitively, with -UPPERCASE as the canonical form. Blank lines, full-line `#` comments, and -trailing comments introduced by whitespace-then-`#` SHALL be ignored. Each -instruction SHALL take exactly one value and SHALL appear at most once. -`PROVIDER` is required. Parse errors SHALL name the offending line. +instructions. The keywords are `PROVIDER`, `MODEL`, `ALIAS`, `CONTEXT`, +`OUTPUT`, `BASEURL` (also accepted as `BASE-URL`, `BASE_URL`, or `URL`), +`PRESET`, and `REMOTE`. Keywords SHALL match case-insensitively, with UPPERCASE +as the canonical form. Blank lines, full-line `#` comments, and trailing +comments introduced by whitespace-then-`#` SHALL be ignored. Each instruction +SHALL take exactly one value and SHALL appear at most once. `PROVIDER` is +required. Parse errors SHALL name the offending line. #### Scenario: A minimal Outfit - **WHEN** a file containing only `PROVIDER openrouter` and - `FAMILY deepseek-v4` is parsed -- **THEN** it yields a selection of that provider and family + `MODEL deepseek/deepseek-v4-pro` is parsed +- **THEN** it yields a selection of that provider and model #### Scenario: Duplicate instruction @@ -96,8 +94,8 @@ by `outfit serve`. #### Scenario: Apply equals add -- **WHEN** an Outfit with `PROVIDER ollama` and `FAMILY llama` is applied -- **THEN** the harness config matches what `outfit add -p ollama -f llama` +- **WHEN** an Outfit with `PROVIDER ollama` and `MODEL llama3.2` is applied +- **THEN** the harness config matches what `outfit add -p ollama -m llama3.2` would have produced #### Scenario: Output override @@ -117,18 +115,16 @@ by `outfit serve`. config and print it to stdout. The provider exported is chosen by the `--provider`/`-p` flag, else the default model's provider, else the sole configured provider; with several providers and no way to choose, the command -SHALL fail listing them. The output SHALL name a family when the configured -models exactly match one, SHALL omit a `MODEL` that only restates that family's -default, SHALL omit a `BASEURL` that only restates the catalogue's default, and -SHALL record `CONTEXT`/`OUTPUT` only when the exported models agree on a single -value — never inventing one. Rendered output SHALL use canonical UPPERCASE -keywords with aligned values, so `outfit export > Outfit` round-trips. +SHALL fail listing them. The output SHALL name the configured model with a +`MODEL` instruction, SHALL omit a `BASEURL` that only restates the catalogue's +default, and SHALL record `CONTEXT`/`OUTPUT` only when the exported models agree +on a single value — never inventing one. Rendered output SHALL use canonical +UPPERCASE keywords with aligned values, so `outfit export > Outfit` round-trips. #### Scenario: Round-trip through export - **WHEN** the user applies an Outfit and then runs `outfit export` -- **THEN** the printed Outfit selects the same provider, family/model, and - limits +- **THEN** the printed Outfit selects the same provider, model, and limits #### Scenario: Ambiguous provider @@ -140,3 +136,4 @@ keywords with aligned values, so `outfit export > Outfit` round-trips. - **WHEN** the harness config has no providers - **THEN** the command fails naming the config file it read + diff --git a/openspec/specs/provider-catalog/spec.md b/openspec/specs/provider-catalog/spec.md index 5e214dc5..aa98f49b 100644 --- a/openspec/specs/provider-catalog/spec.md +++ b/openspec/specs/provider-catalog/spec.md @@ -2,64 +2,24 @@ ## Purpose -Define the catalogue of model providers and model families that `outfit` can -configure: where the catalogue comes from, what it declares, how users inspect -it (`outfit list`), and how they replace it with their own -(`outfit init-providers`, `--providers`, `OUTFIT_PROVIDERS`). - +Define the catalogue of model providers that `outfit` can configure: where the +catalogue comes from, what it declares (provider connection plumbing, not +models), how users inspect it (`outfit list`), and how they replace it with +their own (`outfit init-providers`, `--providers`, `OUTFIT_PROVIDERS`). ## Requirements - -### Requirement: Embedded provider catalogue - -The system SHALL ship with a built-in catalogue of providers and model -families, defined in a YAML file (`providers.yaml`) embedded into the binary at -build time. Each provider entry declares a description and MAY declare a -display name, an npm package, an API key environment variable (with optional -required flag, optional flag, and expected key prefix), static options (such as -`baseURL`), options resolved from environment variables, a `pi` block marking -the provider as usable by the Pi harness, and named model families. Each family -declares a description, a default model, and a set of models. - -A provider whose API key is declared optional is one that also works -unauthenticated — the same engine run as a local server and as an -authenticated remote endpoint — so an unset key variable SHALL mean "no key", -not "a key that is missing". - -#### Scenario: Catalogue loads without external files - -- **WHEN** any command that needs the catalogue runs with no `--providers` flag - and no `OUTFIT_PROVIDERS` environment variable -- **THEN** the embedded catalogue is used, with no file read from disk - -#### Scenario: Family default model is always a member of the family - -- **WHEN** the catalogue defines a model family -- **THEN** that family's `defaultModel` is one of the family's `models` keys - -#### Scenario: An optional key is injected only when set - -- **WHEN** a provider whose API key is optional is applied, with its key - variable set -- **THEN** the key is injected into the harness config - -#### Scenario: An optional key that is unset is not an error - -- **WHEN** the same provider is applied with the key variable unset -- **THEN** the configuration is written with no key, and the command succeeds - ### Requirement: Catalogue listing `outfit list` SHALL print every provider in the catalogue in stable (alphabetical) order, showing for each: its id and description, its API key -environment variable (marked `(required)` when the key is mandatory), the +environment variable (marked `(required)` when the key is mandatory), and the harnesses that support it (`opencode`, plus `pi` when the provider has a `pi` -block), and each model family with its description and default model. +block). #### Scenario: Listing the built-in catalogue - **WHEN** the user runs `outfit list` -- **THEN** every embedded provider is printed with its families, key - requirements, and supported harnesses +- **THEN** every embedded provider is printed with its key requirements and + supported harnesses ### Requirement: Runtime catalogue override @@ -97,3 +57,37 @@ On success it SHALL print how to point `outfit` at the written file. - **WHEN** the user runs `outfit init-providers custom.yaml` and no such file exists - **THEN** the embedded catalogue is written to `custom.yaml` byte-for-byte + +### Requirement: Embedded provider definitions + +The system SHALL ship with a built-in catalogue of providers, defined in a YAML +file (`providers.yaml`) embedded into the binary at build time. Each provider +entry declares a description and MAY declare a display name, an npm package, an +API key environment variable (with optional required flag, optional flag, and +expected key prefix), static options (such as `baseURL`), options resolved from +environment variables, and a `pi` block marking the provider as usable by the Pi +harness. The catalogue SHALL NOT enumerate models: the model a provider serves is +named by the user's selection, not stored in the catalogue. + +A provider whose API key is declared optional is one that also works +unauthenticated — the same engine run as a local server and as an +authenticated remote endpoint — so an unset key variable SHALL mean "no key", +not "a key that is missing". + +#### Scenario: Catalogue loads without external files + +- **WHEN** any command that needs the catalogue runs with no `--providers` flag + and no `OUTFIT_PROVIDERS` environment variable +- **THEN** the embedded catalogue is used, with no file read from disk + +#### Scenario: An optional key is injected only when set + +- **WHEN** a provider whose API key is optional is applied, with its key + variable set +- **THEN** the key is injected into the harness config + +#### Scenario: An optional key that is unset is not an error + +- **WHEN** the same provider is applied with the key variable unset +- **THEN** the configuration is written with no key, and the command succeeds + diff --git a/openspec/specs/provider-selection/spec.md b/openspec/specs/provider-selection/spec.md index 912b17c7..5ce3a225 100644 --- a/openspec/specs/provider-selection/spec.md +++ b/openspec/specs/provider-selection/spec.md @@ -2,58 +2,12 @@ ## Purpose -Define how a single provider selection — a provider plus an optional model -family, model, alias, context/output limits, and base URL — is validated and -applied to (or removed from) the active harness's config. This is the shared -core behind `outfit add`/`outfit remove` and the Outfit-file commands -`apply`/`unapply`, which route through the same logic. - +Define how a single provider selection — a provider plus a model and/or alias, +context/output limits, and base URL — is validated and applied to (or removed +from) the active harness's config. This is the shared core behind `outfit +add`/`outfit remove` and the Outfit-file commands `apply`/`unapply`, which route +through the same logic. ## Requirements - -### Requirement: Selection validation - -A selection SHALL name a provider (`--provider`/`-p`), and applying one SHALL -additionally require at least one of a model family, a model, or an alias. The -provider and any named family MUST exist in the resolved catalogue. - -#### Scenario: Missing provider - -- **WHEN** the user runs `outfit add` without `--provider` -- **THEN** the command fails, pointing at `outfit list` - -#### Scenario: Provider alone is not enough to apply - -- **WHEN** the user runs `outfit add -p openrouter` with no family, model, or - alias -- **THEN** the command fails explaining a selection needs a model family, a - model, or an alias - -#### Scenario: Unknown provider or family - -- **WHEN** the selection names a provider or family not in the catalogue -- **THEN** the command fails naming the unknown id and pointing at - `outfit list` - -### Requirement: Family expansion and default model - -Selecting a family SHALL configure all of that family's models and make the -family's default model the selection's default. An explicit `--model` SHALL be -added (even when not in the family) and SHALL become the default model instead. -The model key a harness stores a selection under SHALL be the alias when one is -given, otherwise the provider-native model id. - -#### Scenario: Family plus pinned model - -- **WHEN** the user runs - `outfit add -p openrouter -f deepseek-v4 -m deepseek/deepseek-v4-pro` -- **THEN** every model in the `deepseek-v4` family is configured and - `deepseek/deepseek-v4-pro` becomes the default model - -#### Scenario: Alias keys the model - -- **WHEN** a selection includes `ALIAS qwen` for model `org/model:quant` -- **THEN** the harness stores the model under the key `qwen` - ### Requirement: Context and output limits The system SHALL parse human-friendly token counts for the context window @@ -144,24 +98,75 @@ environment variable, then the catalogue's per-provider values. also set - **THEN** the configured base URL is `https://gateway/v1` -### Requirement: Removing a selection +### Requirement: Apply feedback + +After applying a selection the system SHALL report the config file written, the +provider configured, the default model when one was set, the resolved context +and output limits when set, and any harness-specific notes (key injection, base +URL, next steps). + +#### Scenario: Successful add + +- **WHEN** `outfit add -p openrouter -m deepseek/deepseek-v4-pro -c 128k` + succeeds +- **THEN** the output names the config path, the provider, the default model, + and the 128000/32000 token limits -`outfit remove` (and `unapply`) SHALL remove the whole provider when no family, -model, or alias is given, and otherwise SHALL remove exactly the named models — -a family expands to its catalogue models, and an alias or model id each name -one key. The command SHALL report how many entries were removed, and SHALL -report "nothing to remove" (not an error) when none matched. +### Requirement: Validating a selection + +A selection SHALL name a provider (`--provider`/`-p`), and applying one SHALL +additionally require at least one of a model or an alias. The named provider +MUST exist in the resolved catalogue. + +#### Scenario: Missing provider + +- **WHEN** the user runs `outfit add` without `--provider` +- **THEN** the command fails, pointing at `outfit list` + +#### Scenario: Provider alone is not enough to apply + +- **WHEN** the user runs `outfit add -p openrouter` with no model or alias +- **THEN** the command fails explaining a selection needs a model or an alias + +#### Scenario: Unknown provider + +- **WHEN** the selection names a provider not in the catalogue +- **THEN** the command fails naming the unknown id and pointing at + `outfit list` + +### Requirement: Selection model key + +The model key a harness stores a selection under SHALL be the alias when one is +given, otherwise the provider-native model id. An explicit `--model` SHALL be +configured and SHALL become the selection's default model. + +#### Scenario: Model becomes the default + +- **WHEN** the user runs `outfit add -p openrouter -m deepseek/deepseek-v4-pro` +- **THEN** `deepseek/deepseek-v4-pro` is configured and becomes the default + model + +#### Scenario: Alias keys the model + +- **WHEN** a selection includes `ALIAS qwen` for model `org/model:quant` +- **THEN** the harness stores the model under the key `qwen` + +### Requirement: Removing a provider or model + +`outfit remove` (and `unapply`) SHALL remove the whole provider when no model or +alias is given, and otherwise SHALL remove exactly the named model — an alias or +model id each name one key. The command SHALL report how many entries were +removed, and SHALL report "nothing to remove" (not an error) when none matched. #### Scenario: Removing a whole provider - **WHEN** the user runs `outfit remove -p ollama` - **THEN** the provider block is removed from the harness config -#### Scenario: Removing one family's models +#### Scenario: Removing one model -- **WHEN** the user runs `outfit remove -p openrouter -f deepseek-v4` -- **THEN** only that family's models are removed and the provider's other - models survive +- **WHEN** the user runs `outfit remove -p openrouter -m deepseek/deepseek-v4-pro` +- **THEN** only that model is removed and the provider's other models survive #### Scenario: Nothing matched @@ -169,15 +174,3 @@ report "nothing to remove" (not an error) when none matched. - **THEN** the command reports there was nothing to remove and exits successfully -### Requirement: Apply feedback - -After applying a selection the system SHALL report the config file written, the -provider (and family) configured, the default model when one was set, the -resolved context and output limits when set, and any harness-specific notes -(key injection, base URL, next steps). - -#### Scenario: Successful add - -- **WHEN** `outfit add -p openrouter -f deepseek-v4 -c 128k` succeeds -- **THEN** the output names the config path, provider and family, the default - model, and the 128000/32000 token limits diff --git a/openspec/specs/shell-completion/spec.md b/openspec/specs/shell-completion/spec.md index f731d99a..09a12fe0 100644 --- a/openspec/specs/shell-completion/spec.md +++ b/openspec/specs/shell-completion/spec.md @@ -5,9 +5,7 @@ Define tab completion: the `outfit completion ` scripts and the hidden `outfit __complete` protocol they call, which is the single source of truth for what completes to what across bash, zsh, and PowerShell. - ## Requirements - ### Requirement: Completion scripts `outfit completion ` SHALL print a completion script for `bash`, `zsh`, @@ -36,24 +34,20 @@ can never spew over the user's prompt. - **WHEN** outfit's own config file is unreadable and completion is attempted - **THEN** `__complete` exits zero with no candidates and no stderr output -### Requirement: Completion coverage +### Requirement: Completion surface coverage Completion SHALL cover the full visible command surface: command names (the hidden `__complete` excluded), each command's flags, its subcommands where it has them, and context-aware values — provider names from the resolved catalogue -(honouring a `--providers` override already on the line), family and model -names scoped to the `--provider` (and `--model-family`) already typed, harness -names, registered alias names where an Outfit path is accepted, and the -supported shells for `completion`. For a command with subcommands, the first -positional slot SHALL offer those subcommands and any later slot SHALL fall +(honouring a `--providers` override already on the line), harness names, +registered alias names where an Outfit path is accepted, and the supported +shells for `completion`. The catalogue no longer enumerates models, so +`--model`/`-m` has no static candidate source; it SHALL still consume its value +so a following flag completes normally. For a command with subcommands, the +first positional slot SHALL offer those subcommands and any later slot SHALL fall through to what the command otherwise accepts. Positional slots beyond a command's arity SHALL offer nothing. -#### Scenario: Families scoped to the typed provider - -- **WHEN** the user completes `outfit add -p openrouter -f ` -- **THEN** only the families of `openrouter` are offered - #### Scenario: Unalias offers exactly the registered names - **WHEN** the user completes `outfit unalias ` @@ -74,3 +68,15 @@ command's arity SHALL offer nothing. - **WHEN** the user completes `outfit remote deploy ` - **THEN** registered alias names and paths are offered + +#### Scenario: Providers complete from the catalogue + +- **WHEN** the user completes `outfit add -p ` +- **THEN** the catalogue's provider names are offered + +#### Scenario: The model flag has no static candidates but consumes its value + +- **WHEN** the user completes `outfit add -p openrouter -m ` +- **THEN** no model candidates are offered and no error occurs +- **AND** a flag typed after `--model ` still completes normally +