Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions gui/src/components/provider-workspace/ProviderModels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down
19 changes: 18 additions & 1 deletion gui/tests/provider-model-custom-add.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand All @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions src/claude/agents-inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}
Expand Down
22 changes: 16 additions & 6 deletions src/cli/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -180,7 +181,6 @@ async function handleCustomAdd(args: string[]): Promise<void> {

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)) {
Expand Down Expand Up @@ -216,6 +216,10 @@ async function handleCustomAdd(args: string[]): Promise<void> {
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(),
Expand Down Expand Up @@ -256,10 +260,16 @@ async function handleCustomRemove(args: string[]): Promise<void> {

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))) {
Expand Down
20 changes: 15 additions & 5 deletions src/providers/model-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
Expand Down Expand Up @@ -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 };
Expand Down
13 changes: 13 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/providers/slug-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): 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 "/" — `<provider>/<encoded id>`. */
export function routedSlug(provider: string, id: string): string {
return `${provider}/${encodeRoutedModelId(id)}`;
Expand All @@ -51,6 +65,21 @@ export function decodeRoutedModelId(requested: string, knownIds: Iterable<string
return aliasMatch ?? requested;
}

/**
* Decode a Codex-facing id, but fail when a custom slash id and another known id
* share the same encoded form. Write-time checks cannot cover a later live cache.
*/
export function decodeRoutedModelIdOrThrow(requested: string, knownIds: Iterable<string>): string {
const ids = [...knownIds];
const encodedRequested = encodeRoutedModelId(requested);
const matches = new Set<string>();
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);
Expand Down
16 changes: 12 additions & 4 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<OcxConfig, "customModels">,
): string[] {
const ids = new Set<string>();
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;
Expand All @@ -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];
}

Expand Down Expand Up @@ -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.
Expand All @@ -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",
);
Expand Down
15 changes: 12 additions & 3 deletions src/server/management/model-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -376,7 +377,6 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
const provider = typeof body.provider === "string" ? body.provider.trim() : "";
const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
if (modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
if (!isValidProviderName(provider)) return jsonResponse({ error: "invalid provider name" }, 400);
if (!hasOwnProvider(config.providers, provider)) return jsonResponse({ error: "provider not configured" }, 404);
const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
Expand All @@ -394,6 +394,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
if (existing.some(cm => 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,
Expand Down Expand Up @@ -422,7 +426,6 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
const cm = { ...list[idx] };
if (typeof body.modelId === "string" && body.modelId.trim()) {
if (body.modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
cm.modelId = body.modelId.trim();
}
if (body.displayName !== undefined) {
Expand Down Expand Up @@ -469,6 +472,12 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
if (list.some((other, i) => 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);
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hyphenated-id>. */
modelId: string;
/** 인간 가독 표시명 (선택, 슬래시 불가) */
displayName?: string;
Expand Down
Loading
Loading