fix: match OpenRouter model ids in the gateway model listing (#1365)

The model picker was empty behind OpenRouter: `normalizeModelId` did not
match `anthropic/claude-opus-5.5:batch[1m]` to `claude-opus-5-5`. It now
strips a leading `anthropic/` and a `:variant` suffix, and maps `5.5` to
`5-5`.
This commit is contained in:
Andrey Efimov
2026-10-19 10:33:33 +02:00
committed by GitHub
parent 1420994ced
commit bcf74c2fe8
3 changed files with 69 additions and 9 deletions

View File

@@ -264,25 +264,40 @@ function resolveSettingsModel(
const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]); const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
/** A model id without the spellings that name the same model: provider /** A model id without the spellings that name the same model: provider
* prefixes, dates, version suffixes, `-fast` and context hints. `deniedModels` * prefixes, dates, version suffixes, `:variant` suffixes, dotted versions,
* and gateway listings both match on it. */ * `-fast` and context hints. `deniedModels` and gateway listings both match on
* it. OpenRouter lists `anthropic/claude-opus-5.5:batch[1m]`, which normalizes
* to `claude-opus-5-5`; `anthropic/openai/gpt-5` keeps its `openai/` prefix and
* matches no Claude id. */
function normalizeModelId(value: string): string { function normalizeModelId(value: string): string {
let id = stripContextHints(value.trim().toLowerCase()); let id = stripContextHints(value.trim().toLowerCase());
const providerPrefix = id.lastIndexOf("anthropic."); const providerPrefix = id.lastIndexOf("anthropic.");
if (providerPrefix !== -1) id = id.slice(providerPrefix + "anthropic.".length); if (providerPrefix !== -1) id = id.slice(providerPrefix + "anthropic.".length);
return id return (
id
// Only a leading `anthropic/`: OpenRouter's `anthropic/openai/gpt-5` must
// keep `openai/`, which `lastIndexOf` would strip along with it.
.replace(/^anthropic\//, "")
// `:batch`, `:free`. A letter must follow the colon, so Bedrock's `-v1:0`
// is left for the `-vN:M` step below.
.replace(/:[a-z][a-z0-9-]*$/, "")
// `claude-opus-5.5` -> `claude-opus-5-5`; only dots between two digits.
.replace(/(?<=\d)\.(?=\d)/g, "-")
.replace(/@.*$/, "") .replace(/@.*$/, "")
.replace(/-v\d+(?::\d+)?$/, "") .replace(/-v\d+(?::\d+)?$/, "")
.replace(/-fast$/, "") .replace(/-fast$/, "")
.replace(/-\d{8}$/, ""); .replace(/-\d{8}$/, "")
);
} }
/** /**
* Whether managed `deniedModels` blocks a model id, following the CLI's rules * Whether managed `deniedModels` blocks a model id, following the CLI's rules
* closely enough for the picker: a family alias blocks the whole family, and a * closely enough for the picker: a family alias blocks the whole family, and a
* model id blocks that version in every spelling plus the later minor versions * model id blocks that version in every spelling plus the later minor versions
* it prefixes (`claude-opus-5` also blocks `claude-opus-5-5`). The CLI stays * it prefixes (`claude-opus-5` also blocks `claude-opus-5-5`). Entries share
* the enforcer; this only keeps the picker from offering a refused row. * `normalizeModelId`, so `claude-opus-4.5` or `anthropic/claude-opus-4.5` also
* blocks `claude-opus-4-5`, which the CLI may still allow. The CLI stays the
* enforcer; this only keeps the picker from offering a refused row.
*/ */
export function isDeniedModel(modelId: string, deniedModels: readonly string[]): boolean { export function isDeniedModel(modelId: string, deniedModels: readonly string[]): boolean {
const id = normalizeModelId(modelId); const id = normalizeModelId(modelId);

View File

@@ -280,9 +280,46 @@ describe("narrowToGatewayModels", () => {
expect(narrowed.map((m) => m.value)).toEqual(["sonnet"]); expect(narrowed.map((m) => m.value)).toEqual(["sonnet"]);
}); });
it("matches OpenRouter ids: vendor prefix, dotted version and :variant suffix", () => {
const models: ModelInfo[] = [
{
value: "default",
resolvedModel: "claude-opus-5-5[1m]",
displayName: "Default",
description: "",
},
{ value: "opus", displayName: "Opus", description: "" },
{
value: "sonnet",
resolvedModel: "claude-sonnet-5-5",
displayName: "Sonnet",
description: "",
},
{ value: "haiku", resolvedModel: "claude-haiku-4-5", displayName: "Haiku", description: "" },
{ value: "fable", resolvedModel: "claude-fable-5-1", displayName: "Fable", description: "" },
];
const narrowed = narrowToGatewayModels(models, [
"anthropic/claude-opus-5.5:batch[1m]",
"anthropic/claude-opus-5.5[1m]",
"anthropic/claude-sonnet-5.5[1m]",
"anthropic/openai/gpt-6.1-sol:batch[1m]",
"anthropic/google/gemini-4-argon[1m]",
]);
expect(narrowed.map((m) => m.value)).toEqual(["default", "opus", "sonnet"]);
});
it("matches a model OpenRouter lists only as a :variant", () => {
const narrowed = narrowToGatewayModels(MODELS, ["anthropic/claude-sonnet-5:batch[1m]"]);
expect(narrowed.map((m) => m.value)).toEqual(["sonnet", "sonnet[1m]"]);
});
it("leaves no model when the listing matches none", () => { it("leaves no model when the listing matches none", () => {
expect(narrowToGatewayModels(MODELS, ["gpt-5", "gemini-3-pro"])).toEqual([]); expect(narrowToGatewayModels(MODELS, ["gpt-5", "gemini-3-pro"])).toEqual([]);
expect(narrowToGatewayModels(MODELS, [])).toEqual([]); expect(narrowToGatewayModels(MODELS, [])).toEqual([]);
expect(narrowToGatewayModels(MODELS, ["anthropic/openai/gpt-5"])).toEqual([]);
// A default row without resolvedModel is not a match on its own. // A default row without resolvedModel is not a match on its own.
expect( expect(
narrowToGatewayModels( narrowToGatewayModels(

View File

@@ -263,6 +263,14 @@ describe("isDeniedModel", () => {
expect(isDeniedModel("us.anthropic.claude-opus-5-v1:0", denied)).toBe(true); expect(isDeniedModel("us.anthropic.claude-opus-5-v1:0", denied)).toBe(true);
expect(isDeniedModel("claude-opus-5-5", denied)).toBe(true); expect(isDeniedModel("claude-opus-5-5", denied)).toBe(true);
expect(isDeniedModel("claude-opus-4-8", denied)).toBe(false); expect(isDeniedModel("claude-opus-4-8", denied)).toBe(false);
expect(isDeniedModel("anthropic/claude-opus-5.5:batch[1m]", denied)).toBe(true);
expect(isDeniedModel("anthropic/us.anthropic.claude-opus-5-v1:0", denied)).toBe(true);
});
it("reads an entry in OpenRouter or dotted spelling as the dashed SDK id", () => {
expect(isDeniedModel("claude-opus-4-5", ["claude-opus-4.5"])).toBe(true);
expect(isDeniedModel("claude-opus-5-5", ["anthropic/claude-opus-5.5"])).toBe(true);
expect(isDeniedModel("claude-opus-5", ["anthropic/claude-opus-5.5"])).toBe(false);
}); });
it("blocks only that version when the entry names a minor", () => { it("blocks only that version when the entry names a minor", () => {