Originally created by: sachin-detrax
Closes [#31].
The OpenAI-compatible provider wizard asked the operator to type a chat model id by hand. Against a remote vLLM/SGLang deployment — the case in [#31] — nobody knows the ids offhand, and providers rotate them without notice. The server already publishes them at GET {baseUrl}/v1/models.
src/llm/provider/openai/fetch-openai-compat-models.ts (new) — GET {baseUrl}/v1/models with the bearer key, sorted ids, 10s timeout, cached per normalized base URL. Same fetch-then-read-sync shape the OpenRouter picker already uses, so the sync key handler can read the result.↑/↓ move (21/341) · Enter select). Typing still works: any printable key is a deliberate override that hides the list, backspacing to empty brings it back. Arrows only for movement, so ids starting with j/k stay typable.model list unavailable (http 401) — type the id).https://host/v1/ was stored verbatim and produced https://host/v1//v1/chat/completions on every later request. It is now normalized once, and the same helper feeds the fetch, the cache key and the displayed URL.The API key comes from the wizard buffer, falling back to resolveLlmProviderApiKey (OPENAI_COMPAT_API_KEY / OPENAI_API_KEY / ATOMIC_AGENT_OPENAI_API_KEY), so a key already in .env needs no retyping.
15 new tests (fetch/cache/normalize, picker key flow, typed override, windowed render, refusal fallback). Full suite: 3293 passing, unchanged pre-existing failures on main; tsc clean.
Also exercised against real servers, no mocks:
https://openrouter.ai/api — 341 ids listed and rendered; base pasted as .../api/v1/ hit the cache instead of re-requesting; arrows + Enter selected a model and built the entry with baseUrl: https://openrouter.ai/api.GET /v1/models auth=Bearer <key>, and the cold-cache render produced the windowed picker.https://api.openai.com with no key → http 401, nothing cached, hint shown; unreachable host → fetch failed hint.Servers whose OpenAI base is a path prefix rather than a host root (e.g. DeepInfra's https://api.deepinfra.com/v1/openai) 404 on discovery, because the codebase appends /v1/... and the normalizer only strips a trailing /v1. That shape already cannot serve chat completions through this provider, so it is pre-existing rather than a regression; the wizard falls back to typed entry there.
Tickets: #31
Tickets: #38
Tickets: #60
Tickets: #62
Tickets: #64
Tickets: #69
Tickets: #80
Originally posted by: Ooooze
Thanks for this — the wizard genuinely needed it, and the degradation path when the server refuses the list is handled honestly.
I checked the wizard-state assumption this builds on:
cursorwas previously a pick-list index only, andadvanceWizardPhaseresets it to0on every phase transition, so reusing it inchat_model_lineis safe. No issue there.Four things I'd like fixed before merge, roughly in order of importance.
1. Base URL normalization belongs in the provider, not the wizard.
OpenAiProvideronly strips a trailing slash today:and every call site appends
/v1/.... Normalizing inbuildProviderEntryFromWizardfixes the wizard path only — configs written before this PR, or edited by hand inconfig.json, keep producinghttps://host/v1/v1/chat/completions. Moving (or duplicating) the normalization intoOpenAiProvider/register-built-in-providersfixes it for everyone and makes the wizard's job just "store what the operator typed".2.
/\/v\d+$/is broader than the stated intent.The comment says "a base URL pasted with
/v1", but the regex strips any/vN.https://host/api/v2silently becomeshttps://host/api, and the next request goes to/api/v1/.... Please anchor it to/v1.3. The cache has no TTL and ignores the API key.
cache.set(base, ids)is never invalidated, so a rotated key or a newly-deployed model is invisible until the process restarts — and a list fetched anonymously is reused for a later authenticated request. The OpenRouter analogue (fetch-openrouter-chat-catalog.ts) already carries a 1h TTL; matching that, plus folding the key into the cache key, would cover both cases.Related, and more of a judgement call for a maintainer: reading this module-level
MapfromhandleProvidersWizardKeyturns a previously pure reducer into one with a hidden global dependency (the new tests have to prime the cache through a stubbedfetchto exercise it). The OpenRouter precedent exists, so I won't block on it, but it's worth a conscious call.4. Minor.
useEffectisn't guarded byw.kind— onlylistCompatChatModelPicksis. Harmless today sinceopenai-compatibleis the sole non-curated kind routed throughbase_url, but a fourth kind would start firing a request at the defaulthttps://api.openai.comwith whatever key resolves from the environment. Akindcheck in the effect keeps that from happening later.normalizeOpenAiCompatBaseUrllives inside the fetch module, butproviders-wizard-build-entry.tsimports it purely as a URL helper. Per the repo's one-responsibility-per-file rule it probably deserves its own file (this becomes moot if it moves to the provider layer per point 1).Originally posted by: sachin-detrax
Thanks — all four fixed in b2c3598.
1 + 2. Normalization moved to the provider, anchored to
/v1.New
src/llm/provider/openai/normalize-openai-base-url.ts, called fromOpenAiProvider's constructor in place of the oldreplace(/\/$/, ""). Configs written before this PR or hand-edited intoconfig.jsonare now fixed too, and the wizard's job is back to "store what the operator typed". Only a trailing/v1is stripped, sohttps://host/api/v2is left alone.Worth noting the duplication your point uncovered:
normalizeOpenRouterBaseUrlandnormalizeAimlapiBaseUrlwere already two copies of exactly this logic. Both are now re-exports of the shared helper, and neither subclass normalizes on its own any more since the base class does it.3. Cache has a 1h TTL and is keyed by base URL + api key.
Matches
fetch-openrouter-chat-catalog.ts. Entries storefetchedAtand the key is`${base}\n${apiKey ?? ""}`, so a rotated key or a newly deployed model is visible without a restart, and an anonymous list can no longer serve an authenticated request. The key resolution the effect was doing inline is nowapiKeyForWizard(), exported next tobaseUrlForWizard(), so the async fetch and the sync cache read cannot disagree about which key they mean.I left the module-level
Mapas-is — that's the maintainer call you flagged, and the OpenRouter precedent is what the picker's fetch-then-read-sync shape is built on.4.
w.kind === "openai-compatible", and the hint falls back to the plain line-field text for any other kind.normalizeOpenAiCompatBaseUrlis gone from the fetch module; it lives in its own file per point 1, which is whatproviders-wizard-build-entry.tsimports.Tests. Three new in
fetch-openai-compat-models.test.ts(key isolation, TTL expiry with fake timers) plusnormalize-openai-base-url.test.tscovering the/api/v2and/v10cases. The key-bindings tests now pinOPENAI_COMPAT_API_KEYinstead of reading whatever is in the ambient env, since the cache key depends on it.tscclean. Full suite 3287 passing; the failing files are the same set asmain—parallel-tool-calls.integrationandllm-health-pollerpass in isolation and vary run to run on both branches.Ticket changed by: Ooooze