Skip to content

An empty provider model refresh overwrites the cached model list, leaving the picker empty until the TTL expires #154

Description

@AWD-tech

Before filing

  • I searched open and closed issues for duplicates.
  • I reproduced this on the latest release (v0.6.2).
  • This is one bug, not several bundled together.

Closest existing issue

None found. The nearest neighbour is #140 (custom provider doesn't set Goose's default provider/model, leaving Goose unavailable) — also a "Goose ends up with no usable model" outcome, but that one is about provider setup not writing config, whereas this is the model cache being emptied after setup already succeeded. Different cause, different fix.

What's broken

An empty result from a provider model refresh overwrites the persisted model cache with models: [] and stamps it as freshly fetched. Because the entry then looks current for MODEL_CACHE_TTL_MS, the model picker shows no models for that provider and cannot self-correct — while the provider itself is fully configured and working.

I hit this with OpenRouter: the Goose picker listed zero OpenRouter models, even though the same OpenRouter key was successfully running chats, and Goose's own inventory DB held 335 models for it.

src/features/providers/stores/providerModelCacheStore.ts (a1a41ce), in refreshProviderModels:

const ids = await fetchProviderSupportedModels(providerId);   // L260 - can resolve to []
const discoveredModels = providerModelOptionsFromIds(providerId, ids);  // L261
// ...
const entry: CachedProviderModels = {
  providerId,
  models,                    // [] when the fetch came back empty
  fetchedAt: Date.now(),     // L285 - stamped fresh regardless
};

Nothing distinguishes "the provider genuinely has no models" from "this refresh returned nothing." An empty array is written over a previously good list and persisted to localStorage under goose:providerModelCache:v1.

isStale() (L130-137) then returns false for the next 5 minutes:

return !entry.runtimeManaged && Date.now() - entry.fetchedAt > MODEL_CACHE_TTL_MS;

so every subsequent refreshProviderModels call returns early at the if (!options.force && !isStale(existing)) return; guard. The picker stays empty until a forced refresh happens to land at a moment when the backend answers with a populated list.

The empty answer appears to come from the backend not having the provider ready when the refresh fires — goosed carries the string agent has no provider available for inventory refresh, which is a non-fatal condition that would surface as an empty list rather than a thrown error, so the catch path never runs and the success path clobbers the cache.

Steps to reproduce

  1. Launch Berd fresh.
  2. Configure OpenRouter as the Goose provider (Settings → AI Provider → Goose → OpenRouter) with a valid API key, and set a model (anthropic/claude-sonnet-4.5).
  3. Confirm it works: start a chat on Goose/OpenRouter and send a message — it responds normally.
  4. Quit and relaunch Berd.
  5. Open the model picker with Goose selected as the agent.

The picker lists models from other providers (in my case chatgpt_codex and ollama entries) but no OpenRouter models at all.

Inspecting the persisted cache while in this state:

sqlite3 ~/Library/WebKit/xyz.block.berd/WebsiteData/Default/*/*/LocalStorage/localstorage.sqlite3
# key goose:providerModelCache:v1  (values are UTF-16LE)
openrouter -> { models: [], fetchedAt: 1787204433054 }   # empty, stamped recent

while Goose's own inventory for the same provider was fully populated:

sqlite3 ~/.local/share/goose/sessions/sessions.db \
 "SELECT provider_id, COUNT(*) FROM provider_inventory_entries e
  JOIN provider_inventory_models m USING(inventory_key) GROUP BY provider_id;"
openrouter|335

I could not isolate a 100%-deterministic trigger for the empty fetch itself — that is the part I'm least sure of, and it's why "frequency" below is "intermittently". What is not in doubt is that once an empty result lands, the cache is overwritten and the TTL keeps it that way.

What you expected to happen

A refresh that returns no models should leave the last known-good list in place — the picker keeps showing the models that worked a moment ago, and the next refresh retries. An empty result should be treated as "no answer", not as "this provider has zero models".

What actually happened

The empty result replaced the cached list and was marked fresh, so the picker showed nothing for that provider and stopped retrying for the TTL window. Goose was effectively unusable from the picker despite being correctly configured — chats already pinned to a model kept working, which is what made it confusing.

How often does it happen?

Intermittently. It recurred across multiple relaunches over two days on v0.6.2 and did not clear on its own; it only came back after the cached entry was repopulated.

Berd version

0.6.2 (Settings → About; CFBundleShortVersionString 0.6.2, CFBundleVersion 0.6.2)

Operating system

macOS 26.4, Apple Silicon (arm64)

Model and provider

Goose agent, OpenRouter provider, model anthropic/claude-sonnet-4.5. Also reproduced with the picker showing codex-acp / ollama entries alongside the empty OpenRouter list.

Relevant log output

No relevant log output. ~/Library/Logs/xyz.block.berd/berd.log contains no entries for GooseUnstableProvidersSupportedModelsList, provider inventory refreshes, or the empty result — the refresh path is renderer-side and doesn't appear to log. Grepped for supported_models, inventory, providerModel, and refresh failed: no matches.

Worth noting: the backend log location in CONTRIBUTING.md (~/Library/Application Support/Block/goose/state/logs/) does not exist on this install, so I had no goosed server log to check either. The only Goose logs present are CLI ones under ~/.local/state/goose/logs/cli/.

The evidence above is therefore the persisted cache state and the code path rather than log lines — I'd rather say that than pad this section.

Suggested fix

Don't let an empty refresh destroy a populated cache. Roughly, around L260-L290:

const ids = await fetchProviderSupportedModels(providerId);
const discoveredModels = providerModelOptionsFromIds(providerId, ids);

// An empty discovery is "no answer", not "no models". Keep the previous
// list and leave the entry stale so the next refresh retries.
if (discoveredModels.length === 0 && (existing?.models.length ?? 0) > 0) {
  return;
}

If you'd rather keep a record of the attempt, setting error and leaving fetchedAt untouched would also work — the key point is that fetchedAt shouldn't be advanced for a result that carries no models, otherwise the TTL guard locks the empty state in.

A regression test would slot in beside the existing ones in providerModelCacheStore / useProviderModels.test.tsx: seed a provider with N models, make the fetch resolve [], assert the cached list is still N.

Other context

I'm aware you don't take outside PRs, so this is diagnosis only — happy to test a patch or gather more detail on request.

Separately, and deliberately not bundled here: Goose's inventory for OpenRouter holds 335 of the 417 models the OpenRouter /api/v1/models endpoint returns (the difference is models without tools support plus date-pinned duplicates). If that filtering isn't intentional I'm glad to file it on its own.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions