diff --git a/gui/src/components/provider-workspace/ProviderModels.tsx b/gui/src/components/provider-workspace/ProviderModels.tsx index 9a3978f23a..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,13 +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 - || trimmedCustomModelId.includes("/") || 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 d8c1848319..6ebc04134a 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,6 +126,23 @@ 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(false); + expect(requests).toBe(0); + + 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); diff --git a/src/claude/agents-inject.ts b/src/claude/agents-inject.ts index e3b15a4b87..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)) + ? decodeRoutedModelIdOrThrow(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..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 } 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"; @@ -180,7 +181,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)) { @@ -216,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(), @@ -256,10 +260,16 @@ 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 => 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/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/src/providers/slug-codec.ts b/src/providers/slug-codec.ts index fb1a4c33ca..61e25200db 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)}`; @@ -51,6 +65,21 @@ export function decodeRoutedModelId(requested: string, knownIds: Iterable): string { + const ids = [...knownIds]; + const encodedRequested = encodeRoutedModelId(requested); + const matches = new Set(); + 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, ids); +} + /** 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 7950425c3c..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 { @@ -87,9 +87,14 @@ 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); + if (prov.defaultModel) ids.add(prov.defaultModel); const registry = providerMatchesRegistryTransportWithStaticGuards(provName, prov) ? PROVIDER_REGISTRY.find(entry => entry.id === provName) : undefined; @@ -108,6 +113,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 +618,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. @@ -622,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/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 0cf22043f4..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"; @@ -376,7 +377,6 @@ 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, @@ -422,7 +426,6 @@ 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/src/types.ts b/src/types.ts index a2771de0e5..0772f50d76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -596,7 +596,7 @@ export interface OcxCustomModel { id: string; /** 프로바이더 키 (기존 providers[name]) */ provider: string; - /** 모델 슬러그 (프로바이더 접두사 없는 bare id) */ + /** Native provider model id; slashes are allowed and encoded for Codex as provider/. */ modelId: string; /** 인간 가독 표시명 (선택, 슬래시 불가) */ displayName?: string; diff --git a/tests/catalog-input-modality-enum.test.ts b/tests/catalog-input-modality-enum.test.ts index cc8bf39b35..6f1a073220 100644 --- a/tests/catalog-input-modality-enum.test.ts +++ b/tests/catalog-input-modality-enum.test.ts @@ -414,3 +414,186 @@ 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); + }); + + 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); + }); + + 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 e40c0ed2a8..e0564f971b 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,121 @@ 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 }); + } + }); + + 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 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: [ + { 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/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", () => { diff --git a/tests/slug-codec.test.ts b/tests/slug-codec.test.ts index 70f8a2fd02..c2beb9bdf3 100644 --- a/tests/slug-codec.test.ts +++ b/tests/slug-codec.test.ts @@ -6,7 +6,9 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { decodeRoutedModelId, + decodeRoutedModelIdOrThrow, encodeRoutedModelId, + encodedModelIdCollides, routedSlug, slugEquals, slugEquivalenceKey, @@ -14,7 +16,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"; @@ -77,6 +79,17 @@ 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("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); @@ -166,6 +179,63 @@ 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("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 = [ + { 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 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" }, + ]; + expect(() => routeModel(config, "zenmux/openai-gpt-5.5")).toThrow(/ambiguous/); + }); + + 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" }, + ]; + 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/); + }); }); describe("catalog emission (Codex-facing)", () => {