diff --git a/docs/guide/architecture/request-lifecycle.md b/docs/guide/architecture/request-lifecycle.md index fe5c3ae9..58e3c237 100644 --- a/docs/guide/architecture/request-lifecycle.md +++ b/docs/guide/architecture/request-lifecycle.md @@ -47,6 +47,12 @@ export async function POST(request: NextRequest, context: RouteContext) { 这些早期鉴权错误保留简单的顶层 `error` 字符串格式,并通过 `logRejectedRequest` 写入拒绝日志;它们不产生上游请求、计费快照或 traffic fixture。 +### 重复鉴权校验的性能边界 + +`verifyApiKey` 对成功的 bcrypt 比对使用进程内短 TTL 缓存(当前 TTL 为 10 秒、最多保留 2048 条),缓存键由进程随机密钥保护的 HMAC-SHA-256 API Key 摘要与当前 bcrypt hash 组成,不保存 API Key 明文。首次请求或缓存失效时仍执行完整 bcrypt 比对。 + +该缓存不改变撤销和准入语义:代理每次请求仍先从数据库读取 `is_active` 的 Key 记录,并在缓存命中后继续检查过期时间、用户状态、模型权限、上游授权与速率 / 消费规则。停用或删除 Key 后,后续请求不会因为缓存命中而继续通过。缓存是单进程的,多实例之间不共享。 + ## 阶段四:路由能力、模型与 API Key 准入 鉴权通过后,`extractRequestContext` 从请求体和路径提取模型、session ID、stream 标志、reasoning effort 与 service tier。`resolveRouteCapability` 将 method、path 和 client profile 映射为 `RouteCapability`。 @@ -81,6 +87,8 @@ export async function POST(request: NextRequest, context: RouteContext) { 3. `selectFromUpstreamCandidates`:按 tier、权重、健康和 session affinity 选择候选。 4. 转发前再次申请熔断器准入;期间变为 `OPEN` 的候选会被拒绝或触发失败转移。 +路径 capability 候选筛选只执行成员判断,并保留 `codex_responses` 等 legacy capability 映射;不会为每个候选重复构造完整 capability 列表。模型规则在单次候选判断中只归一化一次,已归一化规则由路由与模型目录读取路径直接复用。 + 如果路径不支持 capability、Key 没有授权上游或候选集合为空,请求在发送上游前结束。常见统一错误会包含 `request_id`、`reason`、`did_send_upstream` 和用户可读的 `user_hint`。 ## 阶段六:上游调用前的拒绝与资源释放 diff --git a/docs/guide/usage/client-keys.md b/docs/guide/usage/client-keys.md index 913e9313..b90d8d73 100644 --- a/docs/guide/usage/client-keys.md +++ b/docs/guide/usage/client-keys.md @@ -56,6 +56,8 @@ outline: deep 过期判定(`src/app/api/proxy/v1/[...path]/proxy-request-lifecycle.ts` 的 `executeProxyRequest`)发生在每次代理请求鉴权时:`expiresAt && expiresAt < new Date()` 即返回 401。无需周期任务介入。 +重复请求的成功校验可能命中进程内短 TTL 鉴权缓存,但缓存不会绕过数据库中的 `is_active`、过期时间或用户状态检查;停用、删除或过期后的后续请求仍会被拒绝。 + `spending_rules` 与上游的 `spending_rules` 含义类似,但作用对象是「该 Key 的累计消费」而非「该上游的累计消费」。 ## 每分钟速率限制(RPM / TPM) diff --git a/src/app/api/proxy/v1/[...path]/proxy-request-lifecycle.ts b/src/app/api/proxy/v1/[...path]/proxy-request-lifecycle.ts index 148c46f1..52079cd2 100644 --- a/src/app/api/proxy/v1/[...path]/proxy-request-lifecycle.ts +++ b/src/app/api/proxy/v1/[...path]/proxy-request-lifecycle.ts @@ -29,7 +29,7 @@ import { getFallbackRouteCapability, getProviderByRouteCapability, isCliRouteCapability, - resolveRouteCapabilities, + upstreamSupportsRouteCapability, type RouteCapability, type RouteMatchSource, } from "@/lib/route-capabilities"; @@ -40,7 +40,7 @@ import { } from "@/lib/services/route-capability-matcher"; import { ensureRouteCapabilityMigration } from "@/lib/services/route-capability-migration"; import { - matchUpstreamModelRules, + matchNormalizedUpstreamModelRules, normalizeUpstreamModelRules, } from "@/lib/services/upstream-model-rules"; import { @@ -658,14 +658,12 @@ function resolvePathRoutingModelForUpstream( }; } - const result = matchUpstreamModelRules( - originalModel, - normalizeUpstreamModelRules({ - modelRules: upstream.modelRules, - allowedModels: upstream.allowedModels, - modelRedirects: upstream.modelRedirects, - }) - ); + const normalizedRules = normalizeUpstreamModelRules({ + modelRules: upstream.modelRules, + allowedModels: upstream.allowedModels, + modelRedirects: upstream.modelRedirects, + }); + const result = matchNormalizedUpstreamModelRules(originalModel, normalizedRules); return { matched: result.matched, hasExplicitRules: result.hasExplicitRules, @@ -742,7 +740,7 @@ function resolveRouteCapabilityCandidatePool( candidateCapability: RouteCapability ): RouteCapabilityCandidatePool { const capabilityCandidates = activeUpstreams.filter((upstream) => - resolveRouteCapabilities(upstream.routeCapabilities).includes(candidateCapability) + upstreamSupportsRouteCapability(upstream.routeCapabilities, candidateCapability) ); const authorizedCapabilityCandidates = capabilityCandidates.filter((upstream) => allowedUpstreamIdSet.has(upstream.id) diff --git a/src/lib/route-capabilities.ts b/src/lib/route-capabilities.ts index 34c238db..0a36ab86 100644 --- a/src/lib/route-capabilities.ts +++ b/src/lib/route-capabilities.ts @@ -190,6 +190,33 @@ export function resolveRouteCapabilities( ): RouteCapability[] { return normalizeRouteCapabilities(routeCapabilities); } +/** + * Checks whether an upstream capability list contains a requested capability. + * + * This preserves legacy upstream aliases without allocating the normalized + * capability array needed by callers that only need a membership check. + */ +export function upstreamSupportsRouteCapability( + routeCapabilities: readonly string[] | null | undefined, + requestedCapability: RouteCapability +): boolean { + if (!routeCapabilities || routeCapabilities.length === 0) { + return false; + } + + for (const capability of routeCapabilities) { + const normalized = capability.trim(); + if (normalized === requestedCapability) { + return true; + } + + if (LEGACY_UPSTREAM_CAPABILITY_MAP[normalized]?.includes(requestedCapability)) { + return true; + } + } + + return false; +} export function isCliRouteCapability(capability: RouteCapability): boolean { return capability === "codex_cli_responses" || capability === "claude_code_messages"; diff --git a/src/lib/services/downstream-model-catalog.ts b/src/lib/services/downstream-model-catalog.ts index dad5c5cd..9a9be92d 100644 --- a/src/lib/services/downstream-model-catalog.ts +++ b/src/lib/services/downstream-model-catalog.ts @@ -1,7 +1,7 @@ import type { Upstream } from "@/lib/db"; import { normalizeApiKeyAllowedModels } from "@/lib/api-key-models"; import { - matchUpstreamModelRules, + matchNormalizedUpstreamModelRules, normalizeUpstreamModelRules, } from "@/lib/services/upstream-model-rules"; import type { UpstreamModelRule } from "@/lib/services/upstream-model-types"; @@ -43,7 +43,7 @@ function getModelsForUpstream( ): string[] { if (apiKeyAllowedModels !== null) { return apiKeyAllowedModels.filter( - (model) => rules.length === 0 || matchUpstreamModelRules(model, rules).matched + (model) => rules.length === 0 || matchNormalizedUpstreamModelRules(model, rules).matched ); } @@ -56,7 +56,7 @@ function getModelsForUpstream( const models = new Set(); for (const model of catalogModels) { - if (matchUpstreamModelRules(model, rules).matched) { + if (matchNormalizedUpstreamModelRules(model, rules).matched) { models.add(model); } } diff --git a/src/lib/services/upstream-model-rules.ts b/src/lib/services/upstream-model-rules.ts index 4c06f452..367cc529 100644 --- a/src/lib/services/upstream-model-rules.ts +++ b/src/lib/services/upstream-model-rules.ts @@ -321,16 +321,15 @@ export function resolveModelWithRedirects( } /** - * Matches a requested model against upstream model rules. + * Matches a requested model against already normalized upstream rules. + * + * Callers that normalize a rule set once can use this helper to avoid + * rebuilding the same runtime rule state for every model match. */ -export function matchUpstreamModelRules( +export function matchNormalizedUpstreamModelRules( model: string, - modelRules: UpstreamModelRule[] | null | undefined + normalizedRules: UpstreamModelRule[] | null | undefined ): UpstreamModelRuleMatchResult { - const normalizedRules = normalizeUpstreamModelRules({ - modelRules, - }); - if (!normalizedRules || normalizedRules.length === 0) { return { hasExplicitRules: false, @@ -373,16 +372,14 @@ export function matchUpstreamModelRules( }; } - if (rule.type === "regex") { - if (new RegExp(rule.value).test(model)) { - return { - hasExplicitRules: true, - matched: true, - resolvedModel: model, - redirectApplied: false, - matchedRule: rule, - }; - } + if (rule.type === "regex" && new RegExp(rule.value).test(model)) { + return { + hasExplicitRules: true, + matched: true, + resolvedModel: model, + redirectApplied: false, + matchedRule: rule, + }; } } @@ -395,6 +392,21 @@ export function matchUpstreamModelRules( }; } +/** + * Matches a requested model against upstream model rules. + */ +export function matchUpstreamModelRules( + model: string, + modelRules: UpstreamModelRule[] | null | undefined +): UpstreamModelRuleMatchResult { + return matchNormalizedUpstreamModelRules( + model, + normalizeUpstreamModelRules({ + modelRules, + }) + ); +} + /** * Converts selected catalog entries into exact model rules. */ diff --git a/src/lib/utils/auth.ts b/src/lib/utils/auth.ts index c59fa80a..fd89926d 100644 --- a/src/lib/utils/auth.ts +++ b/src/lib/utils/auth.ts @@ -1,8 +1,23 @@ +import { randomBytes, webcrypto } from "crypto"; import bcryptjs from "bcryptjs"; import { config, validateAdminToken } from "./config"; import { decrypt, EncryptionError } from "./encryption"; const BCRYPT_ROUNDS = 12; +const API_KEY_VERIFY_CACHE_KEY_PROMISE = webcrypto.subtle.importKey( + "raw", + randomBytes(32), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] +); + +// The proxy still loads the active key row before calling verifyApiKey, so this +// cache only removes repeated bcrypt work; revocation, expiry, ownership, and +// authorization changes remain database-authoritative on every request. +const API_KEY_VERIFY_CACHE_TTL_MS = 10_000; +const API_KEY_VERIFY_CACHE_MAX_ENTRIES = 2_048; +const apiKeyVerificationCache = new Map(); /** * Hash an API key using bcrypt. @@ -22,8 +37,40 @@ export async function hashApiKey(key: string): Promise { * @returns True if the key matches the hash */ export async function verifyApiKey(key: string, hash: string): Promise { + const cacheKeyDigest = Buffer.from( + await webcrypto.subtle.sign( + "HMAC", + await API_KEY_VERIFY_CACHE_KEY_PROMISE, + new TextEncoder().encode(key) + ) + ).toString("hex"); + const cacheKey = `${hash}:${cacheKeyDigest}`; + const now = Date.now(); + const cachedUntil = apiKeyVerificationCache.get(cacheKey); + + if (cachedUntil !== undefined) { + if (cachedUntil > now) { + apiKeyVerificationCache.delete(cacheKey); + apiKeyVerificationCache.set(cacheKey, cachedUntil); + return true; + } + apiKeyVerificationCache.delete(cacheKey); + } + try { - return await bcryptjs.compare(key, hash); + const isValid = await bcryptjs.compare(key, hash); + if (!isValid) { + return false; + } + + if (apiKeyVerificationCache.size >= API_KEY_VERIFY_CACHE_MAX_ENTRIES) { + const oldestKey = apiKeyVerificationCache.keys().next().value; + if (typeof oldestKey === "string") { + apiKeyVerificationCache.delete(oldestKey); + } + } + apiKeyVerificationCache.set(cacheKey, now + API_KEY_VERIFY_CACHE_TTL_MS); + return true; } catch { return false; } diff --git a/tests/unit/lib/route-capabilities.test.ts b/tests/unit/lib/route-capabilities.test.ts index 9cbd8fdd..8d4bcecd 100644 --- a/tests/unit/lib/route-capabilities.test.ts +++ b/tests/unit/lib/route-capabilities.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; import { + areSingleProviderCapabilities, getPrimaryProviderByCapabilities, normalizeCompensationRuleCapabilities, normalizeRouteCapabilities, resolveRouteCapabilities, - areSingleProviderCapabilities, + upstreamSupportsRouteCapability, } from "@/lib/route-capabilities"; describe("route-capabilities", () => { @@ -24,6 +25,15 @@ describe("route-capabilities", () => { expect(normalizeRouteCapabilities(["codex_responses"])).toEqual(["openai_responses"]); }); + it("checks canonical and legacy upstream capabilities without changing matching semantics", () => { + expect( + upstreamSupportsRouteCapability([" openai_chat_compatible "], "openai_chat_compatible") + ).toBe(true); + expect(upstreamSupportsRouteCapability(["codex_responses"], "openai_responses")).toBe(true); + expect(upstreamSupportsRouteCapability(["codex_responses"], "openai_extended")).toBe(false); + expect(upstreamSupportsRouteCapability(["invalid"], "openai_extended")).toBe(false); + }); + it("normalizes legacy compensation codex_responses capability to generic and cli responses", () => { expect(normalizeCompensationRuleCapabilities(["codex_responses"])).toEqual([ "openai_responses", diff --git a/tests/unit/services/upstream-model-rules.test.ts b/tests/unit/services/upstream-model-rules.test.ts index 310a52b5..dc46a2af 100644 --- a/tests/unit/services/upstream-model-rules.test.ts +++ b/tests/unit/services/upstream-model-rules.test.ts @@ -4,6 +4,7 @@ import { deriveModelRedirectsFromRules, hasExplicitModelRules, importCatalogEntriesToModelRules, + matchNormalizedUpstreamModelRules, matchUpstreamModelRules, normalizeUpstreamModelRules, resolveModelWithRedirects, @@ -222,6 +223,23 @@ describe("upstream-model-rules", () => { }); }); + it("should match an already normalized rule set identically", () => { + const rawRules = [ + { + type: "alias" as const, + value: "gpt-4.1-preview", + targetModel: "gpt-4.1", + source: "manual" as const, + displayLabel: null, + }, + ]; + const normalizedRules = normalizeUpstreamModelRules({ modelRules: rawRules }); + + expect(matchNormalizedUpstreamModelRules("gpt-4.1-preview", normalizedRules)).toEqual( + matchUpstreamModelRules("gpt-4.1-preview", rawRules) + ); + }); + it("should match exact rules without rewriting the model", () => { const result = matchUpstreamModelRules("gpt-4.1", [ { diff --git a/tests/unit/utils/auth.test.ts b/tests/unit/utils/auth.test.ts index ba9ae7f4..b41816bb 100644 --- a/tests/unit/utils/auth.test.ts +++ b/tests/unit/utils/auth.test.ts @@ -49,6 +49,17 @@ describe("auth utilities", () => { expect(isValid).toBe(false); }); + it("should bind cached verification to both the key and its hash", async () => { + const key = "sk-auto-cache-key123456789012345678901234"; + const otherKey = "sk-auto-cache-other12345678901234567890"; + const hash = await hashApiKey(key); + + expect(await verifyApiKey(key, hash)).toBe(true); + expect(await verifyApiKey(otherKey, hash)).toBe(false); + + const otherHash = await hashApiKey(otherKey); + expect(await verifyApiKey(key, otherHash)).toBe(false); + }); it("should handle invalid hash gracefully", async () => { const key = "sk-auto-testkey123456789012345678901234"; @@ -56,7 +67,6 @@ describe("auth utilities", () => { expect(isValid).toBe(false); }); }); - describe("extractApiKey", () => { it("should extract key from Bearer token", () => { const key = extractApiKey("Bearer sk-auto-mykey123");