diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx index c6360bc920..e44dddecf4 100644 --- a/src/shared/components/ModelSelectModal.tsx +++ b/src/shared/components/ModelSelectModal.tsx @@ -3,6 +3,7 @@ import { useState, useMemo, useEffect } from "react"; import { useTranslations } from "next-intl"; import Modal from "./Modal"; +import { buildPassthroughAliasModels } from "./modelSelectModalHelpers"; import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; import { @@ -235,14 +236,14 @@ export default function ModelSelectModal({ const providerCustomModels = customModels[providerId] || []; if (providerInfo.passthroughModels) { - const aliasModels = Object.entries(modelAliases as Record) - .filter(([, fullModel]: [string, string]) => fullModel.startsWith(`${alias}/`)) - .map(([aliasName, fullModel]: [string, string]) => ({ - id: fullModel.replace(`${alias}/`, ""), - name: aliasName, - value: fullModel, - source: "alias", - })); + // Passthrough aliases are stored prefixed by the canonical providerId + // (e.g. "github/gpt-4"), not the public alias (e.g. "gh/"), so we must + // filter/strip by providerId — matching the sibling custom-provider + // branch below. (port: decolua/9router#485) + const aliasModels = buildPassthroughAliasModels( + modelAliases as Record, + providerId + ); // Merge custom models for passthrough providers const customEntries = providerCustomModels diff --git a/src/shared/components/modelSelectModalHelpers.ts b/src/shared/components/modelSelectModalHelpers.ts new file mode 100644 index 0000000000..01d7f26011 --- /dev/null +++ b/src/shared/components/modelSelectModalHelpers.ts @@ -0,0 +1,37 @@ +// Pure helpers extracted from ModelSelectModal so the grouping logic is +// unit-testable (the component itself lives inside a useMemo and is not +// directly exercisable by node:test). Keep these free of React imports. + +export type PassthroughAliasModel = { + id: string; + name: string; + value: string; + source: "alias"; +}; + +/** + * Build the alias-derived model rows for a passthrough provider. + * + * `modelAliases` maps an alias name → the fully-qualified model string, which + * is prefixed by the provider's *canonical id* (e.g. `github/gpt-4`), NOT by + * its public alias (e.g. `gh`). Filtering/stripping must therefore use the + * `providerId`, mirroring the sibling custom-provider branch. Using the alias + * here meant aliases registered under a providerId whose alias differs (the + * common case) never resolved. + * + * Inspired by upstream PR decolua/9router#485 (Anurag Saxena). + */ +export function buildPassthroughAliasModels( + modelAliases: Record, + providerId: string +): PassthroughAliasModel[] { + const prefix = `${providerId}/`; + return Object.entries(modelAliases || {}) + .filter(([, fullModel]) => typeof fullModel === "string" && fullModel.startsWith(prefix)) + .map(([aliasName, fullModel]) => ({ + id: fullModel.replace(prefix, ""), + name: aliasName, + value: fullModel, + source: "alias" as const, + })); +} diff --git a/tests/unit/model-select-passthrough-alias-485.test.ts b/tests/unit/model-select-passthrough-alias-485.test.ts new file mode 100644 index 0000000000..95cc0d2ecf --- /dev/null +++ b/tests/unit/model-select-passthrough-alias-485.test.ts @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildPassthroughAliasModels } from "../../src/shared/components/modelSelectModalHelpers.ts"; + +// Regression guard for port of decolua/9router#485 (Anurag Saxena). +// +// In ModelSelectModal, passthrough-provider model aliases are stored in +// `modelAliases` prefixed by the provider's CANONICAL id (e.g. "github/gpt-4"), +// while the public alias may differ (e.g. providerId "github" → alias "gh"). +// The passthrough branch previously filtered/stripped by `${alias}/`, so any +// provider whose alias differed from its id resolved to ZERO models. The fix +// filters by `${providerId}/`, mirroring the sibling custom-provider branch. + +test("buildPassthroughAliasModels: resolves aliases keyed by providerId even when the public alias differs", () => { + // providerId "github", public alias would be "gh" — aliases are stored under the id. + const modelAliases = { + "GPT 4o": "github/gpt-4o", + "GPT 4o mini": "github/gpt-4o-mini", + Sonnet: "anthropic/claude-3-5-sonnet", + }; + + const result = buildPassthroughAliasModels(modelAliases, "github"); + + // The bug (filtering by alias "gh/") would yield [] here. The fix yields the + // two github-prefixed entries, with the providerId prefix stripped from `id`. + assert.deepEqual(result, [ + { id: "gpt-4o", name: "GPT 4o", value: "github/gpt-4o", source: "alias" }, + { id: "gpt-4o-mini", name: "GPT 4o mini", value: "github/gpt-4o-mini", source: "alias" }, + ]); +}); + +test("buildPassthroughAliasModels: only matches the requested providerId prefix", () => { + const modelAliases = { + a: "openrouter/model-a", + b: "github/model-b", + }; + const result = buildPassthroughAliasModels(modelAliases, "openrouter"); + assert.deepEqual(result, [ + { id: "model-a", name: "a", value: "openrouter/model-a", source: "alias" }, + ]); +}); + +test("buildPassthroughAliasModels: tolerates empty / malformed maps", () => { + assert.deepEqual(buildPassthroughAliasModels({}, "github"), []); + assert.deepEqual( + buildPassthroughAliasModels({ x: undefined as unknown as string }, "github"), + [] + ); +});