diff --git a/src/lib/openrouter-models.ts b/src/lib/openrouter-models.ts index 49e7d4160..9cdf44d24 100644 --- a/src/lib/openrouter-models.ts +++ b/src/lib/openrouter-models.ts @@ -28,12 +28,21 @@ export const OPENROUTER_DEFAULT_MODEL_ID = "anthropic/claude-haiku-4.5"; /** * OpenRouter slugs are structured `/` (sometimes with more - * path segments). Validate the shape before we accept a user-supplied - * custom model ID — stops empty strings, whitespace, and obvious typos - * without getting in the way of legitimate rare slugs. + * path segments) and may carry ONE trailing `:variant` suffix. Validate + * the shape before we accept a user-supplied custom model ID — stops + * empty strings, whitespace, and obvious typos without getting in the + * way of legitimate rare slugs. + * + * The `:variant` part is not optional pedantry: every free model on + * OpenRouter is addressed as `/:free`, and routing variants + * (`:online`, `:extended`, `:nitro`, `:thinking`) use the same syntax. + * The original pattern's character class had no colon, so it rejected + * ALL of them — a user pasting the correct id for a free model got + * "invalid model id" from us, not from OpenRouter. Reported in Discord + * 2026-07-30 for `nvidia/nemotron-3-ultra-550b-a55b:free`. */ const OPENROUTER_SLUG_RE = - /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+$/i; + /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+(?::[a-z0-9][a-z0-9._-]*)?$/i; export function isValidOpenRouterModelId(id: string): boolean { return OPENROUTER_SLUG_RE.test(id.trim()); diff --git a/src/tests/unit/openrouter-models.test.ts b/src/tests/unit/openrouter-models.test.ts index b7cd2e50b..35160418c 100644 --- a/src/tests/unit/openrouter-models.test.ts +++ b/src/tests/unit/openrouter-models.test.ts @@ -33,6 +33,14 @@ describe("openrouter-models", () => { "meta-llama/llama-3.3-70b-instruct", "x-ai/grok-4-fast", "deepseek/deepseek-chat-v3.1", + // `:variant` suffixes — every FREE model on OpenRouter is addressed + // this way, and routing variants share the syntax. These were all + // rejected before the slug pattern learned about the colon. + "nvidia/nemotron-3-ultra-550b-a55b:free", + "qwen/qwen-2.5-vl-72b-instruct:free", + "perplexity/sonar:online", + "openai/gpt-4o:extended", + "meta-llama/llama-3.3-70b-instruct:nitro", ])("accepts valid slug %s", (slug) => { expect(isValidOpenRouterModelId(slug)).toBe(true); }); @@ -46,6 +54,11 @@ describe("openrouter-models", () => { "anthropic//claude", "anthropic/claude/", "org/model//variant", + // a colon is allowed, but only as ONE non-empty trailing variant + "anthropic/claude-haiku-4.5:", + "anthropic/claude-haiku-4.5:free:extra", + "anthropic:free", + "anthropic/claude haiku:free", ])("rejects invalid slug %s", (slug) => { expect(isValidOpenRouterModelId(slug)).toBe(false); });