Skip to content

fix: searchable, grouped model picker for large cloud provider lists#1002

Open
gabrielste1n wants to merge 5 commits into
mainfrom
fix/cloud-model-picker-search
Open

fix: searchable, grouped model picker for large cloud provider lists#1002
gabrielste1n wants to merge 5 commits into
mainfrom
fix/cloud-model-picker-search

Conversation

@gabrielste1n

@gabrielste1n gabrielste1n commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

OpenAI-compatible providers like OpenRouter expose 300–400 models. The custom-provider panel rendered them as a single flat, unsearchable list — no way to find a model, no grouping, long descriptions overflowed, and there was no visible indication of which model was selected. (Fixes #999)

Changes

  • New SearchableModelList used by OpenAICompatiblePanel when a provider returns more than 12 models:
    • Search input filtering on slug, name, and description
    • Grouping by provider prefix (the string before /: anthropic, meta-llama, …), with per-group counts; ungrouped ids fall into "Other"
    • Virtualized rows via @tanstack/react-virtual (already a dependency) so 400 models stay smooth
    • A pinned "Selected" group so the active model is always visible regardless of scroll/search
  • Extracted a reusable ModelCard from ModelCardList so both the plain and virtualized lists render identical cards; added an opt-in truncateDescription so long descriptions ellipsize instead of overflowing
  • Smaller endpoints (Together, Ollama, etc.) keep the existing plain list — no behavior change
  • i18n keys added across all 10 locales

Non-breaking

  • All 6 existing ModelCardList callers pass no truncateDescription → identical rendered output
  • No IPC / native / platform-specific code — pure renderer; identical on macOS/Windows/Linux

Verification

  • tsc --noEmit, eslint, prettier --check, and i18n:check all pass
  • main merged in; clean (no conflicts)

Not yet exercised against a live OpenRouter endpoint (requires an API key + the real 300-model response).


Follow-up commits

OpenRouter as a top-level provider

OpenRouter is now its own provider tab (before Custom) so users don't have to use the Custom field. It reuses the OpenAI-compatible panel pinned to OpenRouter's endpoint and persists as the existing custom provider + base URL — identical to manually configuring Custom with OpenRouter's URL, so there are no inference / secret / IPC changes. The searchable, grouped list and per-row provider icons apply automatically. The panel gained lockedBaseUrl (hide the endpoint editor when the URL is fixed) and a "Get an API key" link. The Custom tab is unchanged.

UTM attribution on partner links

Outbound links to partner sites (BYOK key pages, HuggingFace model pages) are now UTM-tagged so those companies can attribute the traffic to OpenWhispr, via a shared GetApiKeyLink component and a withUtm helper. withUtm only touches http(s) URLs and never overwrites an existing utm_source, so OS-setting/mailto: links and Corti's pre-existing tag are left intact. Own-site links (*.openwhispr.com) are deliberately not tagged.

OpenAI-compatible providers like OpenRouter expose 300-400 models, which
the custom-provider panel rendered as a single flat, unsearchable list —
no way to find a model, no grouping, and no visible selection.

- Add SearchableModelList: search filter, grouping by provider prefix,
  virtualized rows (@tanstack/react-virtual), and a pinned "Selected"
  group so the active model is always visible.
- OpenAICompatiblePanel switches to it above 12 models; smaller lists keep
  the plain list.
- Extract a reusable ModelCard from ModelCardList and add an opt-in
  truncateDescription so long descriptions ellipsize instead of overflowing.
- Add i18n keys across all locales.

Closes #999
The group header already names the provider, so drop the "provider/" prefix
from each row's label (openai/gpt-5.5 -> gpt-5.5) and show the provider's icon
instead, matching the built-in provider cards.

- Add getRemoteProviderIcon() resolving a remote prefix to our existing icons,
  with aliases (google -> gemini, meta-llama -> llama, mistralai -> mistral,
  x-ai -> xai) and "~"-variant normalization; unknown providers keep the globe.
- Merge "~"-prefixed variant groups into their base provider group.

The OpenRouter /models response carries no icon field, so icons are derived
locally from the provider prefix.
Add OpenRouter as its own top-level cloud provider tab (before Custom) so
users no longer have to use the Custom field. It reuses the OpenAI-compatible
panel pinned to OpenRouter's endpoint and persists as the existing custom
provider + base URL, so it needs no inference/secret/IPC changes; the
searchable/grouped model list and per-row provider icons apply automatically.
The panel gains lockedBaseUrl (hide the endpoint editor) and a get-key link.

Tag outbound partner links (BYOK key pages, HuggingFace model pages) with
OpenWhispr UTM attribution via a shared GetApiKeyLink component and a withUtm
helper. withUtm only touches http(s) URLs and never overwrites an existing
utm_source, so OS-setting/mailto links and Corti's existing tag stay intact.
@xAlcahest

Copy link
Copy Markdown
Collaborator

Took a careful pass over this. The feature itself is clean. Most of what I found is edge-case polish, plus one credential-routing thing I'd fix before merge.

Worth fixing

The API key gets sent to the wrong provider when you switch between the OpenRouter and Custom tabs. Both tabs render the same OpenAICompatiblePanel and share customReasoningApiKey (ReasoningModelSelector.tsx:554). handleCloudProviderChange swaps the base URL (:477/:483) but never the key, so after the switch OpenAICompatiblePanel's fetch effect re-runs and sends Authorization: Bearer <old key> to the new provider's /models (OpenAICompatiblePanel.tsx:128-134). OpenRouter → Custom ships your sk-or-... to api.openai.com; Custom → OpenRouter ships the custom key to openrouter.ai. It only 401s, so it's bounded to the wrong vendor's request logs rather than an attacker, but it's a credential going somewhere it shouldn't. Clearing the key alongside the base URL would fix it.

A non-conformant endpoint can take down the whole control panel. OpenAICompatiblePanel.tsx:153-160 casts the /models fields with as string (a no-op at runtime). A numeric id survives the if (!value) guard and later hits value.indexOf("/") / label.localeCompare(...) in SearchableModelList's render; an object description renders as a React child. Either throws during render and trips the global ErrorBoundary. The cast pattern predates this PR, but since the new list now runs string ops on these values, this is the natural place to coerce (String(item?.id ?? "") and friends).

UX papercuts in the new list

  • The "no results" empty state never shows once a model is selected. selectedOption is computed without the query (SearchableModelList.tsx:50) and the pinned Selected row is always pushed (:81-93), so rows.length is never 0 and :132 can't render noSearchResults. Type a non-matching query and you just see the selected model, no feedback.
  • Scroll position isn't reset when the query changes (SearchableModelList.tsx:137). With a few hundred models, scroll down then type and the first match sits off-screen above the viewport.
  • Refresh wipes the in-progress search. loadRemoteModels runs setModelOptions([]) mid-fetch (OpenAICompatiblePanel.tsx:105), which drops displayedModels.length below the threshold (:332) and unmounts SearchableModelList along with its query state.
  • Typing OpenRouter's own suggested URL into the Custom tab (the example at OpenAICompatiblePanel.tsx:262) silently flips you onto the locked OpenRouter tab, because the mount effect re-derives the tab from the base URL (ReasoningModelSelector.tsx:411 via storedToTab). Clicking back to Custom then resets your URL to OpenAI's (:483).
  • OpenRouter shows "API Key (Optional)" (OpenAICompatiblePanel.tsx:271), but its /models is public so the list populates and a model persists without a key, then inference 401s later. The key isn't optional for OpenRouter.

Smaller stuff

  • Prefix icon aliasing applies to any endpoint with >12 models, not just OpenRouter (SearchableModelList.tsx:27providerIcons.ts:57), so a self-hosted google/... id renders the Gemini logo even though gemma.svg exists.
  • A few grouping edge cases on arbitrary endpoint ids: an id ending in / gives a blank label (SearchableModelList.tsx:31); case-variant prefixes (OpenAI/ vs openai/) produce two headers because grouping keeps the raw case while search lowercases (:68); duplicate ids produce duplicate React keys (:103).
  • useVirtualizer has no getItemKey (SearchableModelList.tsx:109), so the measurement cache stays index-keyed and a reused row can briefly render with a stale height after filtering. getItemKey: (i) => rows[i].key handles it.
  • i18n: the extracted ModelCard hardcodes "Cancel"/"Download" (ModelCardList.tsx:200/:213) and the empty state hardcodes "No models available..." (:255), while the same component uses t() for the surrounding badges. common.cancel/common.download already exist.
  • Small dedupe: MODEL_SEARCH_THRESHOLD (SearchableModelList.tsx:11) and SEARCH_THRESHOLD (ui/LanguageSelector.tsx:26) are the same 12; the getProviderIcon + isMonochromeProvider pair at ReasoningModelSelector.tsx:392 and TranscriptionModelPicker.tsx:737 is exactly what getRemoteProviderIcon already returns; TranscriptionModelPicker.tsx:968 re-implements GetApiKeyLink.
  • withUtm (externalLinks.ts:18) returns non-http(s) URLs unchanged and open-external (ipcHandlers.js:2406) has no scheme allowlist before shell.openExternal. Not reachable today since every caller passes hardcoded https, but a scheme guard on the new helper would be cheap defense-in-depth.

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

Labels

None yet

Projects

None yet

2 participants