Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/guide/architecture/request-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。
Expand Down Expand Up @@ -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`。

## 阶段六:上游调用前的拒绝与资源释放
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/usage/client-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 9 additions & 11 deletions src/app/api/proxy/v1/[...path]/proxy-request-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
getFallbackRouteCapability,
getProviderByRouteCapability,
isCliRouteCapability,
resolveRouteCapabilities,
upstreamSupportsRouteCapability,
type RouteCapability,
type RouteMatchSource,
} from "@/lib/route-capabilities";
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions src/lib/route-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
6 changes: 3 additions & 3 deletions src/lib/services/downstream-model-catalog.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
);
}

Expand All @@ -56,7 +56,7 @@ function getModelsForUpstream(

const models = new Set<string>();
for (const model of catalogModels) {
if (matchUpstreamModelRules(model, rules).matched) {
if (matchNormalizedUpstreamModelRules(model, rules).matched) {
models.add(model);
}
}
Expand Down
46 changes: 29 additions & 17 deletions src/lib/services/upstream-model-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
}
}

Expand All @@ -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.
*/
Expand Down
49 changes: 48 additions & 1 deletion src/lib/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

/**
* Hash an API key using bcrypt.
Expand All @@ -22,8 +37,40 @@ export async function hashApiKey(key: string): Promise<string> {
* @returns True if the key matches the hash
*/
export async function verifyApiKey(key: string, hash: string): Promise<boolean> {
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;
}
Expand Down
12 changes: 11 additions & 1 deletion tests/unit/lib/route-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions tests/unit/services/upstream-model-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
deriveModelRedirectsFromRules,
hasExplicitModelRules,
importCatalogEntriesToModelRules,
matchNormalizedUpstreamModelRules,
matchUpstreamModelRules,
normalizeUpstreamModelRules,
resolveModelWithRedirects,
Expand Down Expand Up @@ -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", [
{
Expand Down
12 changes: 11 additions & 1 deletion tests/unit/utils/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,24 @@ 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";

const isValid = await verifyApiKey(key, "invalid-hash");
expect(isValid).toBe(false);
});
});

describe("extractApiKey", () => {
it("should extract key from Bearer token", () => {
const key = extractApiKey("Bearer sk-auto-mykey123");
Expand Down
Loading