mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
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:
@@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user