mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-11-01 13:42:41 +08:00
Enforce profile model allowlists across agent catalogs
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.";
|
||||
|
||||
@@ -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": "选择数据",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user