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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`. (#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`
Expand Down
4 changes: 4 additions & 0 deletions cmd/glue/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions cmd/glue/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
4 changes: 4 additions & 0 deletions cmd/glue/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <id>")
}
}
// Re-render the final assistant item through glamour so code blocks,
// lists, headings, and inline code settle into proper formatting after
Expand Down
7 changes: 4 additions & 3 deletions providers/gemini/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions providers/nvidia/nvidia.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions providers/openrouter/openrouter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions providers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <id>", 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.
Expand Down
27 changes: 27 additions & 0 deletions providers/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package providers

import (
"context"
"errors"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
Loading