diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f76242..375f1c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ and [`agents/peggy/CHANGELOG.md`](agents/peggy/CHANGELOG.md). ## Unreleased +- **Actionable model-unavailable errors + (`providers.ModelUnavailableHint`, `Factory.ModelsListURL`).** A 404 + / model-not-found failure previously surfaced as a raw provider + error with no recovery path (live case: NVIDIA's catalog lists + `moonshotai/kimi-k2.6` but some accounts 404 on it). One-shot runs + and the TUI now append which model was requested, that the account + may lack access, and how to pick another — including the provider's + model-catalog URL declared in the registry (gemini, nvidia, + openrouter). The TUI hint points at `/model `. (#367) + - **`glue run --continue` reopens the most recently updated session.** Resume previously existed only inside the TUI (`/resume` picker); from the CLI you had to remember the `--id` you used. `--continue` diff --git a/cmd/glue/run.go b/cmd/glue/run.go index b36ce1a..b0d1400 100644 --- a/cmd/glue/run.go +++ b/cmd/glue/run.go @@ -18,6 +18,7 @@ import ( "github.com/erain/glue" "github.com/erain/glue/cmd/glue/atmentions" "github.com/erain/glue/cmd/glue/tui" + "github.com/erain/glue/providers" filestore "github.com/erain/glue/stores/file" toolscoding "github.com/erain/glue/tools/coding" // Register the shipped providers so they resolve through the @@ -292,6 +293,9 @@ func runCommand(ctx context.Context, args []string, stdin io.Reader, stdout io.W if wroteDelta { fmt.Fprintln(stdout) } + if hint := providers.ModelUnavailableHint(providerName, effectiveModel, err); hint != "" { + return fmt.Errorf("%w\nglue run: %s", err, hint) + } return err } if wroteDelta { diff --git a/cmd/glue/run_test.go b/cmd/glue/run_test.go index 308adb7..52adda4 100644 --- a/cmd/glue/run_test.go +++ b/cmd/glue/run_test.go @@ -391,3 +391,20 @@ func TestRunCLIContinueEmptyStoreErrors(t *testing.T) { t.Fatalf("stderr = %q, want no sessions found", stderr.String()) } } + +func TestRunCLIModelNotFoundHint(t *testing.T) { + t.Parallel() + provider := &scriptedProvider{err: errors.New(`loop: provider stream: nvidia: http 404: {"status":404,"detail":"Function 'f': Not found for account 'a'"}`)} + var stdout, stderr bytes.Buffer + code := runCLI(context.Background(), []string{ + "run", "--provider", "nvidia", "--prompt", "hi", "--store", t.TempDir(), + }, &stdout, &stderr, fakeFactory(provider)) + if code == 0 { + t.Fatal("code = 0, want nonzero") + } + for _, want := range []string{"may be unavailable", "--model", "integrate.api.nvidia.com/v1/models"} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("stderr = %q, want hint containing %q", stderr.String(), want) + } + } +} diff --git a/cmd/glue/tui/tui.go b/cmd/glue/tui/tui.go index 7672ea5..6d1b8ee 100644 --- a/cmd/glue/tui/tui.go +++ b/cmd/glue/tui/tui.go @@ -21,6 +21,7 @@ import ( "github.com/erain/glue" "github.com/erain/glue/cmd/glue/atmentions" + "github.com/erain/glue/providers" ) // Config wires the TUI to a glue agent. Provider/Model/WorkDir are @@ -1198,6 +1199,9 @@ func (m *Model) handleTurnDone(msg turnDoneMsg) { } if msg.Err != nil { m.appendSystem("error: " + msg.Err.Error()) + if hint := providers.ModelUnavailableHint(m.cfg.Provider, m.cfg.Model, msg.Err); hint != "" { + m.appendSystem("hint: " + hint + " — switch with /model ") + } } // Re-render the final assistant item through glamour so code blocks, // lists, headings, and inline code settle into proper formatting after diff --git a/providers/gemini/gemini.go b/providers/gemini/gemini.go index 6987a3d..10ea4a1 100644 --- a/providers/gemini/gemini.go +++ b/providers/gemini/gemini.go @@ -43,9 +43,10 @@ const providerName = "gemini" func init() { providers.Register(providerName, providers.Factory{ - New: func() loop.Provider { return New(Options{}) }, - DefaultModel: DefaultModel, - EnvKey: EnvKey, + New: func() loop.Provider { return New(Options{}) }, + DefaultModel: DefaultModel, + EnvKey: EnvKey, + ModelsListURL: "https://generativelanguage.googleapis.com/v1beta/models", Capabilities: providers.Capabilities{ // Gemini 3.x Pro: 1M-token window; frontier model that // prefers terse steering; prone to the narrate-then-stop diff --git a/providers/nvidia/nvidia.go b/providers/nvidia/nvidia.go index 07c5a23..e200339 100644 --- a/providers/nvidia/nvidia.go +++ b/providers/nvidia/nvidia.go @@ -26,9 +26,10 @@ const ( func init() { providers.Register(providerName, providers.Factory{ - New: func() loop.Provider { return New(Options{}) }, - DefaultModel: DefaultModel, - EnvKey: EnvKey, + New: func() loop.Provider { return New(Options{}) }, + DefaultModel: DefaultModel, + EnvKey: EnvKey, + ModelsListURL: "https://integrate.api.nvidia.com/v1/models", Capabilities: providers.Capabilities{ // Open-weight hosting; window varies by model — 128k is a // safe floor. Default (explicit) prompt variant; sequential diff --git a/providers/openrouter/openrouter.go b/providers/openrouter/openrouter.go index b961686..9059e26 100644 --- a/providers/openrouter/openrouter.go +++ b/providers/openrouter/openrouter.go @@ -35,9 +35,10 @@ const ( func init() { providers.Register(providerName, providers.Factory{ - New: func() loop.Provider { return New(Options{}) }, - DefaultModel: DefaultModel, - EnvKey: EnvKey, + New: func() loop.Provider { return New(Options{}) }, + DefaultModel: DefaultModel, + EnvKey: EnvKey, + ModelsListURL: "https://openrouter.ai/api/v1/models", Capabilities: providers.Capabilities{ // Aggregator of mostly open-weight models; window varies — // 128k is a safe floor. Default (explicit) prompt variant. diff --git a/providers/registry.go b/providers/registry.go index 046b313..c62929a 100644 --- a/providers/registry.go +++ b/providers/registry.go @@ -58,6 +58,11 @@ type Factory struct { // APIKey is empty. Used by KeyAvailable. EnvKey string + // ModelsListURL is the provider's model-catalog endpoint, used in + // recovery hints when a request fails because the selected model is + // unavailable. Empty when the provider has no such endpoint. + ModelsListURL string + // Capabilities declares harness-relevant facts about the // provider's models. Optional; the zero value means unknown. Capabilities Capabilities @@ -120,6 +125,29 @@ func Known() []string { return out } +// ModelUnavailableHint returns actionable advice when err looks like a +// model-not-found failure from the named provider — the model id was +// wrong, or the account does not have access to it (some catalogs list +// models the account still 404s on). Returns "" when err does not look +// model-shaped, so callers can append the hint unconditionally. +func ModelUnavailableHint(providerName, model string, err error) string { + if err == nil { + return "" + } + msg := strings.ToLower(err.Error()) + if !strings.Contains(msg, "404") && + !strings.Contains(msg, "not found") && + !strings.Contains(msg, "does not exist") && + !strings.Contains(msg, "model_not_found") { + return "" + } + hint := fmt.Sprintf("model %q may be unavailable on your %s account; pick another with --model ", model, providerName) + if f, ok := Lookup(providerName); ok && f.ModelsListURL != "" { + hint += fmt.Sprintf(" (list models: %s)", f.ModelsListURL) + } + return hint +} + // KeyAvailable reports whether the registered provider's env var is // non-empty in the process environment. Returns false for unknown // providers and for providers registered without an EnvKey. diff --git a/providers/registry_test.go b/providers/registry_test.go index b4e447a..141eb87 100644 --- a/providers/registry_test.go +++ b/providers/registry_test.go @@ -2,6 +2,7 @@ package providers import ( "context" + "errors" "strings" "testing" @@ -106,3 +107,29 @@ func TestCapabilitiesForRegistered(t *testing.T) { t.Fatalf("caps = %#v", caps) } } + +func TestModelUnavailableHint(t *testing.T) { + Register("hinted", Factory{ + DefaultModel: "m-default", + ModelsListURL: "https://example.test/v1/models", + }) + + err := errors.New(`provider stream: hinted: http 404: {"status":404,"detail":"Function 'x': Not found for account 'y'"}`) + hint := ModelUnavailableHint("hinted", "some/model", err) + for _, want := range []string{`"some/model"`, "hinted", "--model", "https://example.test/v1/models"} { + if !strings.Contains(hint, want) { + t.Errorf("hint missing %q: %s", want, hint) + } + } + + if got := ModelUnavailableHint("hinted", "m", errors.New("http 429: overloaded")); got != "" { + t.Errorf("non-404 error should give no hint, got %q", got) + } + if got := ModelUnavailableHint("hinted", "m", nil); got != "" { + t.Errorf("nil error should give no hint, got %q", got) + } + // Unknown provider still hints, just without a catalog URL. + if got := ModelUnavailableHint("mystery", "m", err); got == "" || strings.Contains(got, "http") { + t.Errorf("unknown-provider hint wrong: %q", got) + } +}