fix(providers): serve the full model catalog and stop reading API silence as no - #67
fix(providers): serve the full model catalog and stop reading API silence as no#67sosidudku1 wants to merge 2 commits into
Conversation
…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
left a comment
There was a problem hiding this comment.
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})` : ""; |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
Two small regressions from moving the window into renderPickList:
-
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. -
This step used to clamp with
Math.min(w.cursor, picks.length - 1)before rendering. Clamping now happens only insiderenderPickList, whileadvanceWizardPhasestill selects viamodels[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 |
There was a problem hiding this comment.
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.
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_PICKScut 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:
openai/chat-completionsinstead ofchat-completion, so the equality check matched zero rows;featuresarray 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 unknownlabels 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, andresponses/submitfamilies 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_parametersis present today.Review follow-up
The second commit addresses review findings. The 12-row viewport around the cursor now lives in
renderPickListitself, 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 indata, 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.resetModulesplus 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.
tscclean. 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.