From 94a8883598aca8087abcf7087228cbe442dabd5e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:19:42 -0300 Subject: [PATCH] fix(playground): filter playground models by node prefix so custom-endpoint models appear (#3505) (#3581) Integrated into release/v3.8.20 --- CHANGELOG.md | 1 + .../components/StudioConfigPane.tsx | 9 +++++- .../translator/hooks/useAvailableModels.tsx | 21 +++++++++---- .../translator/hooks/useProviderOptions.tsx | 7 ++++- .../unit/playground-model-filter-3505.test.ts | 30 +++++++++++++++++++ 5 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 tests/unit/playground-model-filter-3505.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa04ec3bf..3f0879f962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - **fix(catalog):** PublicAI is no longer miscatalogued as keyless/free — it requires an API key (registry `authType:"apikey"`; signup grants a one-time credit, then it bills). The three PublicAI models moved from `freeType:"keyless"` (which could pick them into the no-auth pool and dispatch with no `Authorization` header) to `"one-time-initial"`, and the provider's `hasFree` flag is now `false` — matching `freeTierCatalog.ts`, which already excluded publicai. ([#3558](https://github.com/diegosouzapw/OmniRoute/issues/3558)) - **fix(gemini-web):** a missing Playwright Chromium browser no longer loops and trips the provider breaker — when the browser binary is not installed, `chromium.launch()` threw an error surfaced as a retryable **500**, so accountFallback marked the account unavailable and retry-looped. It is now classified as a host/config problem and returns **503** with an actionable message (`npx playwright install chromium`) and the `X-Omni-Fallback-Hint: connection_cooldown` header, which skips the provider circuit breaker and applies a short non-exponential cooldown. ([#3516](https://github.com/diegosouzapw/OmniRoute/issues/3516)) - **fix(proxy):** the SOCKS5 proxy option now follows the runtime `ENABLE_SOCKS5_PROXY` env instead of the build-time `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` — Next.js inlines `NEXT_PUBLIC_*` at build time, so a prebuilt Docker image ignored a runtime setting and the SOCKS5 type stayed hidden. The proxy modal now reads `socks5Enabled` from `GET /api/settings/proxies` (server-side `ENABLE_SOCKS5_PROXY`), with the build-time value kept only as a static-deploy fallback. ([#3508](https://github.com/diegosouzapw/OmniRoute/issues/3508)) +- **fix(playground):** the playground model selector now lists models from custom-endpoint (OpenAI/Anthropic-compatible) providers — it filtered `/v1/models` by the provider's connection id, but the catalog emits compatible-provider models under the node's custom prefix (`prefix/model`), so the list came up empty ("None"/"-"). The selector now filters by the node prefix (exposed additively as `modelPrefix` on provider options; the connection id is unchanged, so translator send/translate and connection lookups are unaffected). ([#3505](https://github.com/diegosouzapw/OmniRoute/issues/3505)) - **fix(security):** route raw `err.message` through `sanitizeErrorMessage()` in five web executors (`adapta-web`, `deepseek-web`, `perplexity-web`, `qoder`, `veoaifree-web`) and the embeddings + search handlers (Hard Rule #12) — these built error response bodies from the raw upstream/exception message, which could leak internal detail. ([#3494](https://github.com/diegosouzapw/OmniRoute/issues/3494), [#3495](https://github.com/diegosouzapw/OmniRoute/issues/3495)) - **fix(dashboard):** correct two dashboard fetches that hit non-existent routes (404) — `CustomHostsManager` called `/api/tools/traffic-inspector/custom-hosts` (the real route is `/hosts`), and `FeatureFlagsGrid`'s post-restart liveness probe called `/api/health` (the real lightweight endpoint is `/api/health/ping`). ([#3486](https://github.com/diegosouzapw/OmniRoute/issues/3486), [#3487](https://github.com/diegosouzapw/OmniRoute/issues/3487)) - **chore(providers):** remove the dead `krutrim` registry entry — it was half-registered (present in `providerRegistry.ts` with a baseUrl + one model, but absent from `providers.ts`, with no executor/translator/OAuth), so it was never selectable. Dropped its `ProviderIcon` entry and the `KNOWN_REGISTRY_ONLY` exception. ([#3483](https://github.com/diegosouzapw/OmniRoute/issues/3483)) diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx index 3a66230b18..379b6c0256 100644 --- a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx @@ -52,7 +52,14 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio const { provider, setProvider, providerOptions, loading: loadingProviders } = useProviderOptions( configState.provider ?? "" ); - const { availableModels, loading: loadingModels } = useAvailableModels(provider || undefined); + // #3505: filter models by the selected provider's catalog namespace. Compatible providers + // emit models under their node prefix (e.g. "myprefix/gpt-4o"), not under the connection id, + // so use the option's modelPrefix when present; fall back to the id for built-in providers. + const selectedProviderOption = providerOptions.find( + (opt: { value: string; modelPrefix?: string }) => opt.value === provider + ); + const modelFilterKey = selectedProviderOption?.modelPrefix || provider || undefined; + const { availableModels, loading: loadingModels } = useAvailableModels(modelFilterKey); function update(key: K, value: ConfigState[K]) { setConfigState({ ...configState, [key]: value }); diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx index c0e5ec525e..c3fdbe666e 100644 --- a/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx +++ b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx @@ -25,6 +25,18 @@ const FORMAT_MODEL_PREFIXES = { * pickModelForFormat: (format: string) => string * }} */ +/** + * Filter the /v1/models id list to a provider's models. The `provider` key must be the model + * NAMESPACE used in the catalog: built-in providers use their id (e.g. "openai"), while + * compatible providers use the node's custom PREFIX (e.g. "myprefix"), NOT the node id — see + * #3505. Pure + exported for testing. + */ +export function filterModelsByProvider(allModels: string[], provider?: string): string[] { + return provider + ? allModels.filter((m) => m.startsWith(`${provider}/`) || m === provider) + : allModels; +} + export function useAvailableModels(provider?: string) { const [model, setModel] = useState(""); const [allModels, setAllModels] = useState([]); @@ -46,11 +58,10 @@ export function useAvailableModels(provider?: string) { fetchModels(); }, []); - const availableModels = useMemo(() => { - return provider - ? allModels.filter((m) => m.startsWith(`${provider}/`) || m === provider) - : allModels; - }, [allModels, provider]); + const availableModels = useMemo( + () => filterModelsByProvider(allModels, provider), + [allModels, provider] + ); /** * Pick the best model for a given format from the available models. diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx b/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx index f0c2655bc2..f7518c9a20 100644 --- a/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx +++ b/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx @@ -47,7 +47,12 @@ export function useProviderOptions(initialProvider = "openai") { label = node?.name || t("openaiCompatibleLabel"); if (!info && (pid as string).startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) label = node?.name || t("anthropicCompatibleLabel"); - return { value: pid, label }; + // #3505: compatible providers emit catalog models under the node's custom prefix + // (e.g. "myprefix/gpt-4o"), not under the connection id (`value`). Expose the prefix + // so model-filter consumers (playground) can match; `value` stays the id for + // connection lookups (translator send/translate, ApiTab). + const modelPrefix = !info && node?.prefix ? String(node.prefix) : undefined; + return { value: pid, label, modelPrefix }; }) .sort((a, b) => compareTr(a.label, b.label)); diff --git a/tests/unit/playground-model-filter-3505.test.ts b/tests/unit/playground-model-filter-3505.test.ts new file mode 100644 index 0000000000..931189f1ea --- /dev/null +++ b/tests/unit/playground-model-filter-3505.test.ts @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { filterModelsByProvider } from "../../src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx"; + +// Regression for #3505: the playground model selector filtered the /v1/models list by the +// provider OPTION VALUE (a compatible provider's node id, e.g. "openai-compatible-"), +// but the catalog emits compatible-provider models under the node's custom PREFIX +// (e.g. "myprefix/gpt-4o"). So `startsWith("openai-compatible-/")` matched nothing and +// the selector showed "None"/"-". The fix passes the node prefix as the filter key. This locks +// the filter behaviour: given the right key (the prefix), the models surface. + +test("#3505 filters models by a custom node prefix (the catalog's model namespace)", () => { + const all = ["myprefix/gpt-4o", "myprefix/llama-3", "openai/gpt-4o", "anthropic/claude-opus-4-8"]; + assert.deepEqual(filterModelsByProvider(all, "myprefix"), ["myprefix/gpt-4o", "myprefix/llama-3"]); +}); + +test("#3505 a UUID-style node id (the old wrong key) matches nothing → empty (the bug)", () => { + const all = ["myprefix/gpt-4o", "openai/gpt-4o"]; + assert.deepEqual(filterModelsByProvider(all, "openai-compatible-1234-uuid"), []); +}); + +test("#3505 built-in provider filtering still works", () => { + const all = ["openai/gpt-4o", "openai/gpt-4o-mini", "anthropic/claude-opus-4-8"]; + assert.deepEqual(filterModelsByProvider(all, "openai"), ["openai/gpt-4o", "openai/gpt-4o-mini"]); +}); + +test("#3505 an exact bare match is included; no provider returns all", () => { + assert.deepEqual(filterModelsByProvider(["auto", "openai/gpt-4o"], "auto"), ["auto"]); + assert.deepEqual(filterModelsByProvider(["a", "b"], undefined), ["a", "b"]); +});