From 1e884ca5e7cb775f586faae48a24b54538b700f2 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:25:28 -0600 Subject: [PATCH 1/5] fix(models): allow slash characters in custom model ids Operators can store native ids like openai/gpt-5.5. Codex still sees the one-slash encoded slug. Display names remain slash-rejected. --- .../provider-workspace/ProviderModels.tsx | 1 - gui/tests/provider-model-custom-add.test.tsx | 4 +- src/claude/agents-inject.ts | 2 +- src/cli/models.ts | 5 +- src/router.ts | 11 +++- src/server/management/model-routes.ts | 2 - src/types.ts | 2 +- tests/catalog-input-modality-enum.test.ts | 66 +++++++++++++++++++ tests/cli-models.test.ts | 46 ++++++++++++- tests/slug-codec.test.ts | 21 ++++++ 10 files changed, 147 insertions(+), 13 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderModels.tsx b/gui/src/components/provider-workspace/ProviderModels.tsx index 9a3978f23a..caa96593f8 100644 --- a/gui/src/components/provider-workspace/ProviderModels.tsx +++ b/gui/src/components/provider-workspace/ProviderModels.tsx @@ -50,7 +50,6 @@ export default function ProviderModels({ const trimmedCustomModelId = customModelId.trim(); const customModelInvalid = !customModelsReady || !trimmedCustomModelId - || trimmedCustomModelId.includes("/") || availableModels.includes(trimmedCustomModelId) || customModelIds.includes(trimmedCustomModelId) || configuredModels.includes(trimmedCustomModelId) diff --git a/gui/tests/provider-model-custom-add.test.tsx b/gui/tests/provider-model-custom-add.test.tsx index d8c1848319..e6331270b3 100644 --- a/gui/tests/provider-model-custom-add.test.tsx +++ b/gui/tests/provider-model-custom-add.test.tsx @@ -114,7 +114,7 @@ test("quick-add submits the trimmed model id for the current provider", async () await act(async () => { root.unmount(); }); }); -test("quick-add blocks existing and namespaced model ids", async () => { +test("quick-add blocks existing ids but allows namespaced model ids", async () => { let requests = 0; globalThis.fetch = (async (_input, init) => { if (!init?.method || init.method === "GET") return Response.json([]); @@ -126,7 +126,7 @@ test("quick-add blocks existing and namespaced model ids", async () => { await enterModelId(input, "claude-opus-5"); expect(addButton.disabled).toBe(true); await enterModelId(input, "vendor/model"); - expect(addButton.disabled).toBe(true); + expect(addButton.disabled).toBe(false); expect(requests).toBe(0); await act(async () => { root.unmount(); }); diff --git a/src/claude/agents-inject.ts b/src/claude/agents-inject.ts index e3b15a4b87..e621e700aa 100644 --- a/src/claude/agents-inject.ts +++ b/src/claude/agents-inject.ts @@ -85,7 +85,7 @@ function entryParts(entry: string, config: OcxConfig): { alias: string; id: stri const provider = entry.slice(0, slash); const prov = hasOwnProvider(config.providers, provider) ? config.providers[provider] : undefined; const id = prov - ? decodeRoutedModelId(entry.slice(slash + 1), knownModelIdsForProvider(provider, prov)) + ? decodeRoutedModelId(entry.slice(slash + 1), knownModelIdsForProvider(provider, prov, config)) : entry.slice(slash + 1); return { alias: claudeCodeAlias(provider, id), id, provider }; } diff --git a/src/cli/models.ts b/src/cli/models.ts index 83403c6a01..4e3df861e3 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -6,7 +6,7 @@ import { createInterface } from "node:readline/promises"; import { syncModelsToCodex } from "../codex/sync"; import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config"; import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort"; -import { routedSlug } from "../providers/slug-codec"; +import { routedSlug, slugEquals } from "../providers/slug-codec"; import { findLiveProxy } from "../server/proxy-liveness"; import type { OcxConfig, OcxCustomModel } from "../types"; @@ -180,7 +180,6 @@ async function handleCustomAdd(args: string[]): Promise { if (!provider || !modelId) fail("provider and modelId are required", ADD_USAGE); if (!isValidProviderName(provider)) fail(`invalid provider name "${provider}"`); - if (modelId.includes("/")) fail("modelId must not contain /"); const config = loadConfig(); if (!hasOwnProvider(config.providers, provider)) { @@ -257,7 +256,7 @@ async function handleCustomRemove(args: string[]): Promise { const config = loadConfig(); const existing = config.customModels ?? []; const index = target.includes("/") - ? existing.findIndex(model => routedSlug(model.provider, model.modelId) === target) + ? existing.findIndex(model => slugEquals(target, model.provider, model.modelId)) : existing.findIndex(model => model.id === target); if (index === -1) fail(`custom model "${target}" not found`); diff --git a/src/router.ts b/src/router.ts index 7950425c3c..49160e9cae 100644 --- a/src/router.ts +++ b/src/router.ts @@ -87,7 +87,11 @@ const MODEL_PROVIDER_PATTERNS: Array<{ providerNames: string[]; prefixes: string * last-known-good live /models cache (may be empty on a cold start; decode then passes * unknown ids through unchanged for an honest upstream error). */ -export function knownModelIdsForProvider(provName: string, prov: OcxProviderConfig): string[] { +export function knownModelIdsForProvider( + provName: string, + prov: OcxProviderConfig, + config?: Pick, +): string[] { const ids = new Set(); for (const id of prov.models ?? []) ids.add(id); const registry = providerMatchesRegistryTransportWithStaticGuards(provName, prov) @@ -108,6 +112,9 @@ export function knownModelIdsForProvider(provName: string, prov: OcxProviderConf for (const id of Object.keys(map ?? {})) ids.add(id); } for (const cached of getStaleCached(provName) ?? []) ids.add(cached.id); + for (const model of config?.customModels ?? []) { + if (model.provider === provName && model.modelId) ids.add(model.modelId); + } return [...ids]; } @@ -610,7 +617,7 @@ function routeModelInternal( if (hasOwnProvider(config.providers, provName)) { const prov = config.providers[provName]; if (prov.disabled === true) throw new Error(`Provider is disabled: ${provName}`); - const known = knownModelIdsForProvider(provName, prov); + const known = knownModelIdsForProvider(provName, prov, config); // Self-namespaced native id — the vendor segment equals the provider id, so the FULL ref is // itself a known model (e.g. orcarouter/auto). Route it whole instead of stripping to the // remainder, which would send a bare `auto` the upstream cannot resolve. diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 0cf22043f4..6d7c93daa8 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -376,7 +376,6 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise. */ modelId: string; /** 인간 가독 표시명 (선택, 슬래시 불가) */ displayName?: string; diff --git a/tests/catalog-input-modality-enum.test.ts b/tests/catalog-input-modality-enum.test.ts index cc8bf39b35..73d00145bb 100644 --- a/tests/catalog-input-modality-enum.test.ts +++ b/tests/catalog-input-modality-enum.test.ts @@ -414,3 +414,69 @@ describe("custom-model API validates reasoning-effort ladders", () => { expect(persistCalls).toBe(1); }); }); + +describe("custom-model API allows slash model ids", () => { + let persistCalls = 0; + + async function callCustomModels( + method: "POST" | "PUT", + body: unknown, + pathname = "/api/custom-models", + ): Promise { + const { handleModelRoutes } = await import("../src/server/management/model-routes"); + const url = new URL(`http://127.0.0.1:10199${pathname}`); + const req = new Request(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return handleModelRoutes({ + req, + url, + config: { + providers: { deepseek: { adapter: "openai-chat", baseUrl: "https://example.invalid/v1" } }, + customModels: [ + { id: "existing-uuid", provider: "deepseek", modelId: "deepseek-v4", inputModalities: ["text", "image"] }, + ], + } as unknown as Parameters[0]["config"], + deps: { + saveConfigPreservingClaudeCode: () => { persistCalls++; }, + } as Parameters[0]["deps"], + convergeCodexCatalog: async () => ({ + status: "committed", + changed: false, + degraded: false, + notices: [], + }), + syncClaudeAgentDefsBestEffort: async () => {}, + }); + } + + test("POST accepts slash model ids", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "openai/gpt-5.5", + }); + expect(res?.status).toBe(201); + const payload = await res!.json() as { modelId?: string }; + expect(payload.modelId).toBe("openai/gpt-5.5"); + expect(persistCalls).toBe(1); + }); + + test("PUT accepts slash model ids", async () => { + persistCalls = 0; + const res = await callCustomModels("PUT", { modelId: "openai/gpt-5.5" }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(200); + const payload = await res!.json() as { modelId?: string }; + expect(payload.modelId).toBe("openai/gpt-5.5"); + expect(persistCalls).toBe(1); + }); + + test("PUT still rejects displayName with slash", async () => { + persistCalls = 0; + const res = await callCustomModels("PUT", { displayName: "foo/bar" }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(400); + expect(persistCalls).toBe(0); + }); +}); diff --git a/tests/cli-models.test.ts b/tests/cli-models.test.ts index e40c0ed2a8..52b9e8fc1e 100644 --- a/tests/cli-models.test.ts +++ b/tests/cli-models.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -174,3 +174,47 @@ describe("ocx models richer metadata", () => { } }); }); + +describe("ocx models custom slash ids", () => { + test("models add accepts slash model ids", () => { + const { dir } = freshConfig(); + try { + const result = runCli(["models", "add", "test", "openai/gpt-5.5"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const config = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")); + expect(config.customModels[0].modelId).toBe("openai/gpt-5.5"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("models remove accepts raw and encoded slash selectors", () => { + for (const target of ["test/openai/gpt-5.5", "test/openai-gpt-5.5"]) { + const { dir } = freshConfig(); + try { + const add = runCli(["models", "add", "test", "openai/gpt-5.5"], { OPENCODEX_HOME: dir }); + expect(add.status).toBe(0); + const remove = runCli(["models", "remove", target, "--yes"], { OPENCODEX_HOME: dir }); + expect(remove.status).toBe(0); + const config = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")); + expect(config.customModels ?? []).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + test("models add still rejects displayName with slash", () => { + const { dir } = freshConfig(); + try { + const result = runCli( + ["models", "add", "test", "openai/gpt-5.5", "--display-name", "foo/bar"], + { OPENCODEX_HOME: dir }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("displayName must not contain /"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/slug-codec.test.ts b/tests/slug-codec.test.ts index 70f8a2fd02..23d2749ca3 100644 --- a/tests/slug-codec.test.ts +++ b/tests/slug-codec.test.ts @@ -166,6 +166,27 @@ describe("routeModel decode (proxy layer)", () => { expect(ids).toContain("moonshotai/kimi-k3-free"); expect(ids).toContain("moonshotai/kimi-k3"); }); + + test("knownModelIdsForProvider unions customModels for that provider", () => { + const config = zenmuxConfig(); + config.customModels = [ + { id: "c1", provider: "zenmux", modelId: "openai/gpt-5.5" }, + { id: "c2", provider: "other", modelId: "should-not-appear" }, + ]; + const ids = knownModelIdsForProvider("zenmux", config.providers.zenmux!, config); + expect(ids).toContain("openai/gpt-5.5"); + expect(ids).not.toContain("should-not-appear"); + }); + + test("routeModel decodes encoded custom slash id back to native id", () => { + const config = zenmuxConfig(); + config.customModels = [ + { id: "c1", provider: "zenmux", modelId: "openai/gpt-5.5" }, + ]; + const route = routeModel(config, "zenmux/openai-gpt-5.5"); + expect(route.providerName).toBe("zenmux"); + expect(route.modelId).toBe("openai/gpt-5.5"); + }); }); describe("catalog emission (Codex-facing)", () => { From 93c8cb083bf68f354d815ddf65409a8321daf780 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:31:59 -0600 Subject: [PATCH 2/5] fix(providers): discover Cloudflare Workers AI via models/search Point named cloudflare-workers-ai rows at the official search endpoint with format=openrouter so live lists stop 405ing on GET /ai/v1/models. --- src/providers/model-discovery.ts | 20 +++-- src/providers/registry.ts | 13 ++++ .../provider-model-discovery-contract.test.ts | 75 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/providers/model-discovery.ts b/src/providers/model-discovery.ts index 534c0d00ed..bd3795105f 100644 --- a/src/providers/model-discovery.ts +++ b/src/providers/model-discovery.ts @@ -99,7 +99,12 @@ export function providerModelDiscoverySpecError(spec: ProviderModelDiscoverySpec return "discovery path must be a query-free relative/origin path"; } if (path.includes("\\")) return "discovery path must use forward slashes"; - if (path.split("/").some(segment => segment.replace(/%2e/gi, ".") === "..")) { + const segments = path.split("/"); + if (segments.some((segment, index) => { + const decoded = segment.replace(/%2e/gi, "."); + if (decoded !== "..") return false; + return index !== 0 || segments.filter(s => s.replace(/%2e/gi, ".") === "..").length !== 1; + })) { return "discovery path must not contain parent-directory segments"; } } @@ -351,10 +356,15 @@ export function extractProviderModelItems( } const id = (raw as { id?: unknown }).id; if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape" }; - const normalizedId = id; - const item = raw as ProviderModelsApiItem; - if (!providerModelMatchesDiscoveryFilter(item, discovery.spec?.filter) || seen.has(normalizedId)) continue; - seen.add(normalizedId); + const prefix = discovery.spec?.stripIdPrefix; + let finalId = id; + if (prefix && finalId.startsWith(prefix)) { + finalId = finalId.slice(prefix.length); + if (!isValidModelDiscoveryModelId(finalId)) continue; + } + const item = finalId === id ? raw as ProviderModelsApiItem : { ...(raw as ProviderModelsApiItem), id: finalId }; + if (!providerModelMatchesDiscoveryFilter(item, discovery.spec?.filter) || seen.has(finalId)) continue; + seen.add(finalId); items.push(item); } return { ok: true, items, rawCount: data.length }; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 72bf1ba217..49beb7b928 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -80,6 +80,11 @@ interface ProviderModelDiscoverySharedSpec { maxResponseBytes?: number; /** Optional lower raw-row ceiling; the process-wide hard ceiling still wins. */ maxModels?: number; + /** + * If a valid extracted id starts with this prefix, strip it and re-validate the remainder. + * Empty/invalid remainders skip that row only. + */ + stripIdPrefix?: string; } type ProviderModelDiscoveryLocation = @@ -2467,6 +2472,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Cloudflare Workers AI: OpenAI-compatible endpoint. The base URL contains {account_id} // which must be resolved by the user at setup time. Model IDs use the @cf/ prefix. // Live-verified 2026-07-21 against https://developers.cloudflare.com/workers-ai/models/ + // Official search is sibling to /ai/v1 (GET .../ai/models/search?format=openrouter). id: "cloudflare-workers-ai", label: "Cloudflare Workers AI", baseUrl: "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", adapter: "openai-chat", authKind: "key", freeTier: true, @@ -2481,6 +2487,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "@cf/zai-org/glm-5.2", "@cf/mistralai/mistral-small-3.1-24b-instruct", ], + liveModels: true, + modelDiscovery: { + path: "../models/search", + query: { format: "openrouter", per_page: "1000" }, + stripIdPrefix: "workers-ai/", + maxModels: 256, + }, note: "Workers AI · Free tier included · Account ID required in base URL", }, // FREEZE 2026-07-10: /models was auth-gated under key login. OAuth device-flow + copilot_internal diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts index e71bbd4e3b..07c8c0e6ac 100644 --- a/tests/provider-model-discovery-contract.test.ts +++ b/tests/provider-model-discovery-contract.test.ts @@ -12,6 +12,7 @@ import { providerModelDiscoverySpecError, readBoundedDiscoveryJson, resolveProviderModelDiscovery, + resolveProviderModelDiscoveryUrl, } from "../src/providers/model-discovery"; import { PROVIDER_REGISTRY, @@ -80,6 +81,7 @@ describe("registry-owned provider model discovery", () => { ]) { expect(providerModelDiscoverySpecError({ path })).toContain("parent-directory"); } + expect(providerModelDiscoverySpecError({ path: "../models/search" })).toBeNull(); expect(providerModelDiscoverySpecError({ path: String.raw`models\..\internal` })) .toContain("forward slashes"); expect(providerModelDiscoverySpecError({ path: "models/model..variant" })).toBeNull(); @@ -373,6 +375,79 @@ describe("registry-owned provider model discovery", () => { .toEqual({ ok: false, reason: "invalid_shape" }); } }); + + test("cloudflare-workers-ai resolves official search from the /ai/v1 base", () => { + const url = resolveProviderModelDiscoveryUrl( + "cloudflare-workers-ai", + { + adapter: "openai-chat", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1", + }, + "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1", + "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/models", + ); + expect(url).toBe( + "https://api.cloudflare.com/client/v4/accounts/acct/ai/models/search?format=openrouter&per_page=1000", + ); + }); + + test("strips workers-ai/ openrouter ids and skips empty remainders for cloudflare-workers-ai", () => { + const discovery = resolveProviderModelDiscovery("cloudflare-workers-ai", { + adapter: "openai-chat", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1", + }); + + const stripped = extractProviderModelItems({ + data: [{ id: "workers-ai/@cf/openai/gpt-oss-120b" }], + }, discovery); + expect(stripped).toEqual({ + ok: true, + rawCount: 1, + items: [{ id: "@cf/openai/gpt-oss-120b" }], + }); + + const native = extractProviderModelItems({ + result: [{ id: "uuid", name: "@cf/meta/llama-3.3-70b-instruct-fp8-fast" }], + }, discovery); + expect(native).toEqual({ ok: false, reason: "invalid_shape" }); + + const mixed = extractProviderModelItems({ + data: [ + { id: "workers-ai/" }, + { id: "workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast" }, + ], + }, discovery); + expect(mixed).toEqual({ + ok: true, + rawCount: 2, + items: [{ id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast" }], + }); + }); + + test("cloudflare-workers-ai registry owns openrouter search discovery", () => { + const workers = PROVIDER_REGISTRY.find(row => row.id === "cloudflare-workers-ai"); + const gateway = PROVIDER_REGISTRY.find(row => row.id === "cloudflare-ai-gateway"); + if (!workers || !gateway) throw new Error("missing cloudflare registry entries"); + + expect(workers.liveModels).toBe(true); + expect(workers.modelDiscovery).toEqual({ + path: "../models/search", + query: { format: "openrouter", per_page: "1000" }, + stripIdPrefix: "workers-ai/", + maxModels: 256, + }); + expect(workers.models).toEqual([ + "@cf/meta/llama-3.3-70b-instruct-fp8-fast", + "@cf/qwen/qwq-32b", + "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", + "@cf/moonshotai/kimi-k2.7-code", + "@cf/zai-org/glm-5.3", + "@cf/zai-org/glm-5.2", + "@cf/mistralai/mistral-small-3.1-24b-instruct", + ]); + expect(gateway.modelDiscovery).toBeUndefined(); + expect(gateway.liveModels).toBeUndefined(); + }); }); describe("same-named custom provider preservation", () => { From 5e0b2abaa8e4a4aec8f58f60df33ec137af145c6 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:58:42 -0600 Subject: [PATCH 3/5] fix(models): reject encoded custom-id collisions Refuse custom ids whose encoded slug already names another known model, and fail ambiguous ocx models remove selectors instead of deleting the first match. --- .../provider-workspace/ProviderModels.tsx | 10 ++- gui/tests/provider-model-custom-add.test.tsx | 17 ++++ src/cli/models.ts | 21 +++-- src/providers/slug-codec.ts | 14 ++++ src/server/management/model-routes.ts | 13 ++- tests/catalog-input-modality-enum.test.ts | 79 +++++++++++++++++++ tests/cli-models.test.ts | 48 +++++++++++ tests/slug-codec.test.ts | 29 +++++++ 8 files changed, 224 insertions(+), 7 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderModels.tsx b/gui/src/components/provider-workspace/ProviderModels.tsx index caa96593f8..56cc588292 100644 --- a/gui/src/components/provider-workspace/ProviderModels.tsx +++ b/gui/src/components/provider-workspace/ProviderModels.tsx @@ -7,6 +7,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { filterModels } from "../../provider-workspace/report"; +import { encodedModelIdCollides } from "../../../../src/providers/slug-codec"; export default function ProviderModels({ item, @@ -48,12 +49,19 @@ export default function ProviderModels({ const selectedSet = useMemo(() => new Set(selectedModels), [selectedModels]); const configuredModels = useMemo(() => item.models ?? [], [item.models]); const trimmedCustomModelId = customModelId.trim(); + const knownModelIds = [ + ...availableModels, + ...customModelIds, + ...configuredModels, + ...(item.defaultModel ? [item.defaultModel] : []), + ]; const customModelInvalid = !customModelsReady || !trimmedCustomModelId || availableModels.includes(trimmedCustomModelId) || customModelIds.includes(trimmedCustomModelId) || configuredModels.includes(trimmedCustomModelId) - || item.defaultModel === trimmedCustomModelId; + || item.defaultModel === trimmedCustomModelId + || encodedModelIdCollides(trimmedCustomModelId, knownModelIds); const models = useMemo( () => filterModels(availableModels, item.defaultModel, query, configuredModels, customModelIds, hasLiveModels), [availableModels, item.defaultModel, query, configuredModels, customModelIds, hasLiveModels], diff --git a/gui/tests/provider-model-custom-add.test.tsx b/gui/tests/provider-model-custom-add.test.tsx index e6331270b3..6ebc04134a 100644 --- a/gui/tests/provider-model-custom-add.test.tsx +++ b/gui/tests/provider-model-custom-add.test.tsx @@ -132,6 +132,23 @@ test("quick-add blocks existing ids but allows namespaced model ids", async () = await act(async () => { root.unmount(); }); }); +test("quick-add blocks a slash id that encodes to an existing native id", async () => { + let requests = 0; + globalThis.fetch = (async (_input, init) => { + if (!init?.method || init.method === "GET") return Response.json([]); + requests += 1; + return Response.json({ id: "unexpected" }, { status: 201 }); + }) as typeof fetch; + const colliding = { ...item, models: ["openai-gpt-5.5"], defaultModel: "openai-gpt-5.5" } as WorkspaceItem; + const { root, input, addButton } = await mountProviderModels(["openai-gpt-5.5"], undefined, colliding); + + await enterModelId(input, "openai/gpt-5.5"); + expect(addButton.disabled).toBe(true); + expect(requests).toBe(0); + + await act(async () => { root.unmount(); }); +}); + test("quick-add keeps the model id when the server rejects it", async () => { globalThis.fetch = (async (_input, init) => ( !init?.method || init.method === "GET" diff --git a/src/cli/models.ts b/src/cli/models.ts index 4e3df861e3..db4f20742d 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -6,7 +6,8 @@ import { createInterface } from "node:readline/promises"; import { syncModelsToCodex } from "../codex/sync"; import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config"; import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort"; -import { routedSlug, slugEquals } from "../providers/slug-codec"; +import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec"; +import { knownModelIdsForProvider } from "../router"; import { findLiveProxy } from "../server/proxy-liveness"; import type { OcxConfig, OcxCustomModel } from "../types"; @@ -215,6 +216,10 @@ async function handleCustomAdd(args: string[]): Promise { if (existing.some(model => routedSlug(model.provider, model.modelId) === slug)) { fail(`custom model "${slug}" already exists`); } + const known = knownModelIdsForProvider(provider, config.providers[provider], config); + if (encodedModelIdCollides(modelId, known)) { + fail(`custom model "${slug}" is ambiguous; it encodes to an existing model id`); + } const entry: OcxCustomModel = { id: randomUUID(), @@ -255,10 +260,16 @@ async function handleCustomRemove(args: string[]): Promise { const config = loadConfig(); const existing = config.customModels ?? []; - const index = target.includes("/") - ? existing.findIndex(model => slugEquals(target, model.provider, model.modelId)) - : existing.findIndex(model => model.id === target); - if (index === -1) fail(`custom model "${target}" not found`); + const matchingIndexes = existing.flatMap((model, index) => ( + target.includes("/") + ? slugEquals(target, model.provider, model.modelId) + : model.id === target + ) ? [index] : []); + if (matchingIndexes.length === 0) fail(`custom model "${target}" not found`); + if (matchingIndexes.length > 1) { + fail(`custom model selector "${target}" is ambiguous; use the custom model id`); + } + const index = matchingIndexes[0]!; const model = existing[index]; if (!confirmed && !(await confirmCustomRemoval(model))) { diff --git a/src/providers/slug-codec.ts b/src/providers/slug-codec.ts index fb1a4c33ca..9d93036338 100644 --- a/src/providers/slug-codec.ts +++ b/src/providers/slug-codec.ts @@ -29,6 +29,20 @@ export function encodeRoutedModelId(id: string): string { return id.includes("/") ? id.replaceAll("/", SLUG_ALIAS_SEPARATOR) : id; } +/** + * True when `modelId` shares a Codex-facing encoded form with a different known id. + * That collision is what makes `provider/openai-gpt-5.5` decode to native `openai-gpt-5.5` + * while a custom `openai/gpt-5.5` row is still visible. + */ +export function encodedModelIdCollides(modelId: string, knownIds: Iterable): boolean { + const encoded = encodeRoutedModelId(modelId); + for (const id of knownIds) { + if (id === modelId) continue; + if (encodeRoutedModelId(id) === encoded) return true; + } + return false; +} + /** Codex-facing routed slug: exactly one "/" — `/`. */ export function routedSlug(provider: string, id: string): string { return `${provider}/${encodeRoutedModelId(id)}`; diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 6d7c93daa8..9d7eaf664d 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -97,7 +97,8 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; -import { routedSlug, slugEquals } from "../../providers/slug-codec"; +import { encodedModelIdCollides, routedSlug, slugEquals } from "../../providers/slug-codec"; +import { knownModelIdsForProvider } from "../../router"; import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; @@ -393,6 +394,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise routedSlug(cm.provider, cm.modelId) === newSlug)) { return jsonResponse({ error: "duplicate model" }, 409); } + const known = knownModelIdsForProvider(provider, config.providers[provider], config); + if (encodedModelIdCollides(modelId, known)) { + return jsonResponse({ error: "ambiguous model id" }, 409); + } const entry: OcxCustomModel = { id: randomUUID(), provider, @@ -467,6 +472,12 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) { return jsonResponse({ error: "duplicate model" }, 409); } + const known = knownModelIdsForProvider(cm.provider, config.providers[cm.provider], { + customModels: list.filter((_, i) => i !== idx), + }); + if (encodedModelIdCollides(cm.modelId, known)) { + return jsonResponse({ error: "ambiguous model id" }, 409); + } list[idx] = cm; config.customModels = list; persistConfig(config); diff --git a/tests/catalog-input-modality-enum.test.ts b/tests/catalog-input-modality-enum.test.ts index 73d00145bb..339a6e66a0 100644 --- a/tests/catalog-input-modality-enum.test.ts +++ b/tests/catalog-input-modality-enum.test.ts @@ -479,4 +479,83 @@ describe("custom-model API allows slash model ids", () => { expect(res?.status).toBe(400); expect(persistCalls).toBe(0); }); + + test("POST rejects a slash id that encodes to an existing native id", async () => { + persistCalls = 0; + const { handleModelRoutes } = await import("../src/server/management/model-routes"); + const url = new URL("http://127.0.0.1:10199/api/custom-models"); + const req = new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "deepseek", modelId: "openai/gpt-5.5" }), + }); + const res = await handleModelRoutes({ + req, + url, + config: { + providers: { + deepseek: { + adapter: "openai-chat", + baseUrl: "https://example.invalid/v1", + models: ["openai-gpt-5.5"], + }, + }, + } as unknown as Parameters[0]["config"], + deps: { + saveConfigPreservingClaudeCode: () => { persistCalls++; }, + } as Parameters[0]["deps"], + convergeCodexCatalog: async () => ({ + status: "committed", + changed: false, + degraded: false, + notices: [], + }), + syncClaudeAgentDefsBestEffort: async () => {}, + }); + expect(res?.status).toBe(409); + const payload = await res!.json() as { error?: string }; + expect(payload.error).toContain("ambiguous"); + expect(persistCalls).toBe(0); + }); + + test("PUT rejects renaming onto a colliding native id", async () => { + persistCalls = 0; + const { handleModelRoutes } = await import("../src/server/management/model-routes"); + const url = new URL("http://127.0.0.1:10199/api/custom-models/existing-uuid"); + const req = new Request(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ modelId: "a/b-c" }), + }); + const res = await handleModelRoutes({ + req, + url, + config: { + providers: { + deepseek: { + adapter: "openai-chat", + baseUrl: "https://example.invalid/v1", + models: ["a-b/c"], + }, + }, + customModels: [ + { id: "existing-uuid", provider: "deepseek", modelId: "deepseek-v4" }, + ], + } as unknown as Parameters[0]["config"], + deps: { + saveConfigPreservingClaudeCode: () => { persistCalls++; }, + } as Parameters[0]["deps"], + convergeCodexCatalog: async () => ({ + status: "committed", + changed: false, + degraded: false, + notices: [], + }), + syncClaudeAgentDefsBestEffort: async () => {}, + }); + expect(res?.status).toBe(409); + const payload = await res!.json() as { error?: string }; + expect(payload.error).toContain("ambiguous"); + expect(persistCalls).toBe(0); + }); }); diff --git a/tests/cli-models.test.ts b/tests/cli-models.test.ts index 52b9e8fc1e..3bb99c1f20 100644 --- a/tests/cli-models.test.ts +++ b/tests/cli-models.test.ts @@ -217,4 +217,52 @@ describe("ocx models custom slash ids", () => { rmSync(dir, { recursive: true, force: true }); } }); + + test("models add rejects a slash id that encodes to an existing native id", () => { + const { dir } = freshConfig({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + test: { + adapter: "openai-chat", + baseUrl: "http://localhost:8080/v1", + allowPrivateNetwork: true, + defaultModel: "openai-gpt-5.5", + models: ["openai-gpt-5.5", "a-b/c"], + }, + }, + }); + try { + const slash = runCli(["models", "add", "test", "openai/gpt-5.5"], { OPENCODEX_HOME: dir }); + expect(slash.status).toBe(1); + expect(slash.stderr).toContain("ambiguous"); + const multi = runCli(["models", "add", "test", "a/b-c"], { OPENCODEX_HOME: dir }); + expect(multi.status).toBe(1); + expect(multi.stderr).toContain("ambiguous"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("models remove rejects an encoded selector that matches more than one custom model", () => { + const { dir } = freshConfig({ + customModels: [ + { id: "11111111-1111-4111-8111-111111111111", provider: "test", modelId: "openai/gpt-5.5" }, + { id: "22222222-2222-4222-8222-222222222222", provider: "test", modelId: "openai-gpt-5.5" }, + ], + }); + try { + const result = runCli(["models", "remove", "test/openai-gpt-5.5", "--yes"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("ambiguous"); + expect(result.stderr).toContain("custom model id"); + const config = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")); + expect(config.customModels).toHaveLength(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/slug-codec.test.ts b/tests/slug-codec.test.ts index 23d2749ca3..662de51332 100644 --- a/tests/slug-codec.test.ts +++ b/tests/slug-codec.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { decodeRoutedModelId, encodeRoutedModelId, + encodedModelIdCollides, routedSlug, slugEquals, slugEquivalenceKey, @@ -77,6 +78,12 @@ describe("slug-codec primitives", () => { expect(decodeRoutedModelId("x-y-z", known)).toBe("x-y-z"); }); + test("encodedModelIdCollides detects native vs slash custom collisions", () => { + expect(encodedModelIdCollides("openai/gpt-5.5", ["openai-gpt-5.5"])).toBe(true); + expect(encodedModelIdCollides("a/b-c", ["a-b/c"])).toBe(true); + expect(encodedModelIdCollides("openai/gpt-5.5", ["openai/gpt-5.5", "other"])).toBe(false); + }); + test("slugEquals / slugsEquivalent tolerate raw and encoded mixes", () => { expect(slugEquals("zenmux/moonshotai/kimi-k3-free", "zenmux", "moonshotai/kimi-k3-free")).toBe(true); expect(slugEquals("zenmux/moonshotai-kimi-k3-free", "zenmux", "moonshotai/kimi-k3-free")).toBe(true); @@ -187,6 +194,28 @@ describe("routeModel decode (proxy layer)", () => { expect(route.providerName).toBe("zenmux"); expect(route.modelId).toBe("openai/gpt-5.5"); }); + + test("routeModel prefers native hyphen id over colliding custom slash id", () => { + const config = zenmuxConfig(); + config.providers.zenmux!.models = ["openai-gpt-5.5"]; + config.customModels = [ + { id: "c1", provider: "zenmux", modelId: "openai/gpt-5.5" }, + ]; + const route = routeModel(config, "zenmux/openai-gpt-5.5"); + expect(route.providerName).toBe("zenmux"); + expect(route.modelId).toBe("openai-gpt-5.5"); + }); + + test("routeModel refuses to guess between a/b-c and a-b/c", () => { + const config = zenmuxConfig(); + config.providers.zenmux!.models = ["a-b/c"]; + config.customModels = [ + { id: "c1", provider: "zenmux", modelId: "a/b-c" }, + ]; + const route = routeModel(config, "zenmux/a-b-c"); + expect(route.providerName).toBe("zenmux"); + expect(route.modelId).toBe("a-b-c"); + }); }); describe("catalog emission (Codex-facing)", () => { From 41abdb93c58f96297009910bdf89c933804e1e48 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:27:40 -0600 Subject: [PATCH 4/5] fix(models): fail encoded custom collisions at route time Include defaultModel in the shared known-id set so CLI/API match the GUI, and throw when a later live cache makes a custom encoded slug collide with another known native id. --- src/claude/agents-inject.ts | 4 +-- src/providers/slug-codec.ts | 14 +++++++++ src/router.ts | 5 +-- tests/catalog-input-modality-enum.test.ts | 38 +++++++++++++++++++++++ tests/cli-models.test.ts | 26 ++++++++++++++++ tests/slug-codec.test.ts | 28 ++++++++++++----- 6 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/claude/agents-inject.ts b/src/claude/agents-inject.ts index e621e700aa..f40b8a97e4 100644 --- a/src/claude/agents-inject.ts +++ b/src/claude/agents-inject.ts @@ -20,7 +20,7 @@ import { claudeConfigDir } from "./gateway-cache"; import { DEFAULT_SUBAGENT_MODELS, hasOwnProvider } from "../config"; import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound"; import { knownModelIdsForProvider } from "../router"; -import { decodeRoutedModelId } from "../providers/slug-codec"; +import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec"; export interface ClaudeAgentDef { file: string; @@ -85,7 +85,7 @@ function entryParts(entry: string, config: OcxConfig): { alias: string; id: stri const provider = entry.slice(0, slash); const prov = hasOwnProvider(config.providers, provider) ? config.providers[provider] : undefined; const id = prov - ? decodeRoutedModelId(entry.slice(slash + 1), knownModelIdsForProvider(provider, prov, config)) + ? decodeRoutedModelIdOrThrow(entry.slice(slash + 1), knownModelIdsForProvider(provider, prov, config)) : entry.slice(slash + 1); return { alias: claudeCodeAlias(provider, id), id, provider }; } diff --git a/src/providers/slug-codec.ts b/src/providers/slug-codec.ts index 9d93036338..c403bf7b68 100644 --- a/src/providers/slug-codec.ts +++ b/src/providers/slug-codec.ts @@ -65,6 +65,20 @@ export function decodeRoutedModelId(requested: string, knownIds: Iterable): string { + const encodedRequested = encodeRoutedModelId(requested); + const matches = new Set(); + for (const id of knownIds) { + if (id === requested || encodeRoutedModelId(id) === encodedRequested) matches.add(id); + } + if (matches.size > 1) throw new Error(`ambiguous model id "${requested}"`); + return decodeRoutedModelId(requested, knownIds); +} + /** Does a stored config slug name this routed model, in either raw or encoded form? */ export function slugEquals(stored: string, provider: string, id: string): boolean { return stored === `${provider}/${id}` || stored === routedSlug(provider, id); diff --git a/src/router.ts b/src/router.ts index 49160e9cae..791b8c77a2 100644 --- a/src/router.ts +++ b/src/router.ts @@ -24,7 +24,7 @@ import { OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, } from "./providers/openai-tiers"; -import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec"; +import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec"; import { getStaleCached } from "./codex/model-cache"; import { codexAccountNamespaceEntries } from "./codex/account-namespaces"; import { @@ -94,6 +94,7 @@ export function knownModelIdsForProvider( ): string[] { const ids = new Set(); for (const id of prov.models ?? []) ids.add(id); + if (prov.defaultModel) ids.add(prov.defaultModel); const registry = providerMatchesRegistryTransportWithStaticGuards(provName, prov) ? PROVIDER_REGISTRY.find(entry => entry.id === provName) : undefined; @@ -629,7 +630,7 @@ function routeModelInternal( return routeResult( provName, prov, - decodeRoutedModelId(modelId.slice(slash + 1), known), + decodeRoutedModelIdOrThrow(modelId.slice(slash + 1), known), "explicit-provider", "explicit-provider-namespace", ); diff --git a/tests/catalog-input-modality-enum.test.ts b/tests/catalog-input-modality-enum.test.ts index 339a6e66a0..6f1a073220 100644 --- a/tests/catalog-input-modality-enum.test.ts +++ b/tests/catalog-input-modality-enum.test.ts @@ -558,4 +558,42 @@ describe("custom-model API allows slash model ids", () => { expect(payload.error).toContain("ambiguous"); expect(persistCalls).toBe(0); }); + + test("POST rejects a slash id that encodes to defaultModel only", async () => { + persistCalls = 0; + const { handleModelRoutes } = await import("../src/server/management/model-routes"); + const url = new URL("http://127.0.0.1:10199/api/custom-models"); + const req = new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "deepseek", modelId: "openai/gpt-5.5" }), + }); + const res = await handleModelRoutes({ + req, + url, + config: { + providers: { + deepseek: { + adapter: "openai-chat", + baseUrl: "https://example.invalid/v1", + defaultModel: "openai-gpt-5.5", + }, + }, + } as unknown as Parameters[0]["config"], + deps: { + saveConfigPreservingClaudeCode: () => { persistCalls++; }, + } as Parameters[0]["deps"], + convergeCodexCatalog: async () => ({ + status: "committed", + changed: false, + degraded: false, + notices: [], + }), + syncClaudeAgentDefsBestEffort: async () => {}, + }); + expect(res?.status).toBe(409); + const payload = await res!.json() as { error?: string }; + expect(payload.error).toContain("ambiguous"); + expect(persistCalls).toBe(0); + }); }); diff --git a/tests/cli-models.test.ts b/tests/cli-models.test.ts index 3bb99c1f20..e0564f971b 100644 --- a/tests/cli-models.test.ts +++ b/tests/cli-models.test.ts @@ -247,6 +247,32 @@ describe("ocx models custom slash ids", () => { } }); + test("models add rejects a slash id that encodes to defaultModel only", () => { + const { dir } = freshConfig({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + test: { + adapter: "openai-chat", + baseUrl: "http://localhost:8080/v1", + allowPrivateNetwork: true, + defaultModel: "openai-gpt-5.5", + models: [], + }, + }, + }); + try { + const result = runCli(["models", "add", "test", "openai/gpt-5.5"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("ambiguous"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("models remove rejects an encoded selector that matches more than one custom model", () => { const { dir } = freshConfig({ customModels: [ diff --git a/tests/slug-codec.test.ts b/tests/slug-codec.test.ts index 662de51332..36922e893b 100644 --- a/tests/slug-codec.test.ts +++ b/tests/slug-codec.test.ts @@ -15,7 +15,7 @@ import { } from "../src/providers/slug-codec"; import { knownModelIdsForProvider, routeModel } from "../src/router"; import { buildCatalogEntries, resetCatalogRuntimeStateForTests } from "../src/codex/catalog"; -import { clearModelCache } from "../src/codex/model-cache"; +import { clearModelCache, setCached } from "../src/codex/model-cache"; import { getModelMetadata } from "../src/generated/model-metadata"; import type { RawEntry } from "../src/codex/catalog"; import type { OcxConfig } from "../src/types"; @@ -185,6 +185,13 @@ describe("routeModel decode (proxy layer)", () => { expect(ids).not.toContain("should-not-appear"); }); + test("knownModelIdsForProvider unions defaultModel", () => { + const config = zenmuxConfig(); + config.providers.zenmux!.defaultModel = "openai-gpt-5.5"; + const ids = knownModelIdsForProvider("zenmux", config.providers.zenmux!, config); + expect(ids).toContain("openai-gpt-5.5"); + }); + test("routeModel decodes encoded custom slash id back to native id", () => { const config = zenmuxConfig(); config.customModels = [ @@ -201,9 +208,7 @@ describe("routeModel decode (proxy layer)", () => { config.customModels = [ { id: "c1", provider: "zenmux", modelId: "openai/gpt-5.5" }, ]; - const route = routeModel(config, "zenmux/openai-gpt-5.5"); - expect(route.providerName).toBe("zenmux"); - expect(route.modelId).toBe("openai-gpt-5.5"); + expect(() => routeModel(config, "zenmux/openai-gpt-5.5")).toThrow(/ambiguous/); }); test("routeModel refuses to guess between a/b-c and a-b/c", () => { @@ -212,9 +217,18 @@ describe("routeModel decode (proxy layer)", () => { config.customModels = [ { id: "c1", provider: "zenmux", modelId: "a/b-c" }, ]; - const route = routeModel(config, "zenmux/a-b-c"); - expect(route.providerName).toBe("zenmux"); - expect(route.modelId).toBe("a-b-c"); + expect(() => routeModel(config, "zenmux/a-b-c")).toThrow(/ambiguous/); + }); + + test("routeModel fails when a later live cache collides with an admitted custom slash id", () => { + const config = zenmuxConfig(); + config.customModels = [ + { id: "c1", provider: "zenmux", modelId: "openai/gpt-5.5" }, + ]; + const admitted = routeModel(config, "zenmux/openai-gpt-5.5"); + expect(admitted.modelId).toBe("openai/gpt-5.5"); + setCached("zenmux", [{ provider: "zenmux", id: "openai-gpt-5.5" }]); + expect(() => routeModel(config, "zenmux/openai-gpt-5.5")).toThrow(/ambiguous/); }); }); From facbb0c2300ae979a810a8bc21b50bf77dc8f6de Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:20:17 -0600 Subject: [PATCH 5/5] fix(models): materialize known ids before decode Reuse one array in decodeRoutedModelIdOrThrow so a single-use iterable still decodes openai-gpt-5.5 to openai/gpt-5.5. --- src/providers/slug-codec.ts | 5 +++-- tests/slug-codec.test.ts | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/providers/slug-codec.ts b/src/providers/slug-codec.ts index c403bf7b68..61e25200db 100644 --- a/src/providers/slug-codec.ts +++ b/src/providers/slug-codec.ts @@ -70,13 +70,14 @@ export function decodeRoutedModelId(requested: string, knownIds: Iterable): string { + const ids = [...knownIds]; const encodedRequested = encodeRoutedModelId(requested); const matches = new Set(); - for (const id of knownIds) { + for (const id of ids) { if (id === requested || encodeRoutedModelId(id) === encodedRequested) matches.add(id); } if (matches.size > 1) throw new Error(`ambiguous model id "${requested}"`); - return decodeRoutedModelId(requested, knownIds); + return decodeRoutedModelId(requested, ids); } /** Does a stored config slug name this routed model, in either raw or encoded form? */ diff --git a/tests/slug-codec.test.ts b/tests/slug-codec.test.ts index 36922e893b..c2beb9bdf3 100644 --- a/tests/slug-codec.test.ts +++ b/tests/slug-codec.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { decodeRoutedModelId, + decodeRoutedModelIdOrThrow, encodeRoutedModelId, encodedModelIdCollides, routedSlug, @@ -84,6 +85,11 @@ describe("slug-codec primitives", () => { expect(encodedModelIdCollides("openai/gpt-5.5", ["openai/gpt-5.5", "other"])).toBe(false); }); + test("decodeRoutedModelIdOrThrow decodes a single-use generator", () => { + function* ids() { yield "openai/gpt-5.5"; } + expect(decodeRoutedModelIdOrThrow("openai-gpt-5.5", ids())).toBe("openai/gpt-5.5"); + }); + test("slugEquals / slugsEquivalent tolerate raw and encoded mixes", () => { expect(slugEquals("zenmux/moonshotai/kimi-k3-free", "zenmux", "moonshotai/kimi-k3-free")).toBe(true); expect(slugEquals("zenmux/moonshotai-kimi-k3-free", "zenmux", "moonshotai/kimi-k3-free")).toBe(true);