From 6e34da7fca2403086764400a5994ac9325dfb574 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 10 Aug 2026 12:55:54 +0800 Subject: [PATCH] Enforce profile model allowlists across agent catalogs --- packages/core/src/agents/codex/app-launch.ts | 15 +- .../core/src/agents/codex/model-catalog.ts | 44 ++- .../core/src/agents/kilo/profile-config.ts | 3 +- .../src/agents/opencode/profile-config.ts | 7 +- packages/core/src/agents/pi/profile-config.ts | 7 +- .../core/src/agents/zcode/model-catalog.ts | 13 +- .../core/src/agents/zcode/profile-config.ts | 3 +- packages/core/src/config/config.ts | 12 +- .../src/gateway/claude-code-router-plugin.ts | 17 +- .../src/gateway/features/model-discovery.ts | 72 +++-- .../core/src/gateway/http/request-handler.ts | 15 +- packages/core/src/gateway/request/pipeline.ts | 59 +++- packages/core/src/profiles/model-allowlist.ts | 190 ++++++++++++ packages/core/src/profiles/service.ts | 9 +- .../profile-model-allowlist-http.test.mjs | 289 ++++++++++++++++++ .../unit/agents/codex-model-catalog.test.mjs | 13 + .../profiles/profile-model-allowlist.test.mjs | 195 ++++++++++++ .../pages/home/components/model-selector.tsx | 39 ++- .../ui/src/pages/home/components/profiles.tsx | 105 +++---- packages/ui/src/pages/home/shared/i18n.tsx | 8 +- packages/ui/src/pages/home/shared/profiles.ts | 63 ++-- .../ui/test/component/onboarding.test.tsx | 2 +- packages/ui/test/component/profiles.test.tsx | 152 ++++++++- 23 files changed, 1168 insertions(+), 164 deletions(-) create mode 100644 packages/core/src/profiles/model-allowlist.ts create mode 100644 packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs create mode 100644 packages/core/test/unit/profiles/profile-model-allowlist.test.mjs diff --git a/packages/core/src/agents/codex/app-launch.ts b/packages/core/src/agents/codex/app-launch.ts index 08b7d32b..bd9e2f29 100644 --- a/packages/core/src/agents/codex/app-launch.ts +++ b/packages/core/src/agents/codex/app-launch.ts @@ -10,6 +10,7 @@ import { buildProfileLaunchPlan, resolveCodexConfigFile } from "@ccr/core/profil import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery"; import { buildZcodeModelCatalog } from "@ccr/core/agents/zcode/model-catalog"; import { writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; export type CodexAppLookupResult = { checked: string[]; @@ -204,7 +205,7 @@ export function writeCodexCompatibleAppModelCatalog( const userDataDir = codexElectronUserDataDir(codexHome, profile, spec); mkdirSync(userDataDir, { recursive: true }); const file = codexAppModelCatalogFile(userDataDir, spec); - const content = codexCompatibleAppModelCatalogJson(config, profile.model, spec.kind); + const content = codexCompatibleAppModelCatalogJson(config, profile.model, spec.kind, profileAllowedModels(profile)); const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined; if (previous !== content) { writeFileSync(file, content, "utf8"); @@ -215,19 +216,21 @@ export function writeCodexCompatibleAppModelCatalog( function codexCompatibleAppModelCatalogJson( config?: CodexCompatibleAppModelCatalogConfig, selectedModel?: string, - kind: CodexCompatibleAppKind = "codex" + kind: CodexCompatibleAppKind = "codex", + allowedModels?: string[] ): string { - return `${JSON.stringify(codexCompatibleAppModelCatalog(config, selectedModel, kind), null, 2)}\n`; + return `${JSON.stringify(codexCompatibleAppModelCatalog(config, selectedModel, kind, allowedModels), null, 2)}\n`; } function codexCompatibleAppModelCatalog( config?: CodexCompatibleAppModelCatalogConfig, selectedModel?: string, - kind: CodexCompatibleAppKind = "codex" + kind: CodexCompatibleAppKind = "codex", + allowedModels?: string[] ): CodexModelCatalog { const catalog = kind === "zcode" - ? buildZcodeModelCatalog(config, selectedModel) - : buildCodexModelCatalog(config, selectedModel); + ? buildZcodeModelCatalog(config, selectedModel, { allowedModels }) + : buildCodexModelCatalog(config, selectedModel, { allowedModels }); return { models: catalog.models.map((model) => codexCompatibleAppModelCatalogItem(model, config)) }; diff --git a/packages/core/src/agents/codex/model-catalog.ts b/packages/core/src/agents/codex/model-catalog.ts index c25cece6..6ed51a61 100644 --- a/packages/core/src/agents/codex/model-catalog.ts +++ b/packages/core/src/agents/codex/model-catalog.ts @@ -10,6 +10,7 @@ import { codexDefaultBaseUrl, codexImportedModelContextWindow, readCodexLocalMod import { localAgentProviderApiKey } from "@ccr/core/agents/local-providers/shared"; import { normalizeProviderBaseUrl } from "@ccr/core/providers/url"; import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution"; +import { filterModelIdsByAllowedModels } from "@ccr/core/profiles/model-allowlist"; const fusionModelProviderName = "Fusion"; const codexDefaultContextWindow = 128_000; @@ -19,6 +20,10 @@ export type CodexModelCatalog = { models: CodexModelCatalogItem[]; }; +export type CodexModelCatalogOptions = { + allowedModels?: string[]; +}; + export type CodexModelCatalogItem = { additional_speed_tiers: unknown[]; apply_patch_tool_type: string | null; @@ -57,13 +62,21 @@ export type CodexModelCatalogItem = { web_search_tool_type: string; }; -export function buildCodexModelCatalog(config?: Partial>, selectedModel?: string): CodexModelCatalog { +export function buildCodexModelCatalog( + config?: Partial>, + selectedModel?: string, + options: CodexModelCatalogOptions = {} +): CodexModelCatalog { return { - models: buildCodexModelCatalogIds(config, selectedModel).map((model, index) => codexModelCatalogItem(model, index, config)) + models: buildCodexModelCatalogIds(config, selectedModel, options).map((model, index) => codexModelCatalogItem(model, index, config)) }; } -export function buildCodexModelCatalogIds(config?: Partial>, selectedModel?: string): string[] { +export function buildCodexModelCatalogIds( + config?: Partial>, + selectedModel?: string, + options: CodexModelCatalogOptions = {} +): string[] { const ids: string[] = []; pushUniqueModel(ids, normalizeModelSelector(selectedModel)); @@ -106,15 +119,30 @@ export function buildCodexModelCatalogIds(config?: Partial>, selectedModel?: string): string { - return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel), null, 2)}\n`; +export function codexModelCatalogJson( + config?: Partial>, + selectedModel?: string, + options: CodexModelCatalogOptions = {} +): string { + return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel, options), null, 2)}\n`; } -export function codexModelCatalogBase64(config?: Partial>, selectedModel?: string): string { - const catalog = buildCodexModelCatalog(config, selectedModel); +export function codexModelCatalogBase64( + config?: Partial>, + selectedModel?: string, + options: CodexModelCatalogOptions = {} +): string { + const catalog = buildCodexModelCatalog(config, selectedModel, options); return Buffer.from(JSON.stringify(catalog), "utf8").toString("base64"); } diff --git a/packages/core/src/agents/kilo/profile-config.ts b/packages/core/src/agents/kilo/profile-config.ts index bfd36722..17126813 100644 --- a/packages/core/src/agents/kilo/profile-config.ts +++ b/packages/core/src/agents/kilo/profile-config.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { buildCodexModelCatalogIds } from "@ccr/core/agents/codex/model-catalog"; import { parseJsoncRecord } from "@ccr/core/agents/local-providers/shared"; import { isGatewayProviderEnabled, type AppConfig, type ProfileConfig } from "@ccr/core/contracts/app"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; export type KiloProfileConfigWriteResult = { backupFile?: string; @@ -93,7 +94,7 @@ function kiloGatewayOverrides(config: AppConfig, profile: ProfileConfig, token: const providerName = profile.providerName?.trim() || "Claude Code Router"; const model = normalizeClientModel(profile.model) || defaultClientModel(config); const modelRef = `${providerId}/${model}`; - const models = buildCodexModelCatalogIds(config, model); + const models = buildCodexModelCatalogIds(config, model, { allowedModels: profileAllowedModels({ ...profile, model }) }); return { $schema: "https://app.kilo.ai/config.json", model: modelRef, diff --git a/packages/core/src/agents/opencode/profile-config.ts b/packages/core/src/agents/opencode/profile-config.ts index e09dfec9..2c3f4e4b 100644 --- a/packages/core/src/agents/opencode/profile-config.ts +++ b/packages/core/src/agents/opencode/profile-config.ts @@ -7,6 +7,7 @@ import { isGatewayProviderEnabled, type AppConfig, type GatewayProviderConfig, t import { findModelCatalogEntry, type ModelCatalogEntry, modelCatalogMaxInputTokens, modelCatalogMaxOutputTokens } from "@ccr/core/gateway/model-catalog"; import { modelRegistryForConfig } from "@ccr/core/routing/model-registry"; import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; export type OpenCodeProfileConfigWriteResult = { backupFile?: string; @@ -97,7 +98,7 @@ function openCodeGatewayOverrides(config: AppConfig, profile: ProfileConfig, tok const providerName = profile.providerName?.trim() || "Claude Code Router"; const model = normalizeClientModel(profile.model) || defaultClientModel(config); const modelRef = `${providerId}/${model}`; - const models = openCodeModelConfigs(config, model); + const models = openCodeModelConfigs(config, { ...profile, model }, model); return { $schema: "https://opencode.ai/config.json", model: modelRef, @@ -120,8 +121,8 @@ function openCodeGatewayOverrides(config: AppConfig, profile: ProfileConfig, tok }; } -function openCodeModelConfigs(config: AppConfig, selectedModel: string): Record { - const catalog = buildCodexModelCatalog(config, selectedModel); +function openCodeModelConfigs(config: AppConfig, profile: ProfileConfig, selectedModel: string): Record { + const catalog = buildCodexModelCatalog(config, selectedModel, { allowedModels: profileAllowedModels(profile) }); const resolutionConfig: OpenCodeModelResolutionConfig = { Providers: config.Providers ?? [], virtualModelProfiles: config.virtualModelProfiles ?? [] diff --git a/packages/core/src/agents/pi/profile-config.ts b/packages/core/src/agents/pi/profile-config.ts index d3b68e01..09662c9a 100644 --- a/packages/core/src/agents/pi/profile-config.ts +++ b/packages/core/src/agents/pi/profile-config.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { buildCodexModelCatalogIds } from "@ccr/core/agents/codex/model-catalog"; import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; export type PiProfileConfigWriteResult = { changed: boolean; @@ -49,7 +50,7 @@ export function writePiGatewayConfig( const sessionDir = resolvePiSessionDir(configDir, profile); const file = path.join(profileHome, "models.json"); const providerId = sanitizeProviderId(profile.providerId || "") || "claude-code-router"; - const models = piProfileModels(config, defaultModel); + const models = piProfileModels(config, profile, defaultModel); const model = models.includes(defaultModel) ? defaultModel : models[0] || defaultModel || "default"; const content = `${JSON.stringify(piModelsJson(config, profile, providerId, token, models), null, 2)}\n`; const changed = writeJsonFileIfChanged(file, content); @@ -97,10 +98,10 @@ function piModelConfig(model: string): Record { }; } -function piProfileModels(config: AppConfig, defaultModel: string): string[] { +function piProfileModels(config: AppConfig, profile: ProfileConfig, defaultModel: string): string[] { return uniqueStrings([ defaultModel, - ...buildCodexModelCatalogIds(config, defaultModel) + ...buildCodexModelCatalogIds(config, defaultModel, { allowedModels: profileAllowedModels({ ...profile, model: defaultModel }) }) ].filter(Boolean)); } diff --git a/packages/core/src/agents/zcode/model-catalog.ts b/packages/core/src/agents/zcode/model-catalog.ts index 6d036649..c8a87664 100644 --- a/packages/core/src/agents/zcode/model-catalog.ts +++ b/packages/core/src/agents/zcode/model-catalog.ts @@ -1,7 +1,8 @@ import type { AppConfig } from "@ccr/core/contracts/app"; import { buildCodexModelCatalog, - type CodexModelCatalog + type CodexModelCatalog, + type CodexModelCatalogOptions } from "@ccr/core/agents/codex/model-catalog"; import { findModelCatalogEntry, @@ -23,9 +24,10 @@ type ZcodeModelResolutionConfig = Pick< export function buildZcodeModelCatalog( config?: ZcodeModelCatalogConfig, - selectedModel?: string + selectedModel?: string, + options: CodexModelCatalogOptions = {} ): CodexModelCatalog { - const catalog = buildCodexModelCatalog(config, selectedModel); + const catalog = buildCodexModelCatalog(config, selectedModel, options); const resolutionConfig = config ? { Providers: config.Providers ?? [], @@ -74,7 +76,8 @@ function zcodeModelCatalogEntry( export function zcodeModelCatalogJson( config?: ZcodeModelCatalogConfig, - selectedModel?: string + selectedModel?: string, + options: CodexModelCatalogOptions = {} ): string { - return `${JSON.stringify(buildZcodeModelCatalog(config, selectedModel), null, 2)}\n`; + return `${JSON.stringify(buildZcodeModelCatalog(config, selectedModel, options), null, 2)}\n`; } diff --git a/packages/core/src/agents/zcode/profile-config.ts b/packages/core/src/agents/zcode/profile-config.ts index 6e2b21f0..abe33728 100644 --- a/packages/core/src/agents/zcode/profile-config.ts +++ b/packages/core/src/agents/zcode/profile-config.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { isGatewayProviderEnabled, type AppConfig, type ProfileConfig } from "@ccr/core/contracts/app"; import { normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin"; import { buildZcodeModelCatalog } from "@ccr/core/agents/zcode/model-catalog"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; export type ZcodeProfileConfigWriteResult = { backupFile?: string; @@ -53,7 +54,7 @@ export function writeZcodeGatewayConfig( const file = resolveZcodeConfigFile(profile); const model = normalizeClientModel(profile.model) || defaultClientModel(config); const providerId = sanitizeZcodeProviderId(profile.providerId || "") || "claude-code-router"; - const modelCatalog = buildZcodeModelCatalog(config, model); + const modelCatalog = buildZcodeModelCatalog(config, model, { allowedModels: profileAllowedModels({ ...profile, model }) }); const values: ZcodeGatewayConfigValues = { baseUrl: gatewayEndpoint(config), model, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 613521c4..33fcae8b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -3562,10 +3562,10 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { const id = readString(item.id) || `profile-${index + 1}`; const name = readString(item.name) || defaultProfileAgentName(agent); const model = readString(item.model) ?? ""; - const availableModels = uniqueStrings([ - model, - ...parseStringList(item.availableModels ?? item.available_models ?? item.models) - ]); + const parsedAvailableModels = parseStringList(item.availableModels ?? item.available_models ?? item.models); + const availableModels = parsedAvailableModels.length > 0 + ? uniqueStrings([model, ...parsedAvailableModels].filter(Boolean)) + : undefined; const env = parseStringRecord(item.env) ?? {}; const parsedSurface = parseProfileSurface(readString(item.surface) || readString(item.entry) || readString(item.frontend)) || "auto"; const surface = agent === "zcode" || agent === CLAUDE_DESIGN_PLUGIN_ID @@ -3594,6 +3594,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { haikuModel: readString(item.haikuModel) || readString(item.defaultHaikuModel) || readString(item.smallFastModel) || readString(item.smallModel) || "", id, ...(managedCompact !== undefined ? { managedCompact } : {}), + ...(availableModels ? { availableModels } : {}), model, name, opusModel: readString(item.opusModel) || readString(item.defaultOpusModel) || "", @@ -3609,7 +3610,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { if (agent === "grok" || agent === "kimi" || agent === "pi") { return { agent, - ...(agent === "kimi" ? { availableModels } : {}), + ...(availableModels ? { availableModels } : {}), enabled, env: codexCompatibleProfileEnv(env), id, @@ -3657,6 +3658,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { env: codexCompatibleProfileEnv(env), id, ...(managedCompact !== undefined ? { managedCompact } : {}), + ...(availableModels ? { availableModels } : {}), model, name, providerId: readString(item.providerId) || readString(item.provider) || "claude-code-router", diff --git a/packages/core/src/gateway/claude-code-router-plugin.ts b/packages/core/src/gateway/claude-code-router-plugin.ts index c4db5bec..bd884263 100644 --- a/packages/core/src/gateway/claude-code-router-plugin.ts +++ b/packages/core/src/gateway/claude-code-router-plugin.ts @@ -17,6 +17,7 @@ import { buildRouteScriptInput } from "@ccr/core/routing/route-script-context"; import { normalizeRouteScriptResult } from "@ccr/core/routing/route-script-result"; import type { RouteScriptRuntime } from "@ccr/core/routing/route-script-runtime"; import { profileApiKeyId } from "@ccr/core/profiles/api-key"; +import { isModelAllowedForProfile } from "@ccr/core/profiles/model-allowlist"; export { normalizeRouteSelector } from "@ccr/core/routing/model-registry"; @@ -76,7 +77,8 @@ export class ClaudeCodeRouterPlugin { }; applyAgentRequestEnrichers(request, [{ enrich: (matchedRequest) => { - injectClaudeCodeAgentToolDescription(matchedRequest.body, this.config); + const profile = resolveBuiltInAgentProfile(matchedRequest, this.config, "claude-code"); + injectClaudeCodeAgentToolDescription(matchedRequest.body, this.config, profile); injectClaudeCodeToolHubInstructions(matchedRequest.body, this.config); matchedRequest.builtInClaudeCodeSubagent = removeClaudeCodeBillingSystemHeader(matchedRequest.body); matchedRequest.builtInSubagentModel = extractAndRemoveClaudeCodeSubagentModelTag(matchedRequest.body); @@ -842,12 +844,12 @@ function systemContainsInstruction(system: unknown, marker: string): boolean { : isRecord(block) && typeof block.text === "string" && block.text.includes(marker)); } -function injectClaudeCodeAgentToolDescription(body: Record, config: AppConfig): void { +function injectClaudeCodeAgentToolDescription(body: Record, config: AppConfig, profile?: ProfileConfig): void { if (!Array.isArray(body.tools)) { return; } - const instructions = claudeCodeAgentToolInstructions(config); + const instructions = claudeCodeAgentToolInstructions(config, profile); if (!instructions) { return; } @@ -914,8 +916,8 @@ function appendDescriptionInstruction(description: string | undefined, instructi return existing ? `${existing}\n\n${instruction}` : instruction; } -function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; tool: string; workflow: string } | undefined { - const modelRows = configuredSubagentModelDescriptionRows(config); +function claudeCodeAgentToolInstructions(config: AppConfig, profile?: ProfileConfig): { prompt: string; tool: string; workflow: string } | undefined { + const modelRows = configuredSubagentModelDescriptionRows(config, profile); if (modelRows.length === 0) { return undefined; } @@ -942,9 +944,12 @@ function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; t }; } -function configuredSubagentModelDescriptionRows(config: AppConfig): string[] { +function configuredSubagentModelDescriptionRows(config: AppConfig, profile?: ProfileConfig): string[] { const routeByTarget = new Map(); for (const route of buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions)) { + if (!isModelAllowedForProfile(config, profile, route.targetModel)) { + continue; + } const key = route.targetModel.toLowerCase(); const current = routeByTarget.get(key); if (!current || (current.oneMillionContext && !route.oneMillionContext)) { diff --git a/packages/core/src/gateway/features/model-discovery.ts b/packages/core/src/gateway/features/model-discovery.ts index c8082c7a..c493fb18 100644 --- a/packages/core/src/gateway/features/model-discovery.ts +++ b/packages/core/src/gateway/features/model-discovery.ts @@ -3,7 +3,7 @@ */ import type { IncomingHttpHeaders } from "node:http"; import { isGatewayProviderEnabled } from "@ccr/core/contracts/app"; -import type { ApiKeyConfig, AppConfig, ProviderModelMetadata } from "@ccr/core/contracts/app"; +import type { ApiKeyConfig, AppConfig, ProfileConfig, ProviderModelMetadata } from "@ccr/core/contracts/app"; import { buildClaudeAppGatewayModelRoutes, resolveClaudeAppGatewayRouteModel } from "@ccr/core/agents/claude-app/gateway-routes"; import { modelRegistryForConfig, normalizeRouteSelector } from "@ccr/core/routing/model-registry"; import { findModelCatalogEntry, modelCatalogMaxInputTokens, modelCatalogMaxOutputTokens, readCatalogCapability, type ModelCatalogEntry } from "@ccr/core/gateway/model-catalog"; @@ -16,6 +16,7 @@ import { parseJsonObjectSafe, serializeJsonBodyWithModel } from "@ccr/core/gatew import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; import { contextArchiveConfigForApiKey, contextArchiveMcpEnabled } from "@ccr/core/gateway/context-archive"; import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution"; +import { filterModelIdsForProfile, isModelAllowedForProfile, profileForApiKey } from "@ccr/core/profiles/model-allowlist"; export function shouldServeGatewayModelsResponse(method: string, path: string): boolean { @@ -98,20 +99,22 @@ export function prepareClaudeAppDiscoveredModelRequest( export function createGatewayModelsResponse(config: AppConfig, headers: IncomingHttpHeaders, apiKey?: ApiKeyConfig): Record { const contextArchiveConfig = contextArchiveConfigForApiKey(config, apiKey); const contextArchiveCompact = Boolean(contextArchiveConfig && contextArchiveMcpEnabled(contextArchiveConfig)); + const profile = profileForApiKey(config, apiKey); if (isClaudeAppApiKey(apiKey)) { - return createClaudeAppGatewayModelsResponse(config, { contextArchiveCompact }); + return createClaudeAppGatewayModelsResponse(config, { contextArchiveCompact, profile }); } if (isClaudeCodeUserAgent(headers)) { - return createClaudeAppGatewayModelsResponse(config, { claudeCode: true, contextArchiveCompact }); + return createClaudeAppGatewayModelsResponse(config, { claudeCode: true, contextArchiveCompact, profile }); } - return createOpenAICompatibleGatewayModelsResponse(config); + return createOpenAICompatibleGatewayModelsResponse(config, profile); } -export function createClaudeCliBootstrapResponse(config: AppConfig): Record { - const windows = createClaudeCliAutoCompactWindows(config); +export function createClaudeCliBootstrapResponse(config: AppConfig, apiKey?: ApiKeyConfig): Record { + const profile = profileForApiKey(config, apiKey); + const windows = createClaudeCliAutoCompactWindows(config, profile); return { - additional_model_options: createClaudeCliAdditionalModelOptions(config), + additional_model_options: createClaudeCliAdditionalModelOptions(config, profile), auto_compact_windows: windows, client_data: { rowan_thicket: { ...windows } @@ -134,11 +137,14 @@ type ClaudeCliAdditionalModelOption = { }; -function createClaudeCliAdditionalModelOptions(config: AppConfig): ClaudeCliAdditionalModelOption[] { +function createClaudeCliAdditionalModelOptions(config: AppConfig, profile?: ProfileConfig): ClaudeCliAdditionalModelOption[] { const options: ClaudeCliAdditionalModelOption[] = []; const seen = new Set(); const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); for (const route of routes) { + if (!isModelAllowedForProfile(config, profile, route.targetModel)) { + continue; + } const id = route.oneMillionContext ? claudeCodeOneMillionContextModelId(route.id) : route.id; const normalized = id.trim(); const key = normalized.toLowerCase(); @@ -197,8 +203,8 @@ function formatCompactTokenCount(tokens: number): string { } -function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record { - const data = buildGatewayDiscoverableModelIds(config).map((id) => { +function createOpenAICompatibleGatewayModelsResponse(config: AppConfig, profile?: ProfileConfig): Record { + const data = buildGatewayDiscoverableModelIds(config, profile).map((id) => { const catalogEntry = findModelCatalogEntry(id); return { id, @@ -217,10 +223,13 @@ function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record< } -export function createClaudeCliAutoCompactWindows(config: AppConfig): Record { +export function createClaudeCliAutoCompactWindows(config: AppConfig, profile?: ProfileConfig): Record { const windows: Record = {}; const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); for (const route of routes) { + if (!isModelAllowedForProfile(config, profile, route.targetModel)) { + continue; + } const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel); const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId); const maxInputTokens = claudeGatewayModelContextWindow( @@ -271,9 +280,10 @@ function claudeCliAutoCompactWindow(maxInputTokens: number): number | undefined function createClaudeAppGatewayModelsResponse( config: AppConfig, - options: { claudeCode?: boolean; contextArchiveCompact?: boolean } = {} + options: { claudeCode?: boolean; contextArchiveCompact?: boolean; profile?: ProfileConfig } = {} ): Record { - const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); + const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions) + .filter((route) => isModelAllowedForProfile(config, options.profile, route.targetModel)); const data = routes.map((route) => { const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel); const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId); @@ -309,8 +319,12 @@ function createClaudeAppGatewayModelsResponse( } -function createClaudeCodeModelsResponse(config: AppConfig, contextArchiveCompact = false): Record { - const models = buildClaudeCodeDiscoverableModels(config); +function createClaudeCodeModelsResponse( + config: AppConfig, + contextArchiveCompact = false, + profile?: ProfileConfig +): Record { + const models = buildClaudeCodeDiscoverableModels(config, profile); const data = models.map((model) => { const claudeId = claudeCodeDiscoveryModelId(model.id); const catalogId = stripClaudeCodeOneMillionContextSuffix(model.id); @@ -345,7 +359,11 @@ function createClaudeCodeModelsResponse(config: AppConfig, contextArchiveCompact export function createClaudeCodeModelsResponseForTest(config: AppConfig, apiKey?: ApiKeyConfig): Record { const contextArchiveConfig = contextArchiveConfigForApiKey(config, apiKey); - return createClaudeCodeModelsResponse(config, Boolean(contextArchiveConfig && contextArchiveMcpEnabled(contextArchiveConfig))); + return createClaudeCodeModelsResponse( + config, + Boolean(contextArchiveConfig && contextArchiveMcpEnabled(contextArchiveConfig)), + profileForApiKey(config, apiKey) + ); } @@ -457,12 +475,12 @@ function positiveInteger(value: number | undefined): number | undefined { } -function buildClaudeCodeDiscoverableModelIds(config: AppConfig): string[] { - return buildGatewayDiscoverableModelIds(config); +function buildClaudeCodeDiscoverableModelIds(config: AppConfig, profile?: ProfileConfig): string[] { + return buildGatewayDiscoverableModelIds(config, profile); } -function buildGatewayDiscoverableModelIds(config: AppConfig): string[] { +function buildGatewayDiscoverableModelIds(config: AppConfig, profile?: ProfileConfig): string[] { const baseEntries: Array<{ modelName: string; providerName: string }> = []; for (const provider of config.Providers) { const providerName = provider.name?.trim(); @@ -479,19 +497,19 @@ function buildGatewayDiscoverableModelIds(config: AppConfig): string[] { } const ids = baseEntries.map((entry) => `${entry.providerName}/${entry.modelName}`); - for (const profile of config.virtualModelProfiles ?? []) { - if (!isVisibleVirtualModelProfile(profile)) { + for (const virtualProfile of config.virtualModelProfiles ?? []) { + if (!isVisibleVirtualModelProfile(virtualProfile)) { continue; } for (const entry of baseEntries) { - for (const prefix of profile.match?.prefixes ?? []) { + for (const prefix of virtualProfile.match?.prefixes ?? []) { const normalizedPrefix = prefix.trim(); if (normalizedPrefix) { ids.push(`${entry.providerName}/${normalizedPrefix}${entry.modelName}`); } } - for (const suffix of profile.match?.suffixes ?? []) { + for (const suffix of virtualProfile.match?.suffixes ?? []) { const normalizedSuffix = suffix.trim(); if (normalizedSuffix) { ids.push(`${entry.providerName}/${entry.modelName}${normalizedSuffix}`); @@ -499,7 +517,7 @@ function buildGatewayDiscoverableModelIds(config: AppConfig): string[] { } } - for (const alias of profile.match?.exactAliases ?? []) { + for (const alias of virtualProfile.match?.exactAliases ?? []) { const normalizedAlias = alias.trim(); if (!normalizedAlias) { continue; @@ -508,7 +526,7 @@ function buildGatewayDiscoverableModelIds(config: AppConfig): string[] { } } - return uniqueStrings(ids); + return filterModelIdsForProfile(config, uniqueStrings(ids), profile); } @@ -518,7 +536,7 @@ function gatewayModelOwner(id: string): string { } -function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscoverableModel[] { +function buildClaudeCodeDiscoverableModels(config: AppConfig, profile?: ProfileConfig): ClaudeCodeDiscoverableModel[] { const seen = new Set(); const models: ClaudeCodeDiscoverableModel[] = []; @@ -535,7 +553,7 @@ function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscove models.push({ id: normalized, oneMillionContext }); }; - for (const id of buildClaudeCodeDiscoverableModelIds(config)) { + for (const id of buildClaudeCodeDiscoverableModelIds(config, profile)) { pushModel(id, hasClaudeCodeOneMillionContextSuffix(id)); const baseId = stripClaudeCodeOneMillionContextSuffix(id); if (!hasClaudeCodeOneMillionContextSuffix(id) && gatewayModelSupportsOneMillionContext(config, baseId)) { diff --git a/packages/core/src/gateway/http/request-handler.ts b/packages/core/src/gateway/http/request-handler.ts index 0cb400f6..37010875 100644 --- a/packages/core/src/gateway/http/request-handler.ts +++ b/packages/core/src/gateway/http/request-handler.ts @@ -17,6 +17,8 @@ import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "@ccr/core/g import { authorize, claudeCodeWifTokenPath, handleClaudeCodeWifTokenRequest, reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer"; import { parseJsonObject, readRequestBody, sendJson } from "@ccr/core/gateway/http/io"; import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync"; +import { requestLogRequestedModel } from "@ccr/core/observability/request-log-model"; +import { isModelAllowedForProfile, profileForApiKey } from "@ccr/core/profiles/model-allowlist"; import { applyCors, shouldServeGatewayRequest } from "@ccr/core/gateway/core-runtime/supervisor"; import { billingUsageSyncPath, rawTraceSyncPath } from "@ccr/core/gateway/internal/shared"; import type { BrowserAutomationMcpIntegration } from "@ccr/core/gateway/internal/shared"; @@ -212,13 +214,24 @@ export class GatewayHttpRequestHandler { } if (claudeCliBootstrapRequest) { - sendJson(response, 200, createClaudeCliBootstrapResponse(this.config)); + sendJson(response, 200, createClaudeCliBootstrapResponse(this.config, authorization.apiKey)); return; } if (request.method === "POST" && path === "/v1/messages/count_tokens") { const requestBody = await readRequestBody(request); const body = parseJsonObject(requestBody); + const requestedModel = requestLogRequestedModel(requestBody, path); + const profile = profileForApiKey(this.config, authorization.apiKey); + if (requestedModel && !isModelAllowedForProfile(this.config, profile, requestedModel)) { + sendJson(response, 403, { + error: { + code: "profile_model_not_allowed", + message: `Model "${requestedModel}" is not allowed for this profile.` + } + }); + return; + } if (!reserveApiKeyLimits(authorization.apiKey, request, response, requestBody)) { return; } diff --git a/packages/core/src/gateway/request/pipeline.ts b/packages/core/src/gateway/request/pipeline.ts index 9f3828b4..50b053a8 100644 --- a/packages/core/src/gateway/request/pipeline.ts +++ b/packages/core/src/gateway/request/pipeline.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; -import type { ApiKeyConfig, AppConfig, RequestRouteTraceChange } from "@ccr/core/contracts/app"; +import type { ApiKeyConfig, AppConfig, ProfileConfig, RequestRouteTraceChange, RouterFallbackConfig } from "@ccr/core/contracts/app"; import { createSseErrorDetector, markGatewayRequestLogDropped, @@ -46,6 +46,7 @@ import type { BrowserWebSearchMcpIntegration, BrowserWebSearchProtocolRecord, Up import { cancelResponseBody, destroyResponseStreams, fetchUpstreamWithFallback, mergeFallbackResponseHeaders, rewriteCapabilityResponseHeaders, uniqueStreams, upstreamResponseHeaders } from "@ccr/core/gateway/upstream/executor"; import { requestProtocolForPath, shouldApplyGatewayRouting } from "@ccr/core/routing/protocol-endpoints"; import { createClaudeCodeWebSearchContinuationContext, createHostedWebSearchProtocolContext, hostedWebSearchProtocolResponseStream, hostedWebSearchUnavailableMessage, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, selectClaudeCodeWebSearchContinuationRecords, selectHostedWebSearchProtocolRecords } from "@ccr/core/gateway/features/hosted-web-search/index"; +import { isModelAllowedForProfile, profileForApiKey } from "@ccr/core/profiles/model-allowlist"; export type GatewayRequestPipelineDependencies = { getBrowserWebSearchMcpIntegration: () => BrowserWebSearchMcpIntegration | undefined; @@ -91,6 +92,7 @@ export class GatewayRequestPipeline { sendJson(response, 503, { error: { message: "Gateway service is not configured." } }); return; } + const activeConfig = this.config; const method = request.method ?? "GET"; const requestBody = await readRequestBody(request); @@ -158,6 +160,7 @@ export class GatewayRequestPipeline { let routedModel: string | undefined; let codexApplyPatchBridgeActive = false; let codexMultiAgentBridgeActive = false; + const authenticatedProfile = profileForApiKey(activeConfig, apiKey); const claudeModelRewriteStartedAt = Date.now(); const claudeModelRewrite = prepareClaudeCodeDiscoveredModelRequest(this.config, request.headers, method, path, bodyToForward); if (claudeModelRewrite) { @@ -194,9 +197,7 @@ export class GatewayRequestPipeline { target: routedModel ? { model: routedModel } : undefined }); } - if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) { - return; - } + const modelBeforeRouting = requestLogRequestedModel(bodyToForward ?? requestBody, path); const usageAttributionConfig = coreGatewayUsageAttributionConfig(this.config); const recordUsage = (input: Omit) => { void recordGatewayUsageCapture({ ...input, config: usageAttributionConfig }); @@ -288,6 +289,9 @@ export class GatewayRequestPipeline { const shouldCaptureUsage = shouldCaptureGatewayUsage(method, path); if (shouldServeGatewayModelsResponse(method, path)) { + if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) { + return; + } const responseText = `${JSON.stringify(createGatewayModelsResponse(this.config, request.headers, apiKey))}\n`; const modelHeaders = new Headers({ "cache-control": "no-store, max-age=0", @@ -355,6 +359,22 @@ export class GatewayRequestPipeline { }); } + routeFallback = profileAllowedRouteFallback(activeConfig, authenticatedProfile, routeFallback); + const effectiveModel = routedModel ?? requestLogRequestedModel(bodyToForward ?? requestBody, path); + const deniedModel = profileDeniedModel(activeConfig, authenticatedProfile, modelBeforeRouting, effectiveModel); + if (deniedModel) { + sendJson(response, 403, { + error: { + code: "profile_model_not_allowed", + message: `Model "${deniedModel}" is not allowed for this profile.` + } + }); + return; + } + if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) { + return; + } + const codexBridgeStartedAt = Date.now(); const codexApplyPatchBridgeRequest = prepareCodexApplyPatchBridgeRequest({ body: bodyToForward, @@ -937,3 +957,34 @@ export class GatewayRequestPipeline { }; } } + +function profileDeniedModel( + config: AppConfig, + profile: ProfileConfig | undefined, + modelBeforeRouting: string | undefined, + effectiveModel: string | undefined +): string | undefined { + const modelToAuthorize = effectiveModel ?? modelBeforeRouting; + return modelToAuthorize && !isModelAllowedForProfile(config, profile, modelToAuthorize) + ? modelToAuthorize + : undefined; +} + +function profileAllowedRouteFallback( + config: AppConfig, + profile: ProfileConfig | undefined, + fallback: RouterFallbackConfig +): RouterFallbackConfig { + if (fallback.mode !== "model-chain") { + return fallback; + } + const models = fallback.models.filter((model) => + isModelAllowedForProfile(config, profile, model) + ); + return models.length === fallback.models.length + ? fallback + : { ...fallback, models }; +} + +export const profileDeniedModelForTest = profileDeniedModel; +export const profileAllowedRouteFallbackForTest = profileAllowedRouteFallback; diff --git a/packages/core/src/profiles/model-allowlist.ts b/packages/core/src/profiles/model-allowlist.ts new file mode 100644 index 00000000..ef0fa537 --- /dev/null +++ b/packages/core/src/profiles/model-allowlist.ts @@ -0,0 +1,190 @@ +import type { ApiKeyConfig, AppConfig, ProfileConfig } from "@ccr/core/contracts/app"; +import { resolveClaudeAppGatewayRouteModel } from "@ccr/core/agents/claude-app/gateway-routes"; +import { profileApiKeyId } from "@ccr/core/profiles/api-key"; +import { modelRegistryForConfig, normalizeRouteSelector } from "@ccr/core/routing/model-registry"; + +export type ProfileModelAllowlistConfig = Pick; +export type ModelAllowlistResolutionConfig = Pick & Partial>; + +export function profileForApiKey( + config: Partial>, + apiKey: ApiKeyConfig | undefined +): ProfileConfig | undefined { + if (!config.profile || config.profile.enabled === false) { + return undefined; + } + const apiKeyId = apiKey?.id?.trim(); + if (!apiKeyId) { + return undefined; + } + return config.profile.profiles.find((profile) => + profile.enabled && profileApiKeyId(profile) === apiKeyId + ); +} + +export function profileAllowedModels(profile: ProfileConfig | undefined): string[] | undefined { + const explicit = uniqueModels((profile?.availableModels ?? []).map(normalizeAllowlistModel)); + if (!profile || explicit.length === 0) { + return undefined; + } + return uniqueModels([ + normalizeAllowlistModel(profile.model), + ...explicit + ]); +} + +export function filterModelIdsForProfile( + config: ModelAllowlistResolutionConfig, + ids: string[], + profile: ProfileConfig | undefined +): string[] { + return filterModelIdsByAllowedModels(config, ids, profileAllowedModels(profile)); +} + +export function filterModelIdsByAllowedModels( + config: ModelAllowlistResolutionConfig | undefined, + ids: string[], + allowedModels: string[] | undefined +): string[] { + const allowed = modelAllowlistKeys(config, allowedModels); + if (!allowed) { + return uniqueModels(ids); + } + return uniqueModels(ids).filter((id) => + modelKeys(config, id).some((key) => allowed.has(key)) + ); +} + +export function isModelAllowedForProfile( + config: ModelAllowlistResolutionConfig, + profile: ProfileConfig | undefined, + model: string | undefined +): boolean { + const allowed = modelAllowlistKeys(config, profileAllowedModels(profile)); + if (!allowed) { + return true; + } + const modelKeysToCheck = modelKeys(config, model); + return modelKeysToCheck.length === 0 || modelKeysToCheck.some((key) => allowed.has(key)); +} + +function modelAllowlistKeys( + config: ModelAllowlistResolutionConfig | undefined, + allowedModels: string[] | undefined +): Set | undefined { + const normalized = uniqueModels((allowedModels ?? []).map(normalizeAllowlistModel)); + if (normalized.length === 0) { + return undefined; + } + const keys = new Set(); + for (const model of normalized) { + for (const key of modelKeys(config, model)) { + keys.add(key); + } + } + return keys; +} + +function modelKeys( + config: ModelAllowlistResolutionConfig | undefined, + model: string | undefined +): string[] { + const normalized = normalizeAllowlistModel(model); + if (!normalized) { + return []; + } + const registry = config ? modelRegistryForConfig(config) : undefined; + const rawModelIsConfigured = Boolean(registry?.resolve(normalized)); + const claudeAppTarget = resolveClaudeAppRouteTarget(config, normalized); + const seeds = uniqueModels([ + ...modelKeySeeds(normalized, { includeDiscoveryAliases: !rawModelIsConfigured }), + ...(claudeAppTarget ? [claudeAppTarget] : []) + ]); + const keys = new Set(); + for (const seed of seeds) { + addKey(keys, seed); + } + if (!config || !registry) { + return [...keys]; + } + + for (const seed of seeds) { + const resolved = registry.resolve(seed); + if (resolved) { + addKey(keys, resolved.selector); + addKey(keys, resolved.canonicalSelector); + if (resolved.kind === "provider") { + addKey(keys, `${resolved.provider.name}/${resolved.model}`); + } else { + addKey(keys, resolved.model); + } + } + } + return [...keys]; +} + +function resolveClaudeAppRouteTarget( + config: ModelAllowlistResolutionConfig | undefined, + model: string +): string | undefined { + if (!config?.profile) { + return undefined; + } + return resolveClaudeAppGatewayRouteModel(model, { + Providers: config.Providers, + profile: config.profile, + virtualModelProfiles: config.virtualModelProfiles + }); +} + +function modelKeySeeds( + normalized: string, + options: { includeDiscoveryAliases: boolean } +): string[] { + if (!options.includeDiscoveryAliases) { + return [normalized]; + } + const withoutOneMillionContext = stripOneMillionContextSuffix(normalized); + const withoutClaudePrefix = stripClaudeDiscoveryPrefix(normalized); + const withoutClaudePrefixAndContext = stripOneMillionContextSuffix(withoutClaudePrefix); + return uniqueModels([ + normalized, + withoutOneMillionContext, + withoutClaudePrefix, + withoutClaudePrefixAndContext + ]); +} + +function normalizeAllowlistModel(model: string | undefined): string { + return normalizeRouteSelector(model) ?? ""; +} + +function stripClaudeDiscoveryPrefix(model: string): string { + return model.toLowerCase().startsWith("claude-") ? model.slice("claude-".length).trim() : model; +} + +function stripOneMillionContextSuffix(model: string): string { + return model.replace(/\[1m\]$/i, "").trim(); +} + +function addKey(keys: Set, value: string | undefined): void { + const normalized = normalizeAllowlistModel(value); + if (normalized) { + keys.add(normalized.toLowerCase()); + } +} + +function uniqueModels(models: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const model of models) { + const normalized = normalizeAllowlistModel(model); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) { + continue; + } + seen.add(key); + result.push(normalized); + } + return result; +} diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index 86d34a30..5b00a3a9 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -38,6 +38,7 @@ import { } from "@ccr/core/agents/pi/profile-config"; import { CONFIGDIR } from "@ccr/core/config/constants"; import { pruneInactiveProfileApiKeysFromList, syncProfileApiKeys } from "@ccr/core/profiles/api-key"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; import { resolveZcodeConfigFile, writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config"; import { CONTEXT_ARCHIVE_MCP_SERVER_NAME, contextArchiveConfigForProfile, contextArchiveMcpServer } from "@ccr/core/gateway/context-archive"; import { createClaudeCliAutoCompactWindows } from "@ccr/core/gateway/features/model-discovery"; @@ -477,8 +478,12 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str const source = existsSync(configFile) ? readFileSync(configFile, "utf8") : ""; const configFormat = normalizeCodexConfigFormat(profile.configFormat); const modelCatalogFile = codexModelCatalogFile(configFile); - const modelCatalogResult = writeFileWithBackup(modelCatalogFile, codexModelCatalogJson(config, model)); - const appModelCatalogResult = writeCodexCompatibleAppModelCatalog(CONFIGDIR, { ...profile, model }, config); + const profileWithResolvedModel = { ...profile, model }; + const modelCatalogResult = writeFileWithBackup( + modelCatalogFile, + codexModelCatalogJson(config, model, { allowedModels: profileAllowedModels(profileWithResolvedModel) }) + ); + const appModelCatalogResult = writeCodexCompatibleAppModelCatalog(CONFIGDIR, profileWithResolvedModel, config); const showAllSessions = profile.agent === "zcode" ? false : Boolean(profile.showAllSessions); const toolHubMcpResult = writeCodexToolHubMcpRuntimeConfig(config, token); const contextArchiveMcp = profile.agent === "codex" diff --git a/packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs b/packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs new file mode 100644 index 00000000..41c72194 --- /dev/null +++ b/packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs @@ -0,0 +1,289 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import test from "node:test"; +import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; +import { GatewayHttpRequestHandler } from "@ccr/core/gateway/http/request-handler.ts"; +import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline.ts"; +import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin.ts"; +import { profileApiKeyId } from "@ccr/core/profiles/api-key.ts"; +import { waitForTcpListener } from "../../support/loopback-listener.mjs"; + +test("gateway HTTP model discovery and request access honor profile model allowlists", async (t) => { + const config = createProfileAllowlistHttpTestConfig(); + const plugin = new ClaudeCodeRouterPlugin(config); + const status = { + coreEndpoint: "http://127.0.0.1:1", + endpoint: "http://127.0.0.1:0", + state: "running" + }; + const pipeline = new GatewayRequestPipeline({ + getBrowserWebSearchMcpIntegration: () => undefined, + getConfig: () => config, + getCoreAuthToken: () => "test-core-token", + getPlugin: () => plugin, + getStatus: () => status + }); + const handler = new GatewayHttpRequestHandler({ + getBrowserAutomationMcpIntegration: () => undefined, + getConfig: () => config, + getPlugin: () => plugin, + getStatus: () => status, + handleBillingUsageSync: unsupportedHandler, + handleRawTraceSync: unsupportedHandler, + proxyRequest: (request, response, path, apiKey) => pipeline.proxyRequest(request, response, path, apiKey), + replayContextArchive: async () => ({ ok: false, statusCode: 404, message: "not configured" }) + }); + const server = createServer((request, response) => { + void handler.handleRequest(request, response).catch((error) => { + if (!response.headersSent) { + response.writeHead(502, { "content-type": "application/json" }); + } + response.end(JSON.stringify({ error: { message: String(error?.message ?? error) } })); + }); + }); + + try { + try { + await listen(server); + } catch (error) { + if (isLocalListenUnavailable(error)) { + t.skip(`Local HTTP listen is unavailable: ${formatError(error)}`); + return; + } + throw error; + } + await waitForTcpListener(server); + const endpoint = `http://127.0.0.1:${serverPort(server)}`; + status.endpoint = endpoint; + + const alphaModels = await fetchJson(`${endpoint}/v1/models`, { + headers: authHeaders("profile-alpha-key") + }); + assert.deepEqual(alphaModels.data.map((model) => model.id), ["Provider/alpha"]); + + const betaModels = await fetchJson(`${endpoint}/v1/models`, { + headers: authHeaders("profile-beta-key") + }); + assert.deepEqual(betaModels.data.map((model) => model.id), ["Provider/beta"]); + + const gatewayModels = await fetchJson(`${endpoint}/v1/models`, { + headers: authHeaders("gateway-key") + }); + assert.deepEqual(gatewayModels.data.map((model) => model.id), ["Provider/alpha", "Provider/beta", "Provider/gamma"]); + + const claudeModels = await fetchJson(`${endpoint}/v1/models`, { + headers: { + ...authHeaders("profile-alpha-key"), + "user-agent": "claude-cli/1.0" + } + }); + assert.deepEqual(claudeModels.data.map((model) => model.display_name), ["Provider/alpha"]); + + const claudeAppModels = await fetchJson(`${endpoint}/v1/models`, { + headers: authHeaders("profile-alpha-claude-app-key") + }); + const alphaClaudeAppRouteId = claudeAppModels.data[0]?.id; + assert.equal(claudeAppModels.data.length, 1); + assert.equal(typeof alphaClaudeAppRouteId, "string"); + assert.notEqual(alphaClaudeAppRouteId, "Provider/alpha"); + + const bootstrap = await fetchJson(`${endpoint}/api/claude_cli/bootstrap`, { + headers: authHeaders("profile-alpha-key") + }); + assert.deepEqual(bootstrap.additional_model_options.map((model) => model.display_name), ["Provider/alpha"]); + assert.deepEqual(Object.values(bootstrap.auto_compact_windows), []); + + const denied = await fetch(`${endpoint}/v1/messages`, { + body: JSON.stringify({ + max_tokens: 8, + messages: [{ role: "user", content: "hello" }], + model: "Provider/beta" + }), + headers: { + ...authHeaders("profile-alpha-key"), + "content-type": "application/json" + }, + method: "POST" + }); + const deniedBody = await denied.json(); + assert.equal(denied.status, 403); + assert.equal(deniedBody.error.code, "profile_model_not_allowed"); + + const limitedDenied = await fetch(`${endpoint}/v1/messages`, { + body: JSON.stringify({ + max_tokens: 8, + messages: [{ role: "user", content: "hello" }], + model: "Provider/beta" + }), + headers: { + ...authHeaders("profile-alpha-limited-key"), + "content-type": "application/json" + }, + method: "POST" + }); + const limitedDeniedBody = await limitedDenied.json(); + assert.equal(limitedDenied.status, 403); + assert.equal(limitedDeniedBody.error.code, "profile_model_not_allowed"); + + const deniedCountTokens = await fetch(`${endpoint}/v1/messages/count_tokens`, { + body: JSON.stringify({ + messages: [{ role: "user", content: "hello" }], + model: "Provider/beta" + }), + headers: { + ...authHeaders("profile-alpha-key"), + "content-type": "application/json" + }, + method: "POST" + }); + const deniedCountTokensBody = await deniedCountTokens.json(); + assert.equal(deniedCountTokens.status, 403); + assert.equal(deniedCountTokensBody.error.code, "profile_model_not_allowed"); + + const allowedClaudeAppCountTokens = await fetchJson(`${endpoint}/v1/messages/count_tokens`, { + body: JSON.stringify({ + messages: [{ role: "user", content: "hello" }], + model: alphaClaudeAppRouteId + }), + headers: { + ...authHeaders("profile-alpha-claude-app-limited-key"), + "content-type": "application/json" + }, + method: "POST" + }); + assert.equal(typeof allowedClaudeAppCountTokens.input_tokens, "number"); + } finally { + await closeServer(server); + } +}); + +function createProfileAllowlistHttpTestConfig() { + const alphaProfile = { + agent: "claude-code", + enabled: true, + id: "alpha-profile", + model: "Provider/alpha", + name: "Alpha Profile", + scope: "global", + availableModels: ["Provider/alpha"] + }; + const betaProfile = { + agent: "codex", + enabled: true, + id: "beta-profile", + model: "Provider/beta", + name: "Beta Profile", + scope: "ccr", + availableModels: ["Provider/beta"] + }; + const config = createDefaultAppConfig(); + config.APIKEY = "gateway-key"; + config.APIKEYS = [ + { + createdAt: "2026-01-01T00:00:00.000Z", + id: "gateway", + key: "gateway-key", + name: "Gateway" + }, + { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(alphaProfile), + key: "profile-alpha-key", + name: "Profile: Alpha Profile" + }, + { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(alphaProfile), + key: "profile-alpha-limited-key", + limits: { maxRequests: 1, windowMs: 60_000 }, + name: "Profile: Alpha Profile" + }, + { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(alphaProfile), + key: "profile-alpha-claude-app-key", + name: "Claude App" + }, + { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(alphaProfile), + key: "profile-alpha-claude-app-limited-key", + limits: { maxRequests: 1, windowMs: 60_000 }, + name: "Claude App" + }, + { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(betaProfile), + key: "profile-beta-key", + name: "Profile: Beta Profile" + } + ]; + config.Providers = [{ + api_key: "test-provider-key", + base_url: "http://127.0.0.1:1/v1", + models: ["alpha", "beta", "gamma"], + name: "Provider", + type: "openai_responses" + }]; + config.profile = { + ...config.profile, + enabled: true, + profiles: [alphaProfile, betaProfile] + }; + config.gateway.enabled = true; + return config; +} + +function authHeaders(token) { + return { + authorization: `Bearer ${token}` + }; +} + +async function fetchJson(url, init) { + const response = await fetch(url, init); + const text = await response.text(); + assert.equal(response.status, 200, text); + return JSON.parse(text); +} + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function serverPort(server) { + const address = server.address(); + assert.equal(typeof address, "object"); + assert.ok(address); + return address.port; +} + +function closeServer(server) { + return new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close((error) => { + error ? reject(error) : resolve(); + }); + }); +} + +async function unsupportedHandler() { + throw new Error("not supported in this test"); +} + +function isLocalListenUnavailable(error) { + return error && typeof error === "object" && (error.code === "EPERM" || error.code === "EACCES"); +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/test/unit/agents/codex-model-catalog.test.mjs b/packages/core/test/unit/agents/codex-model-catalog.test.mjs index 94786ca7..63872af7 100644 --- a/packages/core/test/unit/agents/codex-model-catalog.test.mjs +++ b/packages/core/test/unit/agents/codex-model-catalog.test.mjs @@ -22,6 +22,19 @@ test("codex catalog removes duplicate model IDs case-insensitively without reord assert.deepEqual(ids, ["provider/model-a", "Provider/MODEL-B"]); }); +test("codex catalog filters model IDs with a profile allowlist", () => { + const ids = buildCodexModelCatalogIds({ + Providers: [ + { name: "Provider", type: "openai_responses", models: ["alpha", "beta"] }, + { name: "Other", type: "openai_responses", models: ["gamma"] } + ] + }, "Provider/alpha", { + allowedModels: ["Provider/alpha"] + }); + + assert.deepEqual(ids, ["Provider/alpha"]); +}); + test("codex catalog treats unknown models as text-only while enabling apply_patch", () => { const model = catalogModelFor({ Providers: [ diff --git a/packages/core/test/unit/profiles/profile-model-allowlist.test.mjs b/packages/core/test/unit/profiles/profile-model-allowlist.test.mjs new file mode 100644 index 00000000..da606e84 --- /dev/null +++ b/packages/core/test/unit/profiles/profile-model-allowlist.test.mjs @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildClaudeAppGatewayModelRoutes } from "@ccr/core/agents/claude-app/gateway-routes.ts"; +import { createClaudeCliBootstrapResponse, createGatewayModelsResponse } from "@ccr/core/gateway/features/model-discovery.ts"; +import { + filterModelIdsForProfile, + isModelAllowedForProfile, + profileAllowedModels, + profileForApiKey +} from "@ccr/core/profiles/model-allowlist.ts"; +import { + profileAllowedRouteFallbackForTest, + profileDeniedModelForTest +} from "@ccr/core/gateway/request/pipeline.ts"; +import { profileApiKeyId } from "@ccr/core/profiles/api-key.ts"; + +function testConfig(profileOverrides = {}) { + const profile = { + agent: "codex", + enabled: true, + id: "work", + model: "Provider/alpha", + name: "Work", + scope: "ccr", + ...profileOverrides + }; + return { + Providers: [ + { models: ["alpha", "beta"], name: "Provider", type: "openai_responses" }, + { models: ["gamma"], name: "Other", type: "openai_responses" } + ], + profile: { + enabled: true, + profiles: [profile] + }, + Router: { + fallback: { mode: "off", models: [], retryCount: 0 }, + rules: [] + }, + virtualModelProfiles: [] + }; +} + +test("profile model allowlist resolves profile API keys and filters model IDs", () => { + const config = testConfig({ availableModels: ["Provider/alpha"] }); + const profile = config.profile.profiles[0]; + const apiKey = { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(profile), + key: "profile-key", + name: "Profile: Work" + }; + + assert.equal(profileForApiKey(config, apiKey), profile); + assert.deepEqual(profileAllowedModels(profile), ["Provider/alpha"]); + assert.deepEqual( + filterModelIdsForProfile(config, ["Provider/alpha", "Provider/beta", "Other/gamma"], profile), + ["Provider/alpha"] + ); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/alpha"), true); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/beta"), false); +}); + +test("profile model allowlist keeps existing all-model behavior when unset", () => { + const config = testConfig(); + const profile = config.profile.profiles[0]; + + assert.equal(profileAllowedModels(profile), undefined); + assert.deepEqual( + filterModelIdsForProfile(config, ["Provider/alpha", "Provider/beta", "Other/gamma"], profile), + ["Provider/alpha", "Provider/beta", "Other/gamma"] + ); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/beta"), true); +}); + +test("profile model allowlist does not add implicit defaults to explicit lists", () => { + const config = testConfig({ availableModels: ["Provider/beta"], model: "" }); + const profile = config.profile.profiles[0]; + + assert.deepEqual(profileAllowedModels(profile), ["Provider/beta"]); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/alpha"), false); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/beta"), true); +}); + +test("profile model allowlist does not treat configured claude-prefixed models as discovery aliases", () => { + const config = testConfig({ + availableModels: ["Provider/sonnet"], + model: "Provider/sonnet" + }); + config.Providers = [{ + models: ["sonnet", "claude-sonnet"], + name: "Provider", + type: "openai_responses" + }]; + const profile = config.profile.profiles[0]; + + assert.equal(isModelAllowedForProfile(config, profile, "claude-sonnet"), false); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/claude-sonnet"), false); + assert.equal(isModelAllowedForProfile(config, profile, "claude-Provider/sonnet"), true); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/sonnet[1m]"), true); +}); + +test("profile model allowlist resolves Claude App encoded route IDs", () => { + const config = testConfig({ availableModels: ["Provider/alpha"] }); + const profile = config.profile.profiles[0]; + const apiKey = { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(profile), + key: "profile-key", + name: "Claude App" + }; + const response = createGatewayModelsResponse(config, {}, apiKey); + const routeId = response.data[0]?.id; + const betaRouteId = buildClaudeAppGatewayModelRoutes(config).find((route) => + route.targetModel === "Provider/beta" + )?.id; + + assert.ok(routeId); + assert.notEqual(routeId, "Provider/alpha"); + assert.ok(betaRouteId); + assert.equal(isModelAllowedForProfile(config, profile, routeId), true); + assert.equal(isModelAllowedForProfile(config, profile, betaRouteId), false); +}); + +test("profile model access is decided by the final routed model", () => { + const config = testConfig({ availableModels: ["Provider/alpha"] }); + const profile = config.profile.profiles[0]; + + assert.equal( + profileDeniedModelForTest(config, profile, "Provider/alpha", "Provider/beta"), + "Provider/beta" + ); + assert.equal( + profileDeniedModelForTest(config, profile, "Provider/beta", "Provider/alpha"), + undefined + ); +}); + +test("profile model allowlist filters model-chain fallback attempts", () => { + const config = testConfig({ availableModels: ["Provider/alpha"] }); + const profile = config.profile.profiles[0]; + + assert.deepEqual( + profileAllowedRouteFallbackForTest(config, profile, { + mode: "model-chain", + models: ["Provider/beta", "Provider/alpha", "Other/gamma"], + retryCount: 0 + }), + { + mode: "model-chain", + models: ["Provider/alpha"], + retryCount: 0 + } + ); +}); + +test("gateway model discovery uses the authenticated profile allowlist", () => { + const config = testConfig({ availableModels: ["Provider/alpha"] }); + const profile = config.profile.profiles[0]; + const apiKey = { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(profile), + key: "profile-key", + name: "Profile: Work" + }; + + const response = createGatewayModelsResponse(config, {}, apiKey); + assert.deepEqual(response.data.map((model) => model.id), ["Provider/alpha"]); + + const unrestricted = createGatewayModelsResponse(config, {}, { + createdAt: "2026-01-01T00:00:00.000Z", + id: "general", + key: "general-key", + name: "General" + }); + assert.deepEqual(unrestricted.data.map((model) => model.id), ["Provider/alpha", "Provider/beta", "Other/gamma"]); +}); + +test("Claude CLI bootstrap options are filtered by profile allowlist", () => { + const config = testConfig({ + agent: "claude-code", + availableModels: ["Provider/alpha"], + scope: "global" + }); + const profile = config.profile.profiles[0]; + const apiKey = { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(profile), + key: "profile-key", + name: "Profile: Work" + }; + + const response = createClaudeCliBootstrapResponse(config, apiKey); + assert.equal(response.additional_model_options.length, 1); +}); diff --git a/packages/ui/src/pages/home/components/model-selector.tsx b/packages/ui/src/pages/home/components/model-selector.tsx index 8573defc..f77743de 100644 --- a/packages/ui/src/pages/home/components/model-selector.tsx +++ b/packages/ui/src/pages/home/components/model-selector.tsx @@ -315,11 +315,13 @@ export function ModelSelector({ export function ModelMultiSelector({ onChange, providers, + requiredValues = [], value, virtualModelProfiles = [] }: { onChange: (value: string[]) => void; providers: GatewayProviderConfig[]; + requiredValues?: string[]; value: string[]; virtualModelProfiles?: VirtualModelProfileConfig[]; }) { @@ -340,16 +342,30 @@ export function ModelMultiSelector({ model.provider.toLowerCase().includes(normalizedQuery) || model.value.toLowerCase().includes(normalizedQuery) || model.displayName.toLowerCase().includes(normalizedQuery)); - const selected = new Set(value.map(normalizeProviderModelSelector).filter(Boolean)); + const required = new Set(requiredValues.map(modelSelectionKey).filter(Boolean)); + const selected = new Set([...value, ...requiredValues].map(modelSelectionKey).filter(Boolean)); + const hasOptionalSelection = Array.from(selected).some((key) => !required.has(key)); function toggleModel(model: string) { - onChange(selected.has(model) - ? value.filter((candidate) => normalizeProviderModelSelector(candidate) !== model) + const key = modelSelectionKey(model); + if (required.has(key) && selected.has(key)) { + return; + } + onChange(selected.has(key) + ? value.filter((candidate) => modelSelectionKey(candidate) !== key) : [...value, model]); } function selectVisibleModels() { - onChange(Array.from(new Set([...value.map(normalizeProviderModelSelector).filter(Boolean), ...models.map((model) => model.value)]))); + onChange(Array.from(new Set([ + ...requiredValues.map(normalizeProviderModelSelector).filter(Boolean), + ...value.map(normalizeProviderModelSelector).filter(Boolean), + ...models.map((model) => model.value) + ]))); + } + + function clearOptionalModels() { + onChange(requiredValues.map(normalizeProviderModelSelector).filter(Boolean)); } return ( @@ -368,7 +384,7 @@ export function ModelMultiSelector({ - @@ -380,17 +396,20 @@ export function ModelMultiSelector({ ) : null}
{models.map((model) => { - const checked = selected.has(model.value); + const key = modelSelectionKey(model.value); + const checked = selected.has(key); + const locked = checked && required.has(key); return ( ); @@ -400,3 +419,7 @@ export function ModelMultiSelector({
); } + +function modelSelectionKey(value: string): string { + return normalizeProviderModelSelector(value).toLowerCase(); +} diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 36eb03f5..707198f2 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -7,7 +7,7 @@ import { normalizeProfileScope, normalizeProfileSurface, Pencil, Plus, PopoverContent, profileAgentLabel, profileAgentOptions, ProfileConfig, type ProfileAgentOption, profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions, Play, Power, RefreshCw, Select, SelectControl, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, useLayoutEffect, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactDragEvent, type ReactNode, type VirtualModelProfileConfig, - copyTextToClipboard, formatRouterRuleCondition, formatRouterRuleTarget, isRoutingRuleDraftSubmittable, routerRuleTypeLabel, routingRuleFromDraft, type RouterRule, validateProfileEnvRows, + copyTextToClipboard, formatRouterRuleCondition, formatRouterRuleTarget, isRoutingRuleDraftSubmittable, normalizeProviderModelSelector, routerRuleTypeLabel, routingRuleFromDraft, type RouterRule, uniqueStrings, validateProfileEnvRows, useCallback, useEffect, useMemo, useRef, useState, X } from "../shared/index"; import { PopoverPortal } from "@/components/ui/popover"; @@ -710,6 +710,15 @@ export function AddProfileForm({ [providers, virtualModelProfiles] ); const availableModelCount = modelProviderOptions.reduce((count, provider) => count + provider.models.length, 0); + const allAllowedModelValues = useMemo( + () => profileAllowedModelValues(modelProviderOptions), + [modelProviderOptions] + ); + const allowedModelSelectorValue = draft.availableModels.length > 0 + ? draft.availableModels + : draft.model.trim() + ? allAllowedModelValues + : []; const modelPlaceholder = firstProfileModelPlaceholder(modelProviderOptions); const validation = profileDraftValidation(draft, botConfigs, availableModelCount); const isClaudeDesignProfile = draft.agent === "claude-design"; @@ -717,15 +726,14 @@ export function AddProfileForm({ const optionalFieldLabel = t("Optional"); const requiredFieldLabel = t("Required"); const advancedIssueCount = [ - validation.providerId, - validation.providerName, + validation.allowedModels, validation.bot, validation.handoff, validation.env ].filter(Boolean).length; const advancedSummary = advancedIssueCount > 0 ? t("Advanced settings need attention") - : t("Paths, routing, bot, compact, and env"); + : t("Models, paths, routing, bot, compact, and env"); const handleAppPathDrop = useCallback((event: ReactDragEvent) => { if (!showAppPathField) { return; @@ -916,27 +924,10 @@ export function AddProfileForm({ providers={providers} value={draft.model} virtualModelProfiles={virtualModelProfiles} - onChange={(model) => onChange({ - availableModels: model && !draft.availableModels.includes(model) - ? [model, ...draft.availableModels] - : draft.availableModels, - model - })} + onChange={(model) => onChange({ model })} /> {validation.kimiModel ? {t(validation.kimiModel)} : null} - - onChange({ - availableModels, - model: availableModels.includes(draft.model) ? draft.model : availableModels[0] ?? "" - })} - /> - {validation.kimiAvailableModels ? {t(validation.kimiAvailableModels)} : null} - ) : draft.agent === "claude-design" ? null : ( <> @@ -990,23 +981,27 @@ export function AddProfileForm({ onChange={onChange} providers={providers} /> - {draft.agent !== "claude-code" && draft.agent !== "grok" && draft.agent !== "kimi" && draft.agent !== "pi" ? ( - <> - - onChange({ providerId: event.target.value })} /> - {validation.providerId ? {t(validation.providerId)} : null} - - - onChange({ providerName: event.target.value })} /> - {validation.providerName ? {t(validation.providerName)} : null} - - {draft.agent !== "zcode" && draft.agent !== "opencode" && draft.agent !== "kilo" ? ( -
- {t("Show all sessions")} - onChange({ showAllSessions })} /> -
- ) : null} - + + { + const nextAvailableModels = normalizeAllowedModelSelection(availableModels, allAllowedModelValues); + onChange({ + availableModels: nextAvailableModels, + model: nextAvailableModels.length === 0 || nextAvailableModels.includes(draft.model) ? draft.model : nextAvailableModels[0] ?? "" + }); + }} + /> + {validation.allowedModels ? {t(validation.allowedModels)} : null} + + {draft.agent === "codex" ? ( +
+ {t("Show all sessions")} + onChange({ showAllSessions })} /> +
) : null} {draft.agent === "claude-code" || draft.agent === "codex" ? (
@@ -1267,12 +1262,26 @@ function firstProfileModelPlaceholder(providers: ReturnType): string[] { + return uniqueStrings(providers.flatMap((provider) => + provider.models.map((model) => normalizeProviderModelSelector(`${provider.name}/${model}`)) + ).filter(Boolean)); +} + +function normalizeAllowedModelSelection(value: string[], allValues: string[]): string[] { + const selected = uniqueStrings(value.map(normalizeProviderModelSelector).filter(Boolean)); + const allSelected = allValues.length > 0 && + selected.length === allValues.length && + selected.every((model) => allValues.includes(model)); + return allSelected ? [] : selected; +} + function profileDraftValidation( draft: AddProfileDraft, botConfigs: BotGatewaySavedConfig[], availableModelCount: number -): Partial> { - const issues: Partial> = {}; +): Partial> { + const issues: Partial> = {}; if (!draft.name.trim()) { issues.name = "Profile name is required."; } @@ -1282,21 +1291,13 @@ function profileDraftValidation( if (draft.agent === "claude-code" && !draft.model.trim()) { issues.defaultModel = "Default model is required."; } + if (draft.availableModels.length > 0 && !draft.model.trim()) { + issues.allowedModels = "Default model is required when allowed model list is set."; + } if (draft.agent === "kimi") { if (!draft.model.trim()) { issues.kimiModel = "Kimi model is required."; } - if (draft.availableModels.length === 0) { - issues.kimiAvailableModels = "Select at least one allowed model."; - } - } - if (draft.agent !== "claude-code" && draft.agent !== "grok" && draft.agent !== "kimi" && draft.agent !== "pi" && draft.agent !== "claude-design") { - if (!draft.providerId.trim()) { - issues.providerId = "Provider ID is required."; - } - if (!draft.providerName.trim()) { - issues.providerName = "Provider name is required."; - } } if (draft.surface !== "cli" && draft.botEnabled && !botConfigs.some((config) => config.id === draft.botConfigId.trim())) { issues.bot = "Select an existing bot or turn Bot off."; diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 9992c377..20bf56d4 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -302,6 +302,7 @@ export const appCopy: Record = { "Generated output is limited to 1 token for connectivity checks.": "Generated output is limited to 1 token for connectivity checks.", "Gateway service": "Gateway service", "Grok CLI profiles use CCR scope and CLI entry mode.": "Grok CLI profiles use CCR scope and CLI entry mode.", + "Allowed model list": "Allowed model list", "Allowed models": "Allowed models", "Import local agent login": "Import local agent login", "Import local agent provider": "Import local agent provider", @@ -387,7 +388,6 @@ export const appCopy: Record = { "Optional. Check Connection sends a real model request and may consume provider credits.": "Optional. Check Connection sends a real model request and may consume provider credits.", "Select at least one protocol.": "Select at least one protocol.", "Select at least one available model.": "Select at least one available model.", - "Select at least one allowed model.": "Select at least one allowed model.", "Select default model": "Select default model", "Select models": "Select models", "Search added models": "Search added models", @@ -429,12 +429,14 @@ export const appCopy: Record = { "Clear filters": "Clear filters", "Clear filters or broaden the search to find more request logs.": "Clear filters or broaden the search to find more request logs.", "Default model is required.": "Default model is required.", + "Default model is required when allowed model list is set.": "Default model is required when allowed model list is set.", "Enable at least one provider model before saving this profile.": "Enable at least one provider model before saving this profile.", "Enable request logs": "Enable request logs", "Environment variable rows need valid keys.": "Environment variable rows need valid keys.", "Idle seconds must be between 30 and 86400.": "Idle seconds must be between 30 and 86400.", "No request logs match the current filters.": "No request logs match the current filters.", "No request logs yet.": "No request logs yet.", + "Models, paths, routing, bot, compact, and env": "Models, paths, routing, bot, compact, and env", "Paths, routing, bot, compact, and env": "Paths, routing, bot, compact, and env", "Paths, bot, compact, and env": "Paths, bot, compact, and env", "Enhanced route": "Enhanced route", @@ -932,6 +934,7 @@ export const appCopy: Record = { "Kimi profiles need a default model and an allowed model list.": "Kimi 配置档案需要一个默认模型和可用模型列表。", "Kimi model": "Kimi 模型", "Kimi model is required.": "Kimi 模型不能为空。", + "Allowed model list": "允许模型列表", "Allowed models": "允许模型", "Pi": "Pi", "Pi model": "Pi 模型", @@ -1192,6 +1195,7 @@ export const appCopy: Record = { "Model": "模型", "Default model": "默认模型", "Default model is required.": "默认模型不能为空。", + "Default model is required when allowed model list is set.": "设置允许模型列表时默认模型不能为空。", "Model descriptions": "模型描述", "Model override": "默认模型", "Model routing": "模型路由", @@ -1367,6 +1371,7 @@ export const appCopy: Record = { "Retry": "继续重试", "Retry attempts": "重试尝试", "Weighted order": "按权重排序", + "Models, paths, routing, bot, compact, and env": "模型、路径、路由、Bot、压缩和环境变量", "Paths, routing, bot, compact, and env": "路径、路由、Bot、压缩和环境变量", "Paths, bot, compact, and env": "路径、Bot、压缩和环境变量", "Ready to route": "可以开始路由", @@ -1448,7 +1453,6 @@ export const appCopy: Record = { "Select account": "选择账户", "Select an existing bot or turn Bot off.": "请选择已有 Bot,或关闭 Bot。", "Select at least one available model.": "请至少选择一个可用模型。", - "Select at least one allowed model.": "请至少选择一个允许模型。", "Select default model": "选择默认模型", "Select bot": "选择 Bot", "Select data": "选择数据", diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index 6a67894b..664aefd4 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -515,6 +515,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs botConfigId, botEnabled: surface !== "cli" && Boolean(selectedBot || profile.botGateway?.enabled), envRows: keyValueRowsFromRecord(claudeCodeProfileEnv(profile.env ?? {})), + availableModels: profileDraftAvailableModels(profile), fableModel: profile.fableModel ?? "", haikuModel: profile.haikuModel ?? profile.smallFastModel ?? "", managedCompact: Boolean(profile.managedCompact), @@ -531,9 +532,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs return { ...createProfileDraft(profile.agent, profile.name), ...createProfileRoutingDraft(profile.routing), - availableModels: profile.agent === "kimi" - ? uniqueStrings([profile.model, ...(profile.availableModels ?? [])].map(normalizeProfileClientModel).filter(Boolean)) - : [], + availableModels: profileDraftAvailableModels(profile), envRows: keyValueRowsFromRecord(codexCompatibleProfileEnv(profile.env ?? {})), model: profile.model, scope: "ccr", @@ -556,6 +555,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs ...createProfileRoutingDraft(profile.routing), ...botDraft, appPath: profile.appPath ?? "", + availableModels: profileDraftAvailableModels(profile), botConfigId, botEnabled: surface !== "cli" && Boolean(selectedBot || profile.botGateway?.enabled), configFile: profile.configFile ?? defaultCodexConfigFile(profile.agent), @@ -584,6 +584,9 @@ export function isProfileDraftSubmittable(draft: AddProfileDraft): boolean { if (botAllowed && draft.botEnabled && draft.botHandoffEnabled && !isNumberDraftValid(draft.botHandoffIdleSeconds, 30, 86_400)) { return false; } + if (draft.availableModels.length > 0 && !draft.model.trim()) { + return false; + } if (draft.agent === "claude-code") { return Boolean(draft.model.trim()); } @@ -597,12 +600,9 @@ export function isProfileDraftSubmittable(draft: AddProfileDraft): boolean { return true; } if (draft.agent === "kimi") { - return Boolean(draft.model.trim()) && draft.availableModels.length > 0; + return Boolean(draft.model.trim()); } - return ( - Boolean(draft.providerId.trim()) && - Boolean(draft.providerName.trim()) - ); + return true; } function matchingBotConfigId(botGateway: BotGatewayRuntimeConfig | undefined, botConfigs: BotGatewaySavedConfig[]): string { @@ -642,7 +642,7 @@ export function profileConfigFromDraft( return normalizeProfileItem({ agent: draft.agent, appPath: draft.appPath, - availableModels: draft.agent === "kimi" ? draft.availableModels : undefined, + availableModels: profileConfigAvailableModelsFromDraft(draft), ...botGateway, configFile: draft.configFile, enabled: existingProfile?.enabled ?? true, @@ -658,8 +658,8 @@ export function profileConfigFromDraft( model: draft.model, name: draft.name, opusModel: draft.opusModel, - providerId: draft.providerId, - providerName: draft.providerName, + providerId: draft.providerId.trim() || "claude-code-router", + providerName: draft.providerName.trim() || "Claude Code Router", ...(routing ? { routing } : {}), scope: draft.scope, settingsFile: draft.settingsFile, @@ -670,6 +670,19 @@ export function profileConfigFromDraft( }, existingProfiles.length); } +function profileDraftAvailableModels(profile: ProfileConfig): string[] { + return profile.availableModels?.length + ? uniqueStrings([profile.model, ...profile.availableModels].map(normalizeProfileClientModel).filter(Boolean)) + : []; +} + +function profileConfigAvailableModelsFromDraft(draft: AddProfileDraft): string[] | undefined { + const availableModels = uniqueStrings(draft.availableModels.map(normalizeProfileClientModel).filter(Boolean)); + return availableModels.length > 0 + ? uniqueStrings([draft.model, ...availableModels].map(normalizeProfileClientModel).filter(Boolean)) + : undefined; +} + function botGatewayHandoffFromProfileDraft( draft: AddProfileDraft, fallback: BotGatewayRuntimeConfig["handoff"] = fallbackConfig.botGateway.handoff @@ -1221,6 +1234,12 @@ export function profileSummaryItems( : profile.agent === "claude-code" ? t("Keep Claude Code default") : defaultProfileClientModel(config); + const allowedModelSummaryItems = profile.availableModels?.length + ? [{ + label: t("Allowed model list"), + value: String(uniqueStrings([profile.model, ...profile.availableModels].filter(Boolean)).length) + }] + : []; if (profile.agent === "claude-code") { const aliasItems = [ @@ -1236,6 +1255,7 @@ export function profileSummaryItems( })); return [ { label: t("Model"), value: modelValue }, + ...allowedModelSummaryItems, ...aliasItems, ...managedCompactItems, ...routingSummaryItems, @@ -1248,12 +1268,7 @@ export function profileSummaryItems( if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi") { return [ { label: t(profile.agent === "kimi" ? "Kimi model" : profile.agent === "pi" ? "Pi model" : "Model"), value: modelValue }, - ...(profile.agent === "kimi" - ? [{ - label: t("Allowed models"), - value: String(uniqueStrings([profile.model, ...(profile.availableModels ?? [])].filter(Boolean)).length) - }] - : []), + ...allowedModelSummaryItems, ...routingSummaryItems, ...envSummaryItems ]; @@ -1268,7 +1283,7 @@ export function profileSummaryItems( return [ { label: t(profile.agent === "kilo" ? "Kilo model" : "Model"), value: modelValue }, - { label: t("Provider ID"), value: profile.providerId ?? "claude-code-router" }, + ...allowedModelSummaryItems, ...(profile.agent === "zcode" || profile.agent === "opencode" || profile.agent === "kilo" || !profile.showAllSessions ? [] : [{ label: t("Show all sessions"), value: t("Enabled") }]), ...managedCompactItems, ...routingSummaryItems, @@ -1282,10 +1297,10 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro const agent = normalizeProfileAgent(profile.agent); const name = profile.name.trim() || profileAgentLabel(profile.agent); const model = profile.model.trim(); - const availableModels = uniqueStrings([ - model, - ...(profile.availableModels ?? []).map(normalizeProfileClientModel) - ].filter(Boolean)); + const explicitAvailableModels = uniqueStrings((profile.availableModels ?? []).map(normalizeProfileClientModel).filter(Boolean)); + const availableModels = explicitAvailableModels.length > 0 + ? uniqueStrings([model, ...explicitAvailableModels].filter(Boolean)) + : undefined; const scope = normalizeProfileScope(profile.scope); const surface = normalizeProfileSurfaceForAgent(agent, profile.surface); const env = isPlainRecord(profile.env) ? stringRecordValue(profile.env) : {}; @@ -1299,6 +1314,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro ...(surface !== "cli" && appPath ? { appPath } : {}), ...(botConfigId ? { botConfigId } : {}), ...(botGateway ? { botGateway } : {}), + ...(availableModels ? { availableModels } : {}), enabled: profile.enabled, env: claudeCodeProfileEnv(env), fableModel: stringValue(profile.fableModel) || "", @@ -1319,7 +1335,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro if (agent === "grok" || agent === "kimi" || agent === "pi") { return { agent, - ...(agent === "kimi" ? { availableModels } : {}), + ...(availableModels ? { availableModels } : {}), enabled: profile.enabled, env: codexCompatibleProfileEnv(env), id: profile.id || `profile-${index + 1}`, @@ -1348,6 +1364,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro ...(surface !== "cli" && agent !== "zcode" && profile.appPath?.trim() ? { appPath: profile.appPath.trim() } : {}), ...(botConfigId ? { botConfigId } : {}), ...(botGateway ? { botGateway } : {}), + ...(availableModels ? { availableModels } : {}), cliMiddleware: true, codexCliPath: "", codexHome: "", diff --git a/packages/ui/test/component/onboarding.test.tsx b/packages/ui/test/component/onboarding.test.tsx index dd5b4654..2dc8803c 100644 --- a/packages/ui/test/component/onboarding.test.tsx +++ b/packages/ui/test/component/onboarding.test.tsx @@ -104,5 +104,5 @@ test("Onboarding profile step does not prompt to save before continuing", () => assert.doesNotMatch(html, /Save this agent profile to continue/); assert.doesNotMatch(html, /Choose an agent, model, and required profile settings/); assert.doesNotMatch(html, /provider identity/); - assert.match(html, /Paths, routing, bot, compact, and env/); + assert.match(html, /Models, paths, routing, bot, compact, and env/); }); diff --git a/packages/ui/test/component/profiles.test.tsx b/packages/ui/test/component/profiles.test.tsx index 0c9c7f35..8167d297 100644 --- a/packages/ui/test/component/profiles.test.tsx +++ b/packages/ui/test/component/profiles.test.tsx @@ -98,13 +98,17 @@ test("AddProfileForm shows enhanced route as a sibling of profile routing", () = const advancedSettingsIndex = html.indexOf("Advanced settings"); const enhancedRouteIndex = html.indexOf("Enhanced route"); const profileRoutingIndex = html.indexOf("Profile routing"); + const allowedModelListIndex = html.indexOf("Allowed model list"); assert.ok(advancedSettingsIndex >= 0); assert.ok(enhancedRouteIndex > advancedSettingsIndex); assert.ok(profileRoutingIndex > advancedSettingsIndex); + assert.ok(allowedModelListIndex > advancedSettingsIndex); + assert.ok(allowedModelListIndex > profileRoutingIndex); assert.ok(enhancedRouteIndex < profileRoutingIndex); assert.doesNotMatch(html, /Routing disabled/); assert.match(html, /Enhanced route/); + assert.match(html, /Allowed model list/); assert.match(html, /rounded-b-none/); assert.match(html, /rounded-b-md border border-t-0/); assert.doesNotMatch(html, /Profile routes/); @@ -136,10 +140,11 @@ test("AddProfileForm shows private profile routes when profile routing is enable test("AddProfileForm uses Codex-specific enhanced route info for Codex profiles", () => { const config = appConfigFixture(); + const draft = { ...createProfileDraft("codex"), providerId: "", providerName: "", routingEnabled: true }; const html = renderToStaticMarkup( undefined} @@ -151,6 +156,107 @@ test("AddProfileForm uses Codex-specific enhanced route info for Codex profiles" assert.match(html, /Enhanced route/); assert.match(html, /CCR built-in Codex routing optimizes requests to third-party models for this profile\./); + assert.doesNotMatch(html, /Provider ID/); + assert.doesNotMatch(html, /Provider name/); + assert.equal(isProfileDraftSubmittable(draft), true); + assert.equal(profileConfigFromDraft(draft, []).providerId, "claude-code-router"); + assert.equal(profileConfigFromDraft(draft, []).providerName, "Claude Code Router"); +}); + +test("explicit allowed model lists require a default model", () => { + const config = appConfigFixture(); + config.Providers = [{ + models: ["alpha", "beta"], + name: "Provider" + }]; + const draft = { + ...createProfileDraft("codex"), + availableModels: ["Provider/alpha"] + }; + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + + assert.equal(isProfileDraftSubmittable(draft), false); + assert.match(html, /Advanced settings need attention/); + assert.match(html, /Default model is required when allowed model list is set\./); +}); + +test("AddProfileForm does not render unrestricted allowed models as selected without a default model", () => { + const config = appConfigFixture(); + config.Providers = [{ + models: ["alpha", "beta"], + name: "Provider" + }]; + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + const alphaIndex = html.indexOf('title="Provider/alpha"'); + const betaIndex = html.indexOf('title="Provider/beta"'); + const alphaLabel = html.slice(alphaIndex, betaIndex); + const betaLabel = html.slice(betaIndex, html.indexOf("", betaIndex)); + const clearIndex = html.indexOf(">Clear"); + const clearButton = html.slice(html.lastIndexOf("= 0); + assert.ok(betaIndex > alphaIndex); + assert.match(alphaLabel, /aria-checked="false"/); + assert.match(betaLabel, /aria-checked="false"/); + assert.match(clearButton, /disabled=""/); +}); + +test("AddProfileForm keeps the default model locked in allowed model lists", () => { + const config = appConfigFixture(); + config.Providers = [{ + models: ["alpha", "beta"], + name: "Provider" + }]; + const draft = { + ...createProfileDraft("codex"), + availableModels: ["Provider/alpha"], + model: "Provider/alpha" + }; + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + const defaultIndex = html.indexOf('title="Provider/alpha"'); + const otherIndex = html.indexOf('title="Provider/beta"'); + const defaultLabel = html.slice(defaultIndex, otherIndex); + const otherLabel = html.slice(otherIndex, html.indexOf("", otherIndex)); + + assert.ok(defaultIndex >= 0); + assert.ok(otherIndex > defaultIndex); + assert.match(defaultLabel, /aria-checked="true"/); + assert.match(defaultLabel, /disabled=""/); + assert.match(otherLabel, /aria-checked="false"/); + assert.doesNotMatch(otherLabel, /disabled=""/); }); test("AddProfileForm places CLAUDE_APP_PATH below Bot settings", () => { @@ -199,6 +305,7 @@ test("AddProfileForm marks required and optional fields", () => { assert.match(html, /Opus model/); assert.match(html, /Sonnet model/); assert.match(html, /Haiku model/); + assert.doesNotMatch(html, /Allowed model list/); }); test("Claude Code profiles require a default model before submission", () => { @@ -210,10 +317,11 @@ test("Claude Code profiles require a default model before submission", () => { test("AddProfileForm labels Kimi CLI model fields with Kimi-specific copy", () => { const config = appConfigFixture(); + const draft = createProfileDraft("kimi"); const html = renderToStaticMarkup( undefined} onCreateBot={() => undefined} @@ -223,9 +331,40 @@ test("AddProfileForm labels Kimi CLI model fields with Kimi-specific copy", () = ); assert.match(html, /Kimi model/); - assert.match(html, /Allowed models/); + assert.doesNotMatch(html, /Advanced settings need attention/); + assert.doesNotMatch(html, /Allowed model list/); assert.doesNotMatch(html, /Default model/); assert.doesNotMatch(html, /Available models/); + assert.equal(isProfileDraftSubmittable(draft), false); + const defaultOnlyDraft = { ...draft, model: "kimi/k2", availableModels: [] }; + assert.equal(isProfileDraftSubmittable(defaultOnlyDraft), true); + assert.equal(profileConfigFromDraft(defaultOnlyDraft, []).availableModels, undefined); +}); + +test("AddProfileForm shows Kimi allowed models as selected by default", () => { + const config = appConfigFixture(); + config.Providers = [{ + models: ["k2", "k3"], + name: "Kimi" + }]; + const draft = { ...createProfileDraft("kimi"), model: "Kimi/k2" }; + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + + assert.match(html, /Allowed model list/); + assert.match(html, /title="Kimi\/k2"[\s\S]*?aria-checked="true"/); + assert.match(html, /title="Kimi\/k3"[\s\S]*?aria-checked="true"/); + assert.equal(profileConfigFromDraft(draft, []).availableModels, undefined); }); test("AddProfileForm treats Pi as a CCR-only CLI profile", () => { @@ -247,7 +386,7 @@ test("AddProfileForm treats Pi as a CCR-only CLI profile", () => { assert.match(html, /Pi model/); assert.doesNotMatch(html, /Provider ID/); assert.doesNotMatch(html, /Provider name/); - assert.doesNotMatch(html, /Allowed models/); + assert.doesNotMatch(html, /Allowed model list/); }); test("AddProfileForm treats Claude Design as a CCR-only App profile", () => { @@ -346,7 +485,7 @@ test("profileSummaryItems uses Kimi-specific model labels", () => { }, config, (value) => value); assert.equal(items[0]?.label, "Kimi model"); - assert.equal(items[1]?.label, "Allowed models"); + assert.equal(items[1]?.label, "Allowed model list"); assert.equal(items[1]?.value, "2"); }); @@ -381,7 +520,7 @@ test("profileSummaryItems omits disabled profile properties from cards", () => { surface: "auto" }, config, (value) => value); - assert.deepEqual(disabledItems.map((item) => item.label), ["Model", "Provider ID"]); + assert.deepEqual(disabledItems.map((item) => item.label), ["Model"]); assert.doesNotMatch(disabledItems.map((item) => item.value).join(" "), /Disabled/); const enabledItems = profileSummaryItems({ @@ -399,6 +538,7 @@ test("profileSummaryItems omits disabled profile properties from cards", () => { assert.match(enabledItems.map((item) => item.label).join(" "), /Show all sessions/); assert.match(enabledItems.map((item) => item.label).join(" "), /CCR managed compact/); + assert.doesNotMatch(enabledItems.map((item) => item.label).join(" "), /Provider ID/); }); test("profileSummaryItems shows disabled profile enhanced route without private routing status", () => {