feat(api-keys): strict-mode controls for Claude Code default models (#3776)

Adds per-API-key strict-mode controls for the Claude Code default model surface.

- blocked_models deny-list on API keys (deny-list takes precedence over allow-list), so operators can keep a broad dynamic scope like cc/* while excluding expensive families (opus/sonnet/haiku/fable).
- Claude Code default model (cc/*) UI in the API manager: a collapsible families chip group to block individual families through the default model.
- Permission matching expanded with claude-code candidates (cc/, claude/, short aliases, [1m] suffix) so allow and deny match the same candidate set — a blocked family cannot be bypassed via an alias. Setting-dependent claude routing bypasses the permission cache to avoid stale results.
- Model denials on the Anthropic /v1/messages path return a Claude-shaped error (invalid_request_error) via sanitizeErrorMessage instead of the generic 403.

Synced from the v3.8.23 fork point to release/v3.8.24; restored reasoningTokenBufferEnabled (out-of-scope deletion) and re-baselined file-size for the feature growth. Fast Quality Gates + semgrep green; PR unit tests 74/74; typecheck:core + eslint clean.

Integrated into release/v3.8.24.
This commit is contained in:
Witroch4
2026-06-13 14:41:16 -03:00
committed by GitHub
parent 7219f2b38d
commit d4f253bbcd
10 changed files with 684 additions and 121 deletions

View File

@@ -9,12 +9,21 @@
*/
import { extractApiKey } from "@/sse/services/auth";
import { getApiKeyMetadata, getComboByName, isModelAllowedForKey, getApiKeyById } from "@/lib/localDb";
import {
getApiKeyMetadata,
getComboByName,
isModelAllowedForKey,
getApiKeyById,
} from "@/lib/localDb";
import { isDashboardSessionAuthenticated } from "./apiAuth";
import { resolveComboForModel } from "@/lib/db/modelComboMappings";
import { checkBudget } from "@/domain/costRules";
import { checkTokenLimits } from "@omniroute/open-sse/services/tokenLimitCounter.ts";
import { errorResponse, buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
import {
errorResponse,
buildErrorBody,
sanitizeErrorMessage,
} from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
import { checkRateLimit, RateLimitRule } from "./rateLimiter";
@@ -173,6 +182,45 @@ function matchesComboAccessRule(comboName: string, requestedModel: string, rule:
);
}
function isAnthropicMessagesRequest(request: Request): boolean {
if (request.headers.has("anthropic-version")) return true;
try {
const url = new URL(request.url);
return url.pathname.endsWith("/v1/messages");
} catch {
return false;
}
}
function policyErrorResponse(
request: Request,
statusCode: number,
message: string,
anthropicMessage = message,
anthropicErrorType = "permission_error",
anthropicStatusCode = statusCode
): Response {
if (!isAnthropicMessagesRequest(request)) {
return errorResponse(statusCode, message);
}
const safeMessage = sanitizeErrorMessage(anthropicMessage);
return new Response(
JSON.stringify({
type: "error",
error: {
type: anthropicErrorType,
message: safeMessage,
},
}),
{
status: anthropicStatusCode,
headers: { "Content-Type": "application/json" },
}
);
}
async function resolveRequestedComboName(modelStr: string): Promise<string | null> {
const exact = await getComboByName(modelStr);
if (exact && typeof exact.name === "string") return exact.name;
@@ -435,7 +483,12 @@ export async function enforceApiKeyPolicy(
let requestedComboName: string | null = null;
const isQuotaExclusive =
Boolean(apiKeyInfo.allowedQuotas) && (apiKeyInfo.allowedQuotas as string[]).length > 0;
if (!isQuotaExclusive && modelStr && apiKeyInfo.allowedCombos && apiKeyInfo.allowedCombos.length > 0) {
if (
!isQuotaExclusive &&
modelStr &&
apiKeyInfo.allowedCombos &&
apiKeyInfo.allowedCombos.length > 0
) {
try {
const comboAccess = await isComboAllowedForKey(apiKeyInfo.allowedCombos, modelStr);
requestedComboName = comboAccess.comboName;
@@ -487,9 +540,13 @@ export async function enforceApiKeyPolicy(
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(
rejection: policyErrorResponse(
request,
HTTP_STATUS.FORBIDDEN,
`Model "${modelStr}" is not allowed for this API key`
`Model "${modelStr}" is not allowed for this API key`,
`Model "${modelStr}" is not enabled or quota is insufficient. Choose another allowed model.`,
"invalid_request_error",
HTTP_STATUS.BAD_REQUEST
),
};
}
@@ -526,9 +583,7 @@ export async function enforceApiKeyPolicy(
const breach = checkTokenLimits(apiKeyInfo.id, undefined, modelStr ?? undefined);
if (breach) {
const scopeLabel =
breach.scopeType === "global"
? "account"
: `${breach.scopeType} "${breach.scopeValue}"`;
breach.scopeType === "global" ? "account" : `${breach.scopeType} "${breach.scopeValue}"`;
return {
apiKey,
apiKeyInfo,
@@ -545,10 +600,7 @@ export async function enforceApiKeyPolicy(
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(
HTTP_STATUS.SERVICE_UNAVAILABLE,
"Token limit policy unavailable"
),
rejection: errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Token limit policy unavailable"),
};
}
}

View File

@@ -363,7 +363,10 @@ export const bulkWebSessionImportSchema = z.object({
.array(
z.object({
name: z.string().min(1).max(200),
credential: z.string().min(1).max(64 * 1024, "Credential must be under 64 KB"),
credential: z
.string()
.min(1)
.max(64 * 1024, "Credential must be under 64 KB"),
})
)
.min(1, "entries must contain at least 1 item")
@@ -912,24 +915,23 @@ export const v1CountTokensSchema = z
})
.catchall(z.unknown());
export const setBudgetSchema = z
.object({
apiKeyId: z.string().trim().min(1, "apiKeyId is required"),
// #3537: a limit of 0 means "no limit for this period" (checkBudget only enforces when
// activeLimitUsd > 0). The dashboard sends 0 for unfilled fields, so 0 must be accepted —
// `.positive()` (rejects 0) used to 400 any save that left a field blank. Negatives are
// still rejected by `.min(0)`.
dailyLimitUsd: z.coerce.number().min(0, "dailyLimitUsd must be zero or greater").optional(),
weeklyLimitUsd: z.coerce.number().min(0, "weeklyLimitUsd must be zero or greater").optional(),
monthlyLimitUsd: z.coerce.number().min(0, "monthlyLimitUsd must be zero or greater").optional(),
warningThreshold: z.coerce.number().min(0).max(1).optional(),
resetInterval: z.enum(["daily", "weekly", "monthly"]).optional(),
resetTime: z
.string()
.trim()
.regex(/^\d{2}:\d{2}$/, "resetTime must be in HH:MM format")
.optional(),
});
export const setBudgetSchema = z.object({
apiKeyId: z.string().trim().min(1, "apiKeyId is required"),
// #3537: a limit of 0 means "no limit for this period" (checkBudget only enforces when
// activeLimitUsd > 0). The dashboard sends 0 for unfilled fields, so 0 must be accepted —
// `.positive()` (rejects 0) used to 400 any save that left a field blank. Negatives are
// still rejected by `.min(0)`.
dailyLimitUsd: z.coerce.number().min(0, "dailyLimitUsd must be zero or greater").optional(),
weeklyLimitUsd: z.coerce.number().min(0, "weeklyLimitUsd must be zero or greater").optional(),
monthlyLimitUsd: z.coerce.number().min(0, "monthlyLimitUsd must be zero or greater").optional(),
warningThreshold: z.coerce.number().min(0).max(1).optional(),
resetInterval: z.enum(["daily", "weekly", "monthly"]).optional(),
resetTime: z
.string()
.trim()
.regex(/^\d{2}:\d{2}$/, "resetTime must be in HH:MM format")
.optional(),
});
// #3537: the previous superRefine required at least one limit > 0, which made it impossible to
// clear all limits (save 0/0/0). Setting all limits to 0 is a valid "disable enforcement"
// operation, so no cross-field minimum is imposed.
@@ -1908,6 +1910,7 @@ export const updateKeyPermissionsSchema = z
.object({
name: z.string().trim().min(1).max(200).optional(),
allowedModels: z.array(z.string().trim().min(1)).max(1000).optional(),
blockedModels: z.array(z.string().trim().min(1)).max(1000).optional(),
allowedCombos: z.array(z.string().trim().min(1).max(200)).max(500).optional(),
allowedConnections: z.array(z.string().uuid()).max(100).optional(),
noLog: z.boolean().optional(),
@@ -1937,6 +1940,7 @@ export const updateKeyPermissionsSchema = z
if (
value.name === undefined &&
value.allowedModels === undefined &&
value.blockedModels === undefined &&
value.allowedCombos === undefined &&
value.allowedConnections === undefined &&
value.noLog === undefined &&