Enforce profile model allowlists across agent catalogs

This commit is contained in:
musistudio
2026-08-10 12:55:54 +08:00
parent 47b23298fb
commit 6e34da7fca
23 changed files with 1168 additions and 164 deletions
+9 -6
View File
@@ -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))
};
@@ -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<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): CodexModelCatalog {
export function buildCodexModelCatalog(
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>,
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<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): string[] {
export function buildCodexModelCatalogIds(
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>,
selectedModel?: string,
options: CodexModelCatalogOptions = {}
): string[] {
const ids: string[] = [];
pushUniqueModel(ids, normalizeModelSelector(selectedModel));
@@ -106,15 +119,30 @@ export function buildCodexModelCatalogIds(config?: Partial<Pick<AppConfig, "Prov
}
}
return ids;
return filterModelIdsByAllowedModels(
config ? {
Providers: config.Providers ?? [],
virtualModelProfiles: config.virtualModelProfiles ?? []
} : undefined,
ids,
options.allowedModels
);
}
export function codexModelCatalogJson(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): string {
return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel), null, 2)}\n`;
export function codexModelCatalogJson(
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>,
selectedModel?: string,
options: CodexModelCatalogOptions = {}
): string {
return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel, options), null, 2)}\n`;
}
export function codexModelCatalogBase64(config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>, selectedModel?: string): string {
const catalog = buildCodexModelCatalog(config, selectedModel);
export function codexModelCatalogBase64(
config?: Partial<Pick<AppConfig, "Providers" | "Router" | "virtualModelProfiles">>,
selectedModel?: string,
options: CodexModelCatalogOptions = {}
): string {
const catalog = buildCodexModelCatalog(config, selectedModel, options);
return Buffer.from(JSON.stringify(catalog), "utf8").toString("base64");
}
@@ -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,
@@ -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<string, unknown> {
const catalog = buildCodexModelCatalog(config, selectedModel);
function openCodeModelConfigs(config: AppConfig, profile: ProfileConfig, selectedModel: string): Record<string, unknown> {
const catalog = buildCodexModelCatalog(config, selectedModel, { allowedModels: profileAllowedModels(profile) });
const resolutionConfig: OpenCodeModelResolutionConfig = {
Providers: config.Providers ?? [],
virtualModelProfiles: config.virtualModelProfiles ?? []
@@ -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<string, unknown> {
};
}
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));
}
@@ -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`;
}
@@ -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,
+7 -5
View File
@@ -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",
@@ -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<string, unknown>, config: AppConfig): void {
function injectClaudeCodeAgentToolDescription(body: Record<string, unknown>, 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<string, ClaudeAppGatewayModelRoute>();
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)) {
@@ -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<string, unknown> {
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<string, unknown> {
const windows = createClaudeCliAutoCompactWindows(config);
export function createClaudeCliBootstrapResponse(config: AppConfig, apiKey?: ApiKeyConfig): Record<string, unknown> {
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<string>();
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<string, unknown> {
const data = buildGatewayDiscoverableModelIds(config).map((id) => {
function createOpenAICompatibleGatewayModelsResponse(config: AppConfig, profile?: ProfileConfig): Record<string, unknown> {
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<string, number> {
export function createClaudeCliAutoCompactWindows(config: AppConfig, profile?: ProfileConfig): Record<string, number> {
const windows: Record<string, number> = {};
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<string, unknown> {
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<string, unknown> {
const models = buildClaudeCodeDiscoverableModels(config);
function createClaudeCodeModelsResponse(
config: AppConfig,
contextArchiveCompact = false,
profile?: ProfileConfig
): Record<string, unknown> {
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<string, unknown> {
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<string>();
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)) {
@@ -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;
}
+55 -4
View File
@@ -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<UsageCaptureInput, "config">) => {
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;
@@ -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<AppConfig, "Providers" | "profile" | "virtualModelProfiles">;
export type ModelAllowlistResolutionConfig = Pick<AppConfig, "Providers" | "virtualModelProfiles"> & Partial<Pick<AppConfig, "profile">>;
export function profileForApiKey(
config: Partial<Pick<AppConfig, "profile">>,
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<string> | undefined {
const normalized = uniqueModels((allowedModels ?? []).map(normalizeAllowlistModel));
if (normalized.length === 0) {
return undefined;
}
const keys = new Set<string>();
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<string>();
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<string>, value: string | undefined): void {
const normalized = normalizeAllowlistModel(value);
if (normalized) {
keys.add(normalized.toLowerCase());
}
}
function uniqueModels(models: string[]): string[] {
const seen = new Set<string>();
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;
}
+7 -2
View File
@@ -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"
@@ -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);
}
@@ -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: [
@@ -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);
});
@@ -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({
<Button disabled={models.length === 0} onClick={selectVisibleModels} size="sm" type="button" variant="outline">
{t("All")}
</Button>
<Button disabled={selected.size === 0} onClick={() => onChange([])} size="sm" type="button" variant="outline">
<Button disabled={!hasOptionalSelection} onClick={clearOptionalModels} size="sm" type="button" variant="outline">
{t("Clear")}
</Button>
</div>
@@ -380,17 +396,20 @@ export function ModelMultiSelector({
) : null}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{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 (
<Label
className={cn(
"flex h-9 min-w-0 cursor-pointer items-center gap-2 rounded-md border border-border bg-background px-2 text-left text-[12px] transition-colors hover:bg-muted",
checked && "border-primary bg-accent"
checked && "border-primary bg-accent",
locked && "cursor-not-allowed opacity-75 hover:bg-accent"
)}
key={model.value}
title={`${model.provider}/${model.displayName}`}
>
<Checkbox checked={checked} onCheckedChange={() => toggleModel(model.value)} />
<Checkbox checked={checked} disabled={locked} onCheckedChange={() => toggleModel(model.value)} />
<span className="min-w-0 flex-1 truncate">{model.provider} / {model.displayName}</span>
</Label>
);
@@ -400,3 +419,7 @@ export function ModelMultiSelector({
</div>
);
}
function modelSelectionKey(value: string): string {
return normalizeProviderModelSelector(value).toLowerCase();
}
@@ -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<HTMLElement>) => {
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 ? <ProfileFieldHint>{t(validation.kimiModel)}</ProfileFieldHint> : null}
</Field>
<Field className="sm:col-span-2" label={t("Allowed models")} requirement="required" requirementLabel={requiredFieldLabel}>
<ModelMultiSelector
providers={providers}
value={draft.availableModels}
virtualModelProfiles={virtualModelProfiles}
onChange={(availableModels) => onChange({
availableModels,
model: availableModels.includes(draft.model) ? draft.model : availableModels[0] ?? ""
})}
/>
{validation.kimiAvailableModels ? <ProfileFieldHint>{t(validation.kimiAvailableModels)}</ProfileFieldHint> : null}
</Field>
</>
) : 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" ? (
<>
<Field label={t("Provider ID")} requirement="required" requirementLabel={requiredFieldLabel}>
<Input value={draft.providerId} onChange={(event) => onChange({ providerId: event.target.value })} />
{validation.providerId ? <ProfileFieldHint>{t(validation.providerId)}</ProfileFieldHint> : null}
</Field>
<Field label={t("Provider name")} requirement="required" requirementLabel={requiredFieldLabel}>
<Input value={draft.providerName} onChange={(event) => onChange({ providerName: event.target.value })} />
{validation.providerName ? <ProfileFieldHint>{t(validation.providerName)}</ProfileFieldHint> : null}
</Field>
{draft.agent !== "zcode" && draft.agent !== "opencode" && draft.agent !== "kilo" ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 px-3 py-2">
<span className="text-[12px] font-medium">{t("Show all sessions")}</span>
<Toggle checked={draft.showAllSessions} onChange={(showAllSessions) => onChange({ showAllSessions })} />
</div>
) : null}
</>
<Field className="sm:col-span-2" label={t("Allowed model list")} requirement="optional" requirementLabel={optionalFieldLabel}>
<ModelMultiSelector
providers={providers}
requiredValues={draft.model.trim() ? [draft.model] : []}
value={allowedModelSelectorValue}
virtualModelProfiles={virtualModelProfiles}
onChange={(availableModels) => {
const nextAvailableModels = normalizeAllowedModelSelection(availableModels, allAllowedModelValues);
onChange({
availableModels: nextAvailableModels,
model: nextAvailableModels.length === 0 || nextAvailableModels.includes(draft.model) ? draft.model : nextAvailableModels[0] ?? ""
});
}}
/>
{validation.allowedModels ? <ProfileFieldHint>{t(validation.allowedModels)}</ProfileFieldHint> : null}
</Field>
{draft.agent === "codex" ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 px-3 py-2">
<span className="text-[12px] font-medium">{t("Show all sessions")}</span>
<Toggle checked={draft.showAllSessions} onChange={(showAllSessions) => onChange({ showAllSessions })} />
</div>
) : null}
{draft.agent === "claude-code" || draft.agent === "codex" ? (
<div className="sm:col-span-2">
@@ -1267,12 +1262,26 @@ function firstProfileModelPlaceholder(providers: ReturnType<typeof profileModelP
return provider && model ? `${provider.name}/${model}` : "";
}
function profileAllowedModelValues(providers: ReturnType<typeof profileModelProviderOptions>): 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<Record<"bot" | "defaultModel" | "env" | "handoff" | "kimiAvailableModels" | "kimiModel" | "models" | "name" | "providerId" | "providerName", string>> {
const issues: Partial<Record<"bot" | "defaultModel" | "env" | "handoff" | "kimiAvailableModels" | "kimiModel" | "models" | "name" | "providerId" | "providerName", string>> = {};
): Partial<Record<"allowedModels" | "bot" | "defaultModel" | "env" | "handoff" | "kimiModel" | "models" | "name", string>> {
const issues: Partial<Record<"allowedModels" | "bot" | "defaultModel" | "env" | "handoff" | "kimiModel" | "models" | "name", string>> = {};
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.";
+6 -2
View File
@@ -302,6 +302,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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": "选择数据",
+40 -23
View File
@@ -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: "",
@@ -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/);
});
+146 -6
View File
@@ -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(
<AddProfileForm
botConfigs={config.botConfigs}
draft={{ ...createProfileDraft("codex"), routingEnabled: true }}
draft={draft}
error=""
mode="edit"
onChange={() => 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(
<AddProfileForm
botConfigs={config.botConfigs}
draft={draft}
error=""
mode="edit"
onChange={() => 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(
<AddProfileForm
botConfigs={config.botConfigs}
draft={createProfileDraft("codex")}
error=""
mode="edit"
onChange={() => 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("</label>", betaIndex));
const clearIndex = html.indexOf(">Clear</button>");
const clearButton = html.slice(html.lastIndexOf("<button", clearIndex), clearIndex);
assert.ok(alphaIndex >= 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(
<AddProfileForm
botConfigs={config.botConfigs}
draft={draft}
error=""
mode="edit"
onChange={() => 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("</label>", 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(
<AddProfileForm
botConfigs={config.botConfigs}
draft={createProfileDraft("kimi")}
draft={draft}
error=""
onChange={() => 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(
<AddProfileForm
botConfigs={config.botConfigs}
draft={draft}
error=""
mode="edit"
onChange={() => 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", () => {