Skip to content

fix(providers): serve the full model catalog and stop reading API silence as no - #67

Open
sosidudku1 wants to merge 2 commits into
mainfrom
feat/full-provider-catalogs
Open

fix(providers): serve the full model catalog and stop reading API silence as no#67
sosidudku1 wants to merge 2 commits into
mainfrom
feat/full-provider-catalogs

Conversation

@sosidudku1

@sosidudku1 sosidudku1 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Reported internally: the cloud model picker offers only a handful of models. Against the live APIs today the picker shows 12 of 305 usable OpenRouter models and 13 of 337 aimlapi models.

Three independent causes.

1. The live catalogs were capped

MAX_PICKS cut the list to 12 (OpenRouter) and 32 (aimlapi) after the curated ids. That cap made sense for an unfilterable list; it silently hides most of the catalog. Both caps are gone. Curated ids keep their hand-picked order, the OpenRouter tail keeps its score order, and the aimlapi tail is now sorted alphabetically instead of following map insertion order.

2. aimlapi changed its response shape, and the parser matched nothing

Two breaking changes on their side, neither of which failed loudly:

  • rows are now typed openai/chat-completions instead of chat-completion, so the equality check matched zero rows;
  • the features array that advertised tool support was removed entirely, so the "can this model call tools" check rejected everything that survived.

The net effect was a live refresh that always returned nothing and fell back to the 13-entry offline list, which also explains the price unknown labels in the picker.

Type matching is now suffix-based (*/chat-completions, plus the older spellings), which survives another vendor-prefix rename while still excluding the video, image, anthropic/messages, and responses/submit families that share the same payload.

3. Absent was read as negative

Both fetchers now distinguish "the API says this model has no tools" from "the API says nothing". Only the former drops a model. This is the generic form of the aimlapi breakage: a provider dropping a capability field can no longer empty our catalog. OpenRouter has the same guard even though its supported_parameters is present today.

Review follow-up

The second commit addresses review findings. The 12-row viewport around the cursor now lives in renderPickList itself, so the OpenRouter and aimlapi picker phases window their rows instead of painting the full 300+ row catalog into the terminal, with a position counter once the list outgrows the window. Both fetchers skip null and scalar rows in data, so one bad row can no longer throw and drag the whole live catalog into the static fallback. The fallback tests now reset the module-level cache (vi.resetModules plus a dynamic import) and assert the exact offline list, so they test the fallback rather than a leftover cache. The stale JSDoc describing the removed type/features filter is rewritten, and a comment explains why vision silence deliberately still reads as "no vision".

Verification

Ran the parsers against the live payloads captured from both providers: 337 aimlapi picks (was 13) and 305 OpenRouter picks (was 12).

Tests: 16 new across both commits. Cap removal on both providers, offline fallback on network error and on a malformed payload (against a fresh module cache), null rows skipped, the current aimlapi shape verbatim (including the non-chat families that must stay out), explicit no-tools still dropped, silent-API models kept, rows without a usable id ignored, alphabetical tail ordering, and 12-row windowing of 300+ model lists in every wizard picker phase. tsc clean. Full suite shows the same pre-existing failures as main.

Note

Until the picker gains text filtering, these lists are long. That work is the companion branch; merging this one first is fine, since the wizard picker now windows its rows too.

…ence as no

The cloud picker showed 12 OpenRouter models out of 305 usable ones, and
13 aimlapi models out of 337, because the live catalogs were capped and,
for aimlapi, never parsed at all.

- drop MAX_PICKS in both fetchers (12 for OpenRouter, 32 for aimlapi):
  with a filterable picker a long list costs nothing, while a capped one
  silently hides most of the catalog. Curated ids keep their order, the
  aimlapi tail is now alphabetical instead of map order
- aimlapi changed its response shape: rows are typed
  openai/chat-completions rather than chat-completion, so the type filter
  matched nothing and every chat model was dropped. Matching is now on
  the suffix, which also covers the older spelling and survives another
  vendor-prefix rename, while still excluding the video, image, messages
  and responses families in the same payload
- aimlapi also removed the features array that advertised tool support.
  Both fetchers now separate absent from negative: a model is dropped
  only when the API explicitly says it cannot call tools, so a provider
  dropping a capability field can no longer empty the catalog
- verified against the live payloads: 337 aimlapi picks and 305
  OpenRouter picks, up from 13 and 12

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g fetchers

Review follow-up for the full-catalog change: a 300+ row catalog exposed
paths that were fine at 12 rows.

- the 12-row viewport lived only in the openai-compatible step; the
  OpenRouter and aimlapi pick_chat_model phases still rendered the whole
  catalog into the terminal. The windowing now lives in renderPickList
  itself, so every picker phase shares it, with a position counter in
  the hint once the list outgrows the window
- both fetchers skip null and scalar rows in `data`: one bad row used to
  throw and drag the whole live catalog into the static fallback
- the fallback tests passed against a leftover module-level cache from
  earlier tests and proved nothing; they now reset the module cache
  (vi.resetModules plus a dynamic import) and assert the exact offline
  list
- rewrite the stale JSDoc on refreshAimlapiChatCatalogFromApi that still
  described the type/features filter this PR removed, and note why
  vision silence deliberately reads as "no vision" while tool silence
  does not read as "no tools"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@Ooooze Ooooze left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice diagnosis — three genuinely independent causes, and the fixes are structural rather than patches. Suffix matching on type, keeping undefined apart from "none", skipping null rows, and vi.resetModules() in the fallback tests (which previously asserted against a leftover cache and proved nothing) are all the right calls.

Two things I'd resolve before this lands, both consequences of removing the cap rather than problems with the parsing work itself.

Model option lists are O(n^2) per render.
src/tui/providers/providers-model-options.ts is not in this diff, but it is where the cap removal bites: formatOpenRouterChatModelDetails / formatAimlapiChatModelDetails resolve their entry with a linear listXChatPicks().find(...), once per row, for a list that is now 305-337 rows instead of 12. That is ~113k comparisons per frame, re-run on every j/k keystroke, and the window in renderPickList does not help because it slices after every label is built. Swapping the .find for a Map<id, entry> lookup is a one-line change and keeps the picker responsive.

Reaching a model 250 rows down.
providers-wizard-key-bindings.ts only advances one row at a time ((cursor + 1) % len) — no PageUp, no Home, no jump. Windowing makes a 305-row catalog renderable, not navigable. Either land this together with #68 (typed filtering) or add a page-jump here; on its own this is arguably worse to operate than the curated 12.

On the description rather than the code: scoreChat still returns -1 for anthropic/* and /gemini/i, and the existing test pins that. So "the full model catalog" is the full catalog minus Claude and Gemini — which is what most people will look for first. Worth calling that exclusion out explicitly (and why it exists) so it doesn't come back as a follow-up issue.

Math.max(0, total - PICK_WINDOW),
);
const visible = options.slice(start, start + PICK_WINDOW);
const position = total > PICK_WINDOW ? ` (${clamped + 1}/${total})` : "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windowing here fixes the rendering cost but not the build cost. Both cloud phases call listOpenRouterChatModels() / listAimlapiChatModels() in the component body, which formats a label for all 337 rows before we slice 12 of them — and each label goes through formatAimlapiChatModelDetails, which does a linear listAimlapiChatPicks().find(...). That is ~113k comparisons per frame (it was 12x12 before the cap came off), re-run on every j/k keystroke.

Cheapest fix is in providers-model-options.ts: build a Map<id, entry> once instead of .find per row in resolveOpenRouterCatalogEntry / resolveAimlapiCatalogEntry. Alternatively pass the window offset down so the label formatting only runs for visible rows.

Also: with these additions the file is 312 lines, past the 300-line ceiling in AGENTS.md. renderPickList + PICK_WINDOW look like the natural extraction into their own module.

cursor - start,
`↑/↓ move (${cursor + 1}/${picks.length}) · Enter select · type to enter an id by hand · Esc cancel`,
picks.map((id) => ({ label: id })),
w.cursor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small regressions from moving the window into renderPickList:

  1. The position counter is now conditional on total > PICK_WINDOW, so this step loses the (5/30) readout it always showed for short lists, and for long ones the counter moved from the head of the hint to its tail.

  2. This step used to clamp with Math.min(w.cursor, picks.length - 1) before rendering. Clamping now happens only inside renderPickList, while advanceWizardPhase still selects via models[wizard.cursor] ?? models[0]. If the cursor ever outruns the list (catalog shrinking under a refresh between renders), the last row is highlighted but the first one is picked. Worth clamping the cursor in state rather than at paint time.

}

function readVisionSupport(m: AimlapiApiModel): boolean {
// Unlike tools, silence deliberately reads as "no vision": overclaiming

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says underclaiming "only hides a badge", but supportsVision flows into ModelCatalogEntry -> ProviderCapabilities, and registerVisionTools / LlamaServerProvider.describeImage gate on it. So for any live id not covered by the static catalog, vision silence does not hide a badge — it disables vision.describe for that model entirely. Since features is gone from the current response, that is now every new live id.

Curated ids are safe (entryFromLiveModel returns the static entry), so this is not a regression against today's picker, but the reasoning in the comment undersells the consequence.

Related: this PR adds tags?: readonly string[] to AimlapiApiModel and never reads it. Either derive the capability signal from tags (it carries playground:chat and friends) or drop the field.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants