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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ it dresses the agent for you:

- **One command, any model.** Pick from a built-in catalogue — OpenRouter,
Bedrock, Ollama, llama.cpp, vLLM, oMLX, or any OpenAI-compatible endpoint. No
URLs or model ids to look up.
URLs to look up, and `outfit list --models` fetches the model ids straight from
the provider.
- **Your config survives.** Settings are merged *into* what you already have.
Other providers, your theme, even your comments stay exactly where you left them.
- **Keys stay where they belong.** Secrets are read from a local `.env` and never
Expand Down Expand Up @@ -86,6 +87,12 @@ See what's in the catalogue:
outfit list
```

Need a model id? Ask the provider itself — no memorising, no guessing:

```sh
outfit list --models openrouter # the models it currently serves, live
```

Add a provider and a model:

```sh
Expand Down Expand Up @@ -143,7 +150,7 @@ quarter of the context for you. It can't exceed the context window.
## Usage

```sh
outfit list
outfit list [--models [<provider>]] # the catalogue; --models fetches live model ids
outfit show [--harness <name>] # show what the harness has configured
outfit add --provider <name> [--model <id>] [--alias <name>] [--context <size>] [--output <size>] [--base-url <url>]
outfit remove --provider <name> [--model <id>] [--alias <name>]
Expand Down
36 changes: 33 additions & 3 deletions cmd/outfit/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import (

"github.com/lucinate-ai/outfit/internal/catalog"
"github.com/lucinate-ai/outfit/internal/config"
"github.com/lucinate-ai/outfit/internal/discovery"
"github.com/lucinate-ai/outfit/internal/harness"
"github.com/lucinate-ai/outfit/internal/opencode"
)

//go:embed completion.bash
Expand Down Expand Up @@ -61,6 +63,7 @@ const (
kindAliasOnly // registered aliases; a path would be meaningless
kindHarness
kindProvider
kindModel
kindShell
)

Expand Down Expand Up @@ -89,11 +92,11 @@ var selectionFlags = []string{
}

// 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.
// static source in the catalogue; --model/-m completes from live per-provider
// discovery, scoped to the --provider already on the line.
var selectionValues = map[string]candidateKind{
"--provider": kindProvider, "-p": kindProvider,
"--model": kindNone, "-m": kindNone,
"--model": kindModel, "-m": kindModel,
"--alias": kindNone, "-a": kindNone,
"--context": kindNone, "-c": kindNone,
"--output": kindNone, "-o": kindNone,
Expand Down Expand Up @@ -307,6 +310,19 @@ func candidatesFor(kind candidateKind, words []string) ([]string, string) {
return nil, directiveNoFile
}
return cat.SortedProviderNames(), directiveNoFile
case kindModel:
p := providerOn(words)
if p == nil {
return nil, directiveNoFile
}
// Best-effort: source models live from the provider's endpoint. Any
// failure (offline, timeout, no key) yields no candidates, keeping
// completion quiet.
models, err := discovery.Models(p, "", opencode.EnvResolver(""))
if err != nil {
return nil, directiveNoFile
}
return models, directiveNoFile
case kindShell:
return completionShells, directiveNoFile
default:
Expand All @@ -330,6 +346,20 @@ 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]
}

// 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.
Expand Down
33 changes: 29 additions & 4 deletions cmd/outfit/complete_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -231,21 +233,44 @@ func TestComplete_EqualsForm(t *testing.T) {
func TestComplete_ModelValues(t *testing.T) {
isolateConfig(t)

// No static model source, so no candidates and no error.
got, directive := complete(t, "add", "-p", "openrouter", "-m", "")
// amazon-bedrock has no models endpoint (no base URL), so discovery makes no
// network call and offers nothing — the offline, no-candidates path.
got, directive := complete(t, "add", "-p", "amazon-bedrock", "-m", "")
if len(got) != 0 {
t.Errorf("expected no model candidates, got %v", got)
t.Errorf("expected no candidates for a non-discoverable provider, got %v", got)
}
if directive != directiveNoFile {
t.Errorf("directive = %q, want %q", directive, directiveNoFile)
}

// -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") {
if got, _ := complete(t, "add", "-p", "amazon-bedrock", "-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)
}
}

// TestComplete_ModelDiscovery checks that --model/-m offers the models a
// provider's endpoint currently serves, sourced live from a stub.
func TestComplete_ModelDiscovery(t *testing.T) {
isolateConfig(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[{"id":"disc-a"},{"id":"disc-b"}]}`))
}))
defer srv.Close()

dir := t.TempDir()
provPath := filepath.Join(dir, "providers.yaml")
mustWrite(t, provPath, "providers:\n stub:\n description: Stub\n npm: \"@ai-sdk/openai-compatible\"\n options:\n baseURL: "+srv.URL+"\n")

got, directive := complete(t, "add", "--providers", provPath, "-p", "stub", "-m", "")
if !hasAll(got, "disc-a", "disc-b") {
t.Errorf("discovered models not offered: %v", got)
}
if directive != directiveNoFile {
t.Errorf("directive = %q, want %q", directive, directiveNoFile)
}
}

// TestComplete_AttachedEqualsForm checks the same flag=value case as it arrives
// from zsh and PowerShell, which pass `--outfit=qw` as a single word rather than
// splitting it on "=" the way bash does.
Expand Down
32 changes: 31 additions & 1 deletion cmd/outfit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import (
"github.com/lucinate-ai/outfit/internal/catalog"
"github.com/lucinate-ai/outfit/internal/config"
"github.com/lucinate-ai/outfit/internal/contextsize"
"github.com/lucinate-ai/outfit/internal/discovery"
"github.com/lucinate-ai/outfit/internal/harness"
"github.com/lucinate-ai/outfit/internal/opencode"
"github.com/lucinate-ai/outfit/internal/outfit"
Expand Down Expand Up @@ -1114,7 +1115,9 @@ func cmdShow(args []string) error {
func cmdList(args []string) error {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
var providers string
var showModels bool
fs.StringVar(&providers, "providers", "", "path to a providers.yaml override")
fs.BoolVar(&showModels, "models", false, "also fetch each provider's current models live from its endpoint")
if err := fs.Parse(args); err != nil {
return err
}
Expand All @@ -1123,9 +1126,27 @@ func cmdList(args []string) error {
if err != nil {
return err
}

// An optional positional argument narrows the listing to one provider, which
// is the natural way to ask for a single provider's live models.
names := cat.SortedProviderNames()
if rest := fs.Args(); len(rest) > 0 {
if _, ok := cat.Providers[rest[0]]; !ok {
return fmt.Errorf("unknown provider %q (see `outfit list`)", rest[0])
}
names = []string{rest[0]}
}

// Only resolve keys and hit the network when --models is asked for; a plain
// list stays entirely offline.
var resolve func(string) string
if showModels {
resolve = opencode.EnvResolver("")
}

var b strings.Builder
b.WriteString("Available providers:\n")
for _, name := range cat.SortedProviderNames() {
for _, name := range names {
p := cat.Providers[name]
fmt.Fprintf(&b, "\n %s — %s\n", name, p.Description)
if p.APIKeyEnv != "" {
Expand All @@ -1140,6 +1161,15 @@ func cmdList(args []string) error {
harnesses = "opencode, pi"
}
fmt.Fprintf(&b, " harnesses: %s\n", harnesses)
if showModels {
if models, err := discovery.Models(p, "", resolve); err == nil && len(models) > 0 {
for _, m := range models {
fmt.Fprintf(&b, " model %s\n", m)
}
} else {
b.WriteString(" models: (none found)\n")
}
}
}
fmt.Print(b.String())
return nil
Expand Down
50 changes: 50 additions & 0 deletions cmd/outfit/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"

"github.com/lucinate-ai/outfit/internal/catalog"
Expand Down Expand Up @@ -479,6 +482,53 @@ func TestCmdList(t *testing.T) {
}
}

// TestCmdList_Models covers `outfit list --models`: it fetches each provider's
// current models live, and a plain `outfit list` makes no network request.
func TestCmdList_Models(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
_, _ = w.Write([]byte(`{"data":[{"id":"model-b"},{"id":"model-a"}]}`))
}))
defer srv.Close()

dir := t.TempDir()
provPath := filepath.Join(dir, "providers.yaml")
mustWrite(t, provPath, "providers:\n stub:\n description: Stub provider\n npm: \"@ai-sdk/openai-compatible\"\n options:\n baseURL: "+srv.URL+"\n")

// --models on a single provider prints its live models (sorted).
out := captureStdout(t, func() {
if err := cmdList([]string{"--providers", provPath, "--models", "stub"}); err != nil {
t.Fatalf("cmdList --models: %v", err)
}
})
if !strings.Contains(out, "model model-a") || !strings.Contains(out, "model model-b") {
t.Errorf("discovered models missing from output:\n%s", out)
}
if atomic.LoadInt32(&hits) == 0 {
t.Error("expected --models to make a discovery request")
}

// A plain list makes no network request at all.
before := atomic.LoadInt32(&hits)
out = captureStdout(t, func() {
if err := cmdList([]string{"--providers", provPath}); err != nil {
t.Fatalf("cmdList: %v", err)
}
})
if atomic.LoadInt32(&hits) != before {
t.Error("a plain `outfit list` should not hit the network")
}
if strings.Contains(out, "model model-a") {
t.Errorf("plain list should not print models:\n%s", out)
}

// An unknown provider positional is an error.
if err := cmdList([]string{"--providers", provPath, "nope"}); err == nil {
t.Error("expected an error for an unknown provider filter")
}
}

func TestCmdInitProviders_EndToEnd(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "providers.yaml")
Expand Down
13 changes: 13 additions & 0 deletions docs/commands/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,36 @@ needs (if any), and which harnesses support it.
```sh
outfit list
outfit list --providers ./my-providers.yaml # a custom catalogue
outfit list --models # add each provider's live models
outfit list --models openrouter # just one provider's models
```

This is the *could* view — what's available to configure. For what your agent
*currently has* configured, see [`outfit show`](show.md).

The catalogue names providers, not models — models change too often to curate.
`--models` fills that gap on demand: it asks each provider's own endpoint what it
currently serves (OpenRouter, an OpenAI-compatible gateway, a local llama.cpp or
Ollama server, …) and lists the ids you can drop into an `Outfit`'s `MODEL`. A
positional provider name narrows the query to one provider.

## Flags

| Flag | Meaning |
| ---- | ------- |
| `--providers` | Path to a custom catalogue (or set `OUTFIT_PROVIDERS`) — see [`outfit init-providers`](init-providers.md) |
| `--models` | Also fetch each listed provider's current models, live from its endpoint |

## Notes

- A provider marked with a required API key won't configure until that
variable is set in a `.env` beside your `Outfit`, or in your environment.
- Not every provider maps to every harness — the listing names which harnesses
each supports (AWS Bedrock, for instance, is opencode-only).
- `--models` is best-effort: it uses the same base URL and key a selection
would, applies a short timeout, and prints `(none found)` when a provider is
unreachable or has no queryable endpoint (AWS Bedrock has none). A plain
`outfit list` makes no network request.

## See also

Expand Down
7 changes: 7 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ outfit list
That's the catalogue: every provider, the API key it needs (if any), and which
harnesses support it.

Need a model id to go with a provider? Add `--models` and `outfit` asks the
provider's own endpoint what it currently serves:

```sh
outfit list --models openrouter
```

## 3. Dress your agent

Pick a provider and model. For a hosted one, drop the key in a `.env` or
Expand Down
4 changes: 3 additions & 1 deletion docs/outfit-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ 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, run `outfit list`.
To see the available providers, run `outfit list`. To find a `MODEL` id for one,
run `outfit list --models <provider>`, which asks the provider's own endpoint
what it currently serves.

## Examples

Expand Down
Loading