From 3525ec2104ed88fc150c6e6a09029776e2d6ca40 Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 20 Mar 2026 00:00:22 +0800 Subject: [PATCH 1/6] docs: add api key spending quota proposal --- .../add-api-key-spending-quota/.openspec.yaml | 2 + .../add-api-key-spending-quota/design.md | 241 ++++++++++++++++++ .../add-api-key-spending-quota/proposal.md | 43 ++++ .../specs/api-key-spending-quota/spec.md | 147 +++++++++++ .../add-api-key-spending-quota/tasks.md | 31 +++ 5 files changed, 464 insertions(+) create mode 100644 openspec/changes/add-api-key-spending-quota/.openspec.yaml create mode 100644 openspec/changes/add-api-key-spending-quota/design.md create mode 100644 openspec/changes/add-api-key-spending-quota/proposal.md create mode 100644 openspec/changes/add-api-key-spending-quota/specs/api-key-spending-quota/spec.md create mode 100644 openspec/changes/add-api-key-spending-quota/tasks.md diff --git a/openspec/changes/add-api-key-spending-quota/.openspec.yaml b/openspec/changes/add-api-key-spending-quota/.openspec.yaml new file mode 100644 index 0000000..4e61834 --- /dev/null +++ b/openspec/changes/add-api-key-spending-quota/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-19 diff --git a/openspec/changes/add-api-key-spending-quota/design.md b/openspec/changes/add-api-key-spending-quota/design.md new file mode 100644 index 0000000..585a3f8 --- /dev/null +++ b/openspec/changes/add-api-key-spending-quota/design.md @@ -0,0 +1,241 @@ +## Context + +当前项目已经具备以下相关能力: + +- `api_keys` 与 `api_key_upstreams` 支撑 API Key 的创建、编辑、启停和上游授权。 +- `/api/proxy/v1/[...path]` 在请求入口完成 API Key 校验、上游选择、请求转发、请求日志写入和计费快照持久化。 +- `request_billing_snapshots` 已经按 `api_key_id` 保存 `finalCost`、`billingStatus`、`billedAt` 等字段,能够作为金额限额的事实来源。 +- 上游侧已经存在 `upstream.spendingRules` 与 `upstream-quota-tracker`,支持 `daily`、`monthly`、`rolling` 三种周期和多规则 AND 语义。 + +本次变更需要在不改变上游限额行为的前提下,为 API Key 增加金额限额能力,并且补齐密钥管理页的规则级额度状态展示。用户已经确认本次范围为: + +- 数据模型直接挂在 `api_keys.spending_rules` +- 仅限制金额 +- 支持多条规则同时生效,语义与上游一致 +- 基于已计费金额做硬拒绝,接受并发下的小幅超额 +- 被额度拒绝的请求需要落请求日志,并计入密钥请求次数 +- `unbilled` 请求允许通过且不计入额度 +- 额度状态只在密钥管理页展示,且需要展示到规则级 + +涉及模块横跨数据库 Schema、Admin API、代理入口、请求日志、计费快照消费聚合、前端表单与列表展示,属于典型的跨模块变更。 + +### 请求与额度关系草图 + +```text +┌──────────────┐ +│ Client │ +└──────┬───────┘ + │ + ▼ +┌──────────────────────────────┐ +│ Proxy Route │ +│ verify key │ +│ check key spending quota │ +└──────┬───────────────────────┘ + │ pass reject + │ │ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────┐ +│ route + forward │ │ write rejection log │ +│ write request log │ │ return 429 │ +│ persist billing row │ └──────────────────────┘ +└──────┬───────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ API Key Quota Tracker │ +│ increment billed cost │ +│ periodic DB reconciliation │ +└──────────────────────────────┘ +``` + +### 密钥管理页展示草图 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Key Name Status Access Spending Rules │ +├──────────────────────────────────────────────────────────────┤ +│ Production Key Active restricted │ +│ Daily [#######---] $72 / $100 resets in 6h │ +│ Rolling 6h [##########] $26 / $20 exceeded, recover...│ +├──────────────────────────────────────────────────────────────┤ +│ Sandbox Key Active unrestricted │ +│ No spending rules configured │ +└──────────────────────────────────────────────────────────────┘ +``` + +视觉层级说明: + +- 第一层是密钥基础信息,保持当前表格结构不变。 +- 第二层是规则级状态块,每条规则独立展示已用金额、上限、占比、超额状态和恢复信息。 +- 超额规则优先使用危险态文本和边框强调,但不自动把密钥切成停用态,避免和 `is_active` 语义混淆。 + +## Goals / Non-Goals + +**Goals:** + +- 为 API Key 增加与上游一致的金额限额规则结构与校验逻辑。 +- 在代理入口实现 API Key 限额硬拒绝,并在超额后稳定阻断后续请求。 +- 基于 `request_billing_snapshots` 的已计费金额维护密钥额度状态。 +- 在密钥管理页展示每条规则的实时状态,包括 fixed window 重置时间和 rolling 规则预计恢复时间。 +- 为被额度拒绝请求提供可审计的请求日志记录,并让密钥维度统计能够包含这些拒绝请求。 + +**Non-Goals:** + +- 不实现 Token、请求次数或按模型维度的密钥限额。 +- 不实现管理员手动清零、手动恢复额度或额度重置按钮。 +- 不保证多实例部署下的强一致额度判断。 +- 不在 Dashboard 总览页新增额度卡片或跨页面可视化。 +- 不解决“单次请求绝不允许超过上限”的预占用问题。 + +## Decisions + +### 1. 将 `spending_rules` 直接存入 `api_keys` + +决策: + +- 在 `api_keys` 表中新增 `spending_rules` JSON 字段,结构与上游 `spendingRules` 保持同构: + - `period_type: "daily" | "monthly" | "rolling"` + - `limit: number` + - `period_hours?: number` + +原因: + +- 当前需求只覆盖金额限额,直接复用现有上游规则结构最简洁。 +- API Key 的创建和编辑已经集中在同一个表单与同一组 Admin API 中,挂在 `api_keys` 上能避免引入额外 CRUD。 +- 该结构已经在上游配额中被验证过,命名和行为都已有一致参照。 + +备选方案: + +- 独立 `api_key_spending_rules` 表。该方案扩展性更强,但会让第一版的迁移、查询和表单交互复杂度明显上升,因此本次不采用。 + +### 2. 引入独立的 `api-key-quota-tracker`,而不是直接复用上游 tracker + +决策: + +- 新建 API Key 额度追踪服务,复用上游 tracker 的周期语义、DB 校准和滚动恢复算法,但实体主键改为 `api_key_id`,返回结构改为密钥管理页所需的规则级状态。 + +原因: + +- 上游 tracker 的业务语义是“路由过滤候选上游”,而 API Key tracker 的语义是“在入口拒绝请求并生成密钥状态展示”。 +- 两者虽然共享金额限额算法,但日志语义、调用方、返回结构和 UI 消费位置不同,直接复用会让服务职责混杂。 +- 独立服务便于后续在不影响上游路由逻辑的前提下演进密钥级规则展示和统计口径。 + +备选方案: + +- 让现有 `upstream-quota-tracker` 接受泛型主体类型。该方案抽象层次更高,但会在第一版引入不必要的广域重构。 + +### 3. 基于已计费金额执行硬拒绝,接受小幅超额 + +决策: + +- 代理入口在 API Key 鉴权通过后、上游选路前检查当前额度状态。 +- 若任一规则已达到或超过限额,则直接返回 `429`。 +- 实际消费累加以 `billingStatus = billed` 的快照为准,不做预占用。 + +原因: + +- 系统当前只能在请求完成并拿到 usage 与价格后得到精确金额,无法在请求开始前知道最终成本。 +- 采用已计费金额作为唯一事实来源,能让拒绝逻辑与账单口径保持一致。 +- 这与用户确认的“接受小幅超额”一致,避免引入复杂的预估和回滚机制。 + +备选方案: + +- 请求开始前基于 `max_tokens` 预留预算。该方案更严格,但会把模型差异、流式中断、缓存计费和回滚全部引入第一版。 + +### 4. 被额度拒绝的请求要写日志,但不归属上游 + +决策: + +- 对额度拒绝请求写入 `request_logs`: + - `apiKeyId` 有值 + - `upstreamId = null` + - `statusCode = 429` + - `errorMessage` 写入额度拒绝原因 +- 这类日志计入密钥请求次数,但不计入上游请求次数。 + +原因: + +- 用户明确要求需要审计能力,并希望密钥层能看到被拒绝尝试。 +- `upstreamId = null` 能天然避免把这类请求算入上游 leaderboard 和上游请求统计。 +- 保留统一日志入口,便于后续排查“为什么这个密钥不可用”。 + +备选方案: + +- 完全不记录日志。这样会损失排查与审计价值,本次不采用。 + +### 5. `unbilled` 请求允许通过,且不计入额度 + +决策: + +- 只有 `request_billing_snapshots.billingStatus = "billed"` 的请求会累加到密钥额度。 +- 价格缺失等导致的 `unbilled` 请求允许执行,不额外触发密钥额度拦截。 + +原因: + +- 当前金额限额的目标是约束“已确认可计费金额”,而不是所有调用次数。 +- 与上游限额现有语义保持一致,降低认知差异。 + +备选方案: + +- 只要配置了限额,遇到无法计费请求就直接拒绝。该方案更保守,但会让价格尚未维护的模型在有限额密钥下完全不可用。 + +### 6. 密钥管理页复用现有列表 API,直接返回规则配置与规则级状态 + +决策: + +- 扩展 `GET /api/admin/keys` 返回: + - `spending_rules` + - `spending_rule_statuses` + - `is_quota_exceeded` +- 创建、编辑 API 同样接收和返回 `spending_rules`。 + +原因: + +- 第一版只在密钥管理页展示额度状态,列表接口已经是该页的唯一数据源,直接扩展可避免新建只服务单页的接口。 +- 规则状态需要和规则配置一起展示,聚合在同一响应里能简化前端状态管理。 + +备选方案: + +- 新建 `/api/admin/keys/quota`。这样可以拆分职责,但会增加请求次数和前端合并逻辑,本次不采用。 + +### 7. rolling 规则的预计恢复时间通过 DB 回算获得 + +决策: + +- 当 rolling 规则超额时,基于当前窗口内该密钥的 billed 快照时间序列,计算“累计金额重新低于限制值”的最早时间,作为 `estimated_recovery_at`。 + +原因: + +- 仅返回 `null` 无法满足密钥管理页规则级展示需求。 +- 现有上游配额已经有相同的语义预期,可以沿用同类算法。 + +备选方案: + +- rolling 规则不显示预计恢复时间。该方案实现简单,但不满足已确认的展示要求。 + +## Risks / Trade-offs + +- [并发下可能小幅超额] → 通过设计文档和规格明确“按已计费金额硬拒绝”的边界,并在日志中保留拒绝与超额现象,避免误解为强一致预算锁。 +- [多实例下内存 tracker 不一致] → 第一版以单实例为主要目标,DB 校准仍保证最终一致;后续如扩为多实例,可再引入共享缓存或基于数据库的集中状态。 +- [`unbilled` 请求可能形成预算绕过口子] → 在规格中明确该行为是已接受的产品语义,并通过后续价格配置完善降低口子规模。 +- [列表接口返回的状态计算变重] → 仅对当前分页内密钥计算规则级状态,并复用内存 tracker + DB 校准,而不是每次请求都对全表做聚合。 +- [日志口径变化影响统计理解] → 通过 `upstreamId = null` 将额度拒绝请求与真实上游调用隔离,并在相关统计转换中明确密钥与上游的不同口径。 + +## Migration Plan + +1. 为 PostgreSQL 与 SQLite 的 `api_keys` 表增加 `spending_rules` 字段,并生成数据库迁移。 +2. 扩展 API Key 的类型、校验和 Admin API,使创建、编辑、列表链路能够读写并返回规则配置。 +3. 引入 API Key 额度追踪服务,在进程启动后完成初始化和数据库校准。 +4. 在代理入口接入额度预检查,并为额度拒绝请求补齐日志写入。 +5. 扩展密钥管理页的表单和列表展示,确认规则级状态与时间信息能够正确渲染。 +6. 通过服务层、路由层和前端测试覆盖配置、拒绝、恢复、日志与显示口径。 + +回滚策略: + +- 若上线后需要回退功能,可先移除代理入口额度检查与前端展示,再回滚 API 层对 `spending_rules` 的读写。 +- 已落库的 `spending_rules` 数据可以保留为空闲字段,不会影响旧逻辑继续运行。 + +## Open Questions + +- 当前没有阻塞实现的开放问题;后续若需要支持多实例强一致预算、手动额度重置或非金额型限额,应作为独立变更处理。 diff --git a/openspec/changes/add-api-key-spending-quota/proposal.md b/openspec/changes/add-api-key-spending-quota/proposal.md new file mode 100644 index 0000000..086e9c1 --- /dev/null +++ b/openspec/changes/add-api-key-spending-quota/proposal.md @@ -0,0 +1,43 @@ +## Why + +当前系统已经具备 API Key 管理、请求日志、计费快照和上游消费限额能力,但缺少针对单个 API Key 的消费约束。只依赖上游限额无法阻止单个密钥过度消耗预算,也无法在密钥管理页直观解释某个密钥为什么被拒绝、何时恢复可用,因此需要补齐密钥级金额限额能力。 + +## What Changes + +- 为 API Key 新增 `spending_rules` 配置,支持零条或多条金额限额规则。 +- 支持与上游限额一致的周期语义:`daily`、`monthly`、`rolling`,其中 `rolling` 需要 `period_hours`。 +- 在代理请求入口增加 API Key 消费限额检查;当任一规则超额时,后续请求立即以硬拒绝方式返回。 +- 被 API Key 限额拒绝的请求仍写入请求日志,并计入密钥请求次数,但不计入上游请求次数。 +- `unbilled` 请求允许通过,且不计入 API Key 消费限额。 +- 在密钥管理页展示每条规则的已用金额、限额金额、占比、超额状态,以及 fixed window 的重置时间或 rolling window 的预计恢复时间。 +- 管理员调整限额规则后立即生效;若新限额已低于当前已用金额,对应密钥立即进入临时超额状态,待窗口恢复后自动可用。 + +## Capabilities + +### New Capabilities +- `api-key-spending-quota`: API Key 金额限额的配置、运行时拦截、消费追踪、管理台状态展示与拒绝日志语义。 + +### Modified Capabilities +- None. + +## Impact + +- Affected code: + - `src/lib/db/schema-pg.ts` + - `src/lib/db/schema-sqlite.ts` + - `src/lib/services/key-manager.ts` + - `src/app/api/admin/keys/route.ts` + - `src/app/api/admin/keys/[id]/route.ts` + - `src/app/api/proxy/v1/[...path]/route.ts` + - `src/lib/services/request-logger.ts` + - `src/lib/services/stats-service.ts` + - `src/components/admin/create-key-dialog.tsx` + - `src/components/admin/edit-key-dialog.tsx` + - `src/components/admin/keys-table.tsx` + - `src/hooks/use-api-keys.ts` + - `src/types/api.ts` +- Affected systems: + - API Key 管理 Admin API + - 代理入口与请求日志链路 + - 密钥管理页额度状态展示 + - 基于请求日志与计费快照的密钥统计口径 diff --git a/openspec/changes/add-api-key-spending-quota/specs/api-key-spending-quota/spec.md b/openspec/changes/add-api-key-spending-quota/specs/api-key-spending-quota/spec.md new file mode 100644 index 0000000..190f615 --- /dev/null +++ b/openspec/changes/add-api-key-spending-quota/specs/api-key-spending-quota/spec.md @@ -0,0 +1,147 @@ +## ADDED Requirements + +### Requirement: API Key 消费限额配置 +系统 SHALL 允许管理员为每个 API Key 配置零条或多条消费限额规则,并将规则持久化到 `api_keys.spending_rules`。每条规则包含限额金额(USD)和周期类型(每天、每月、滚动 N 小时)。多条规则之间为 AND 语义:任一规则超额即视为该 API Key 超额。未配置任何规则的 API Key 不受消费限额约束。 + +#### Scenario: 创建密钥时配置单条每日限额规则 +- **WHEN** 管理员创建 API Key 并设置 `spending_rules = [{ period_type: "daily", limit: 50 }]` +- **THEN** 系统持久化该规则 +- **AND** 该密钥在当日已计费金额达到 $50 后进入临时超额状态 + +#### Scenario: 创建密钥时配置多条叠加规则 +- **WHEN** 管理员创建 API Key 并设置 `spending_rules = [{ period_type: "daily", limit: 100 }, { period_type: "rolling", limit: 30, period_hours: 6 }]` +- **THEN** 系统持久化全部规则 +- **AND** 该密钥在任一规则达到上限后进入临时超额状态 + +#### Scenario: 不配置限额规则 +- **WHEN** 管理员创建或编辑 API Key 时不设置任何 `spending_rules`(空数组或 null) +- **THEN** 该 API Key 不受消费限额约束 +- **AND** 行为与限额功能不存在时保持一致 + +#### Scenario: 编辑时调低限额立即生效 +- **WHEN** 管理员将某个 API Key 的限额调整为低于当前已计费金额 +- **THEN** 新规则在保存后立即生效 +- **AND** 该 API Key 在后续请求中立即按超额状态处理 + +#### Scenario: 移除全部规则后恢复额度约束豁免 +- **WHEN** 管理员编辑 API Key 并移除全部 `spending_rules` +- **THEN** 该 API Key 的消费限额约束立即解除 +- **AND** 不需要管理员额外执行手动重置 + +#### Scenario: rolling 规则缺少 period_hours +- **WHEN** 管理员提交 `period_type = "rolling"` 且未提供 `period_hours` +- **THEN** 系统 MUST 返回参数校验错误 + +#### Scenario: 规则限额金额非法 +- **WHEN** 管理员提交的某条规则 `limit` 小于或等于 0 +- **THEN** 系统 MUST 返回参数校验错误 + +### Requirement: 代理入口消费限额硬拒绝 +系统 SHALL 在 API Key 鉴权通过后、选择上游之前检查该密钥的消费限额状态。当任一规则在当前周期内的已计费金额达到或超过上限时,系统 MUST 立即拒绝后续请求,而不是继续进入上游路由或转发流程。 + +#### Scenario: 未超额的密钥允许继续请求 +- **WHEN** API Key 已配置限额规则且所有规则当前已计费金额均低于上限 +- **THEN** 系统允许请求继续进入后续路由与转发流程 + +#### Scenario: 任一规则超额时拒绝请求 +- **WHEN** API Key 的任一消费限额规则已达到或超过当前周期上限 +- **THEN** 系统 MUST 以拒绝响应终止请求 +- **AND** 系统 MUST 不再为该请求选择或调用任何上游 + +#### Scenario: 超额状态是临时的 +- **WHEN** API Key 因固定窗口或滚动窗口限额而超额 +- **THEN** 该 API Key 仍保持 `is_active = true` +- **AND** 仅在额度恢复到限制以下后自动恢复可用 + +#### Scenario: 固定窗口恢复后重新可用 +- **WHEN** daily 或 monthly 规则进入新的周期且当前周期实际已计费金额低于上限 +- **THEN** 该 API Key 在下一次额度状态计算后恢复为可用 + +#### Scenario: 滚动窗口恢复后重新可用 +- **WHEN** rolling 规则窗口外的旧消费滑出后,当前窗口内已计费金额重新低于上限 +- **THEN** 该 API Key 在下一次额度状态计算后恢复为可用 + +### Requirement: 已计费金额追踪与校准 +系统 SHALL 仅基于 `request_billing_snapshots` 中 `billingStatus = "billed"` 的记录追踪 API Key 的消费限额状态,并通过内存累加与数据库校准保证额度状态与已计费金额保持一致。 + +#### Scenario: billed 请求计入额度 +- **WHEN** 某次代理请求完成计费并生成 `billingStatus = "billed"` 的快照 +- **THEN** 系统 MUST 将该请求的 `finalCost` 累加到对应 API Key 的所有适用规则中 + +#### Scenario: unbilled 请求不计入额度 +- **WHEN** 某次代理请求因价格缺失或其他原因生成 `billingStatus = "unbilled"` 的快照 +- **THEN** 系统 MUST 不将该请求计入 API Key 消费限额 +- **AND** 该请求本身仍允许执行 + +#### Scenario: 启动后全量校准额度 +- **WHEN** API Key 额度追踪器首次初始化 +- **THEN** 系统 MUST 从 `request_billing_snapshots` 聚合所有配置了限额规则的 API Key 当前周期已计费金额 + +#### Scenario: 定期数据库校准 +- **WHEN** 额度追踪器的校准周期触发 +- **THEN** 系统 MUST 用数据库聚合结果覆盖内存中的当前消费值 +- **AND** rolling 窗口需要通过校准移除已滑出窗口的旧消费 + +#### Scenario: fixed window 使用 UTC 周期边界 +- **WHEN** 系统计算 daily 或 monthly 规则的当前周期 +- **THEN** 系统 MUST 使用 UTC 日期和 UTC 月初作为周期边界 + +### Requirement: 限额拒绝请求日志与统计口径 +系统 SHALL 为因 API Key 消费限额被拒绝的请求保留请求日志,并将其计入密钥请求次数;但这类请求 MUST 不计入上游请求次数。 + +#### Scenario: 额度拒绝请求写入日志 +- **WHEN** 某次请求因 API Key 消费限额超额而被拒绝 +- **THEN** 系统 MUST 写入一条请求日志 +- **AND** 该日志 MUST 关联对应 `api_key_id` +- **AND** 该日志 MUST 不关联任何 `upstream_id` +- **AND** 该日志 MUST 标识为额度拒绝错误 + +#### Scenario: 密钥请求统计包含额度拒绝 +- **WHEN** 系统按 API Key 聚合请求次数 +- **THEN** 因消费限额被拒绝的请求 MUST 计入对应密钥的请求次数 + +#### Scenario: 上游请求统计不包含额度拒绝 +- **WHEN** 系统按上游聚合请求次数 +- **THEN** 因消费限额被拒绝的请求 MUST 不计入任何上游的请求次数 + +### Requirement: 密钥管理页规则级额度状态展示 +系统 SHALL 在密钥管理页展示每个 API Key 的每条消费限额规则状态,包括当前已用金额、限额金额、占比、是否超额,以及 fixed window 的重置时间或 rolling window 的预计恢复时间。 + +#### Scenario: 列表展示多条规则状态 +- **WHEN** 某个 API Key 配置了多条 `spending_rules` +- **THEN** 密钥管理页 MUST 为每条规则分别展示一条状态信息 +- **AND** 每条状态信息 MUST 包含周期类型、已用金额、限额金额和占比 + +#### Scenario: 固定窗口展示重置时间 +- **WHEN** 某条规则的 `period_type` 为 `daily` 或 `monthly` +- **THEN** 密钥管理页 MUST 展示该规则的下一个周期重置时间 + +#### Scenario: rolling 规则展示预计恢复时间 +- **WHEN** 某条规则的 `period_type` 为 `rolling` +- **THEN** 密钥管理页 MUST 展示该规则的预计恢复时间 + +#### Scenario: 超额规则高亮显示 +- **WHEN** 某条规则当前已达到或超过限额 +- **THEN** 密钥管理页 MUST 对该规则显示明确的超额状态提示 + +#### Scenario: 无规则密钥不展示额度状态块 +- **WHEN** 某个 API Key 未配置 `spending_rules` +- **THEN** 密钥管理页 MUST 不渲染规则级额度状态信息 + +### Requirement: API Key 管理接口返回规则与状态 +系统 SHALL 通过现有 API Key Admin API 返回限额规则配置与规则级额度状态,以支撑创建、编辑和密钥管理页展示。 + +#### Scenario: 创建接口接收 spending_rules +- **WHEN** 管理员调用创建 API Key 接口并提交 `spending_rules` +- **THEN** 系统 MUST 校验并持久化规则配置 +- **AND** 创建响应 MUST 返回该密钥的规则配置 + +#### Scenario: 更新接口接收 spending_rules +- **WHEN** 管理员调用更新 API Key 接口并提交新的 `spending_rules` +- **THEN** 系统 MUST 校验并更新规则配置 +- **AND** 更新响应 MUST 返回更新后的规则配置 + +#### Scenario: 列表接口返回规则级额度状态 +- **WHEN** 管理员调用 API Key 列表接口 +- **THEN** 系统 MUST 为每个密钥返回其 `spending_rules` +- **AND** 对于已配置规则的密钥,系统 MUST 返回每条规则的额度状态、超额标识和对应时间信息 diff --git a/openspec/changes/add-api-key-spending-quota/tasks.md b/openspec/changes/add-api-key-spending-quota/tasks.md new file mode 100644 index 0000000..7e48e88 --- /dev/null +++ b/openspec/changes/add-api-key-spending-quota/tasks.md @@ -0,0 +1,31 @@ +## 1. Schema 与类型扩展 + +- [ ] 1.1 为 PostgreSQL 与 SQLite 的 `api_keys` 表新增 `spending_rules` 字段,并生成对应迁移 +- [ ] 1.2 在 `src/types/api.ts`、服务层输入输出类型与 API transformer 中补齐 `spending_rules`、规则级状态与超额标识字段 +- [ ] 1.3 扩展 API Key 创建与更新请求的服务层参数定义,确保规则结构与上游限额规则的校验口径一致 + +## 2. API Key 额度追踪与拒绝链路 + +- [ ] 2.1 新增 API Key 额度追踪服务,支持 fixed window、rolling window、规则级状态、预计恢复时间、启动初始化与定期数据库校准 +- [ ] 2.2 在 API Key 管理服务与 Admin API 中接入 `spending_rules` 的创建、编辑、列表返回与立即生效逻辑 +- [ ] 2.3 在 `/api/proxy/v1/[...path]` 中接入 API Key 额度预检查,并在超额时返回 `429` 且阻止上游路由与转发 +- [ ] 2.4 为额度拒绝请求补齐请求日志写入语义,确保其计入密钥请求次数且不归属任何上游 +- [ ] 2.5 在 billed 快照落库后接入 API Key 额度增量累加,并保持 `unbilled` 请求不计入额度 + +## 3. 密钥管理页配置与状态展示 + +- [ ] 3.1 在创建与编辑密钥对话框中增加动态 `spending_rules` 配置区域,支持多规则添加、删除与 rolling 参数联动 +- [ ] 3.2 在密钥列表表格中增加规则级额度状态展示,显示已用金额、限额金额、占比、超额提示、重置时间或预计恢复时间 +- [ ] 3.3 更新 `use-api-keys` 及相关页面状态处理,确保新增规则配置和规则级状态能够正确回显与刷新 + +## 4. 统计口径与接口一致性 + +- [ ] 4.1 调整密钥维度统计与转换逻辑,使额度拒绝请求计入密钥请求次数 +- [ ] 4.2 校准上游维度统计口径,确保额度拒绝请求不计入任何上游请求次数 +- [ ] 4.3 检查并补齐 API 响应中的错误码、错误消息与规则状态字段,保证前后端语义一致 + +## 5. 测试与验收 + +- [ ] 5.1 为 API Key 管理服务、额度追踪服务与代理拒绝链路补齐单元测试,覆盖多规则、立即生效、rolling 恢复与 `unbilled` 豁免 +- [ ] 5.2 为 Admin API 路由与密钥管理页组件补齐测试,覆盖规则配置、列表状态展示和额度拒绝日志口径 +- [ ] 5.3 运行与本次变更相关的测试和质量检查,确认 proposal 对应实现具备可提交状态 From 75e73c311a340838cb09267690cec879698abef9 Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 20 Mar 2026 11:48:18 +0800 Subject: [PATCH 2/6] chore: update config --- openspec/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/config.yaml b/openspec/config.yaml index f3d0e5f..af9c4d4 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -26,7 +26,7 @@ rules: - 涉及前端变更时,需包含关键场景的布局示意图和视觉层级说明 tasks: - task.md设计:按照线性流程分解任务,每个任务都要有明确的输入和输出,确保阶段性的任务是可提交的,每个阶段的任务都要有明确的目标和验收标准 - - 执行流程:每完成一个任务就立即勾选任务完成,每完成一个阶段的任务就就行一次阶段性的提交,每次提交都需要通过质量门禁 + - 执行流程:每完成一个任务就立即勾选任务完成,进行任务时需要自主规划提交节点并主动完成提交,每次提交都需要通过质量门禁 context: | 语言:中文(简体) From 4406d6164a76be850b037c414d51248e203af6fb Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 20 Mar 2026 12:32:45 +0800 Subject: [PATCH 3/6] feat: add api key spending quota --- drizzle-sqlite/0003_medical_rattler.sql | 1 + drizzle-sqlite/meta/0003_snapshot.json | 1702 +++++++++++++++ drizzle-sqlite/meta/_journal.json | 7 + drizzle/0024_tiny_brother_voodoo.sql | 1 + drizzle/meta/0024_snapshot.json | 1906 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../add-api-key-spending-quota/tasks.md | 34 +- src/app/api/admin/keys/[id]/route.ts | 5 + src/app/api/admin/keys/route.ts | 3 + src/app/api/proxy/v1/[...path]/route.ts | 130 +- src/components/admin/create-key-dialog.tsx | 192 +- src/components/admin/edit-key-dialog.tsx | 205 +- src/components/admin/keys-table.tsx | 132 +- src/lib/db/schema-pg.ts | 4 + src/lib/db/schema-sqlite.ts | 4 + src/lib/services/api-key-quota-tracker.ts | 402 ++++ src/lib/services/billing-cost-service.ts | 33 +- src/lib/services/key-manager.ts | 211 +- src/lib/services/spending-rules.ts | 64 + src/lib/services/unified-error.ts | 5 + src/lib/utils/api-transformers.ts | 38 + src/messages/en.json | 23 + src/messages/zh-CN.json | 23 + src/types/api.ts | 22 + tests/components/create-key-dialog.test.tsx | 54 + tests/components/edit-key-dialog.test.tsx | 46 + tests/components/keys-table.test.tsx | 36 + tests/unit/api/admin/keys/route.test.ts | 196 ++ tests/unit/api/proxy/route.test.ts | 84 + .../services/api-key-quota-tracker.test.ts | 194 ++ tests/unit/services/key-manager.test.ts | 14 + tests/unit/utils/api-transformers.test.ts | 79 + 32 files changed, 5789 insertions(+), 68 deletions(-) create mode 100644 drizzle-sqlite/0003_medical_rattler.sql create mode 100644 drizzle-sqlite/meta/0003_snapshot.json create mode 100644 drizzle/0024_tiny_brother_voodoo.sql create mode 100644 drizzle/meta/0024_snapshot.json create mode 100644 src/lib/services/api-key-quota-tracker.ts create mode 100644 src/lib/services/spending-rules.ts create mode 100644 tests/unit/api/admin/keys/route.test.ts create mode 100644 tests/unit/services/api-key-quota-tracker.test.ts diff --git a/drizzle-sqlite/0003_medical_rattler.sql b/drizzle-sqlite/0003_medical_rattler.sql new file mode 100644 index 0000000..e8b705e --- /dev/null +++ b/drizzle-sqlite/0003_medical_rattler.sql @@ -0,0 +1 @@ +ALTER TABLE `api_keys` ADD `spending_rules` text;--> statement-breakpoint diff --git a/drizzle-sqlite/meta/0003_snapshot.json b/drizzle-sqlite/meta/0003_snapshot.json new file mode 100644 index 0000000..50e7c55 --- /dev/null +++ b/drizzle-sqlite/meta/0003_snapshot.json @@ -0,0 +1,1702 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "707bc16f-cdab-4049-bdeb-6db22f625cfd", + "prevId": "90b78acf-8072-4f96-bb8b-e3493fa4e278", + "tables": { + "api_key_upstreams": { + "name": "api_key_upstreams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "api_key_upstreams_api_key_id_idx": { + "name": "api_key_upstreams_api_key_id_idx", + "columns": ["api_key_id"], + "isUnique": false + }, + "api_key_upstreams_upstream_id_idx": { + "name": "api_key_upstreams_upstream_id_idx", + "columns": ["upstream_id"], + "isUnique": false + }, + "uq_api_key_upstream": { + "name": "uq_api_key_upstream", + "columns": ["api_key_id", "upstream_id"], + "isUnique": true + } + }, + "foreignKeys": { + "api_key_upstreams_api_key_id_api_keys_id_fk": { + "name": "api_key_upstreams_api_key_id_api_keys_id_fk", + "tableFrom": "api_key_upstreams", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_upstreams_upstream_id_upstreams_id_fk": { + "name": "api_key_upstreams_upstream_id_upstreams_id_fk", + "tableFrom": "api_key_upstreams", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_value_encrypted": { + "name": "key_value_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unrestricted'" + }, + "spending_rules": { + "name": "spending_rules", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": ["key_hash"], + "isUnique": true + }, + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": ["key_hash"], + "isUnique": false + }, + "api_keys_is_active_idx": { + "name": "api_keys_is_active_idx", + "columns": ["is_active"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "billing_manual_price_overrides": { + "name": "billing_manual_price_overrides", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "billing_manual_price_overrides_model_unique": { + "name": "billing_manual_price_overrides_model_unique", + "columns": ["model"], + "isUnique": true + }, + "billing_manual_price_overrides_model_idx": { + "name": "billing_manual_price_overrides_model_idx", + "columns": ["model"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "billing_model_prices": { + "name": "billing_model_prices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_input_tokens": { + "name": "max_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "billing_model_prices_model_idx": { + "name": "billing_model_prices_model_idx", + "columns": ["model"], + "isUnique": false + }, + "billing_model_prices_source_idx": { + "name": "billing_model_prices_source_idx", + "columns": ["source"], + "isUnique": false + }, + "uq_billing_model_prices_model_source": { + "name": "uq_billing_model_prices_model_source", + "columns": ["model", "source"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "billing_price_sync_history": { + "name": "billing_price_sync_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "billing_price_sync_history_created_at_idx": { + "name": "billing_price_sync_history_created_at_idx", + "columns": ["created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "billing_tier_rules": { + "name": "billing_tier_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "threshold_input_tokens": { + "name": "threshold_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_label": { + "name": "display_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "billing_tier_rules_model_idx": { + "name": "billing_tier_rules_model_idx", + "columns": ["model"], + "isUnique": false + }, + "billing_tier_rules_source_idx": { + "name": "billing_tier_rules_source_idx", + "columns": ["source"], + "isUnique": false + }, + "uq_billing_tier_rules_model_source_threshold": { + "name": "uq_billing_tier_rules_model_source_threshold", + "columns": ["model", "source", "threshold_input_tokens"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_breaker_states": { + "name": "circuit_breaker_states", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'closed'" + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opened_at": { + "name": "opened_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_probe_at": { + "name": "last_probe_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "circuit_breaker_states_upstream_id_unique": { + "name": "circuit_breaker_states_upstream_id_unique", + "columns": ["upstream_id"], + "isUnique": true + }, + "circuit_breaker_states_upstream_id_idx": { + "name": "circuit_breaker_states_upstream_id_idx", + "columns": ["upstream_id"], + "isUnique": false + }, + "circuit_breaker_states_state_idx": { + "name": "circuit_breaker_states_state_idx", + "columns": ["state"], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_breaker_states_upstream_id_upstreams_id_fk": { + "name": "circuit_breaker_states_upstream_id_upstreams_id_fk", + "tableFrom": "circuit_breaker_states", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "compensation_rules": { + "name": "compensation_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_builtin": { + "name": "is_builtin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_header": { + "name": "target_header", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sources": { + "name": "sources", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'missing_only'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "compensation_rules_name_unique": { + "name": "compensation_rules_name_unique", + "columns": ["name"], + "isUnique": true + }, + "compensation_rules_enabled_idx": { + "name": "compensation_rules_enabled_idx", + "columns": ["enabled"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "request_billing_snapshots": { + "name": "request_billing_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_log_id": { + "name": "request_log_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unbillable_reason": { + "name": "unbillable_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_source": { + "name": "price_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_input_price_per_million": { + "name": "base_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_output_price_per_million": { + "name": "base_output_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_cache_read_input_price_per_million": { + "name": "base_cache_read_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_cache_write_input_price_per_million": { + "name": "base_cache_write_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched_rule_type": { + "name": "matched_rule_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched_rule_display_label": { + "name": "matched_rule_display_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applied_tier_threshold": { + "name": "applied_tier_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_max_input_tokens": { + "name": "model_max_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_max_output_tokens": { + "name": "model_max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_multiplier": { + "name": "input_multiplier", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_multiplier": { + "name": "output_multiplier", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_read_cost": { + "name": "cache_read_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_cost": { + "name": "cache_write_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_cost": { + "name": "final_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "billed_at": { + "name": "billed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "request_billing_snapshots_request_log_id_unique": { + "name": "request_billing_snapshots_request_log_id_unique", + "columns": ["request_log_id"], + "isUnique": true + }, + "request_billing_snapshots_request_log_id_idx": { + "name": "request_billing_snapshots_request_log_id_idx", + "columns": ["request_log_id"], + "isUnique": false + }, + "request_billing_snapshots_billing_status_idx": { + "name": "request_billing_snapshots_billing_status_idx", + "columns": ["billing_status"], + "isUnique": false + }, + "request_billing_snapshots_model_idx": { + "name": "request_billing_snapshots_model_idx", + "columns": ["model"], + "isUnique": false + }, + "request_billing_snapshots_created_at_idx": { + "name": "request_billing_snapshots_created_at_idx", + "columns": ["created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "request_billing_snapshots_request_log_id_request_logs_id_fk": { + "name": "request_billing_snapshots_request_log_id_request_logs_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "request_logs", + "columnsFrom": ["request_log_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "request_billing_snapshots_api_key_id_api_keys_id_fk": { + "name": "request_billing_snapshots_api_key_id_api_keys_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "request_billing_snapshots_upstream_id_upstreams_id_fk": { + "name": "request_billing_snapshots_upstream_id_upstreams_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "request_logs": { + "name": "request_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cached_tokens": { + "name": "cached_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_creation_tokens": { + "name": "cache_creation_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_creation_5m_tokens": { + "name": "cache_creation_5m_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_creation_1h_tokens": { + "name": "cache_creation_1h_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_duration_ms": { + "name": "routing_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_type": { + "name": "routing_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lb_strategy": { + "name": "lb_strategy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority_tier": { + "name": "priority_tier", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failover_attempts": { + "name": "failover_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failover_history": { + "name": "failover_history", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_decision": { + "name": "routing_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thinking_config": { + "name": "thinking_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affinity_hit": { + "name": "affinity_hit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "affinity_migrated": { + "name": "affinity_migrated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_stream": { + "name": "is_stream", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "session_id_compensated": { + "name": "session_id_compensated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "header_diff": { + "name": "header_diff", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "request_logs_api_key_id_idx": { + "name": "request_logs_api_key_id_idx", + "columns": ["api_key_id"], + "isUnique": false + }, + "request_logs_upstream_id_idx": { + "name": "request_logs_upstream_id_idx", + "columns": ["upstream_id"], + "isUnique": false + }, + "request_logs_created_at_idx": { + "name": "request_logs_created_at_idx", + "columns": ["created_at"], + "isUnique": false + }, + "request_logs_routing_type_idx": { + "name": "request_logs_routing_type_idx", + "columns": ["routing_type"], + "isUnique": false + } + }, + "foreignKeys": { + "request_logs_api_key_id_api_keys_id_fk": { + "name": "request_logs_api_key_id_api_keys_id_fk", + "tableFrom": "request_logs", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "request_logs_upstream_id_upstreams_id_fk": { + "name": "request_logs_upstream_id_upstreams_id_fk", + "tableFrom": "request_logs", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "upstream_health": { + "name": "upstream_health", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_healthy": { + "name": "is_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_check_at": { + "name": "last_check_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "upstream_health_upstream_id_unique": { + "name": "upstream_health_upstream_id_unique", + "columns": ["upstream_id"], + "isUnique": true + }, + "upstream_health_upstream_id_idx": { + "name": "upstream_health_upstream_id_idx", + "columns": ["upstream_id"], + "isUnique": false + }, + "upstream_health_is_healthy_idx": { + "name": "upstream_health_is_healthy_idx", + "columns": ["is_healthy"], + "isUnique": false + } + }, + "foreignKeys": { + "upstream_health_upstream_id_upstreams_id_fk": { + "name": "upstream_health_upstream_id_upstreams_id_fk", + "tableFrom": "upstream_health", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "upstreams": { + "name": "upstreams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "official_website_url": { + "name": "official_website_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "max_concurrency": { + "name": "max_concurrency", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "route_capabilities": { + "name": "route_capabilities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affinity_migration": { + "name": "affinity_migration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_input_multiplier": { + "name": "billing_input_multiplier", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "billing_output_multiplier": { + "name": "billing_output_multiplier", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "spending_rules": { + "name": "spending_rules", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "upstreams_name_unique": { + "name": "upstreams_name_unique", + "columns": ["name"], + "isUnique": true + }, + "upstreams_name_idx": { + "name": "upstreams_name_idx", + "columns": ["name"], + "isUnique": false + }, + "upstreams_is_active_idx": { + "name": "upstreams_is_active_idx", + "columns": ["is_active"], + "isUnique": false + }, + "upstreams_priority_idx": { + "name": "upstreams_priority_idx", + "columns": ["priority"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle-sqlite/meta/_journal.json b/drizzle-sqlite/meta/_journal.json index 507bcd9..8520de9 100644 --- a/drizzle-sqlite/meta/_journal.json +++ b/drizzle-sqlite/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1773927435295, "tag": "0002_api_keys_access_mode", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1773979738418, + "tag": "0003_medical_rattler", + "breakpoints": true } ] } diff --git a/drizzle/0024_tiny_brother_voodoo.sql b/drizzle/0024_tiny_brother_voodoo.sql new file mode 100644 index 0000000..bde6f9e --- /dev/null +++ b/drizzle/0024_tiny_brother_voodoo.sql @@ -0,0 +1 @@ +ALTER TABLE "api_keys" ADD COLUMN "spending_rules" json; diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json new file mode 100644 index 0000000..e95a0bc --- /dev/null +++ b/drizzle/meta/0024_snapshot.json @@ -0,0 +1,1906 @@ +{ + "id": "074f3ade-567b-4ff8-8320-05c83a4dcfcf", + "prevId": "2d41ed45-fa93-458b-99b2-eae9b5d57820", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_key_upstreams": { + "name": "api_key_upstreams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_upstreams_api_key_id_idx": { + "name": "api_key_upstreams_api_key_id_idx", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_upstreams_upstream_id_idx": { + "name": "api_key_upstreams_upstream_id_idx", + "columns": [ + { + "expression": "upstream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_upstreams_api_key_id_api_keys_id_fk": { + "name": "api_key_upstreams_api_key_id_api_keys_id_fk", + "tableFrom": "api_key_upstreams", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_upstreams_upstream_id_upstreams_id_fk": { + "name": "api_key_upstreams_upstream_id_upstreams_id_fk", + "tableFrom": "api_key_upstreams", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_api_key_upstream": { + "name": "uq_api_key_upstream", + "nullsNotDistinct": false, + "columns": [ + "api_key_id", + "upstream_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key_hash": { + "name": "key_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "key_value_encrypted": { + "name": "key_value_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "access_mode": { + "name": "access_mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'unrestricted'" + }, + "spending_rules": { + "name": "spending_rules", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_is_active_idx": { + "name": "api_keys_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_manual_price_overrides": { + "name": "billing_manual_price_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_manual_price_overrides_model_idx": { + "name": "billing_manual_price_overrides_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_manual_price_overrides_model_unique": { + "name": "billing_manual_price_overrides_model_unique", + "nullsNotDistinct": false, + "columns": [ + "model" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_model_prices": { + "name": "billing_model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_input_tokens": { + "name": "max_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_model_prices_model_idx": { + "name": "billing_model_prices_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_model_prices_source_idx": { + "name": "billing_model_prices_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_billing_model_prices_model_source": { + "name": "uq_billing_model_prices_model_source", + "nullsNotDistinct": false, + "columns": [ + "model", + "source" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_price_sync_history": { + "name": "billing_price_sync_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_price_sync_history_created_at_idx": { + "name": "billing_price_sync_history_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_tier_rules": { + "name": "billing_tier_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "threshold_input_tokens": { + "name": "threshold_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_label": { + "name": "display_label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_tier_rules_model_idx": { + "name": "billing_tier_rules_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_tier_rules_source_idx": { + "name": "billing_tier_rules_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_billing_tier_rules_model_source_threshold": { + "name": "uq_billing_tier_rules_model_source_threshold", + "nullsNotDistinct": false, + "columns": [ + "model", + "source", + "threshold_input_tokens" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.circuit_breaker_states": { + "name": "circuit_breaker_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'closed'" + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_at": { + "name": "last_probe_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "circuit_breaker_states_upstream_id_idx": { + "name": "circuit_breaker_states_upstream_id_idx", + "columns": [ + { + "expression": "upstream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "circuit_breaker_states_state_idx": { + "name": "circuit_breaker_states_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "circuit_breaker_states_upstream_id_upstreams_id_fk": { + "name": "circuit_breaker_states_upstream_id_upstreams_id_fk", + "tableFrom": "circuit_breaker_states", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "circuit_breaker_states_upstream_id_unique": { + "name": "circuit_breaker_states_upstream_id_unique", + "nullsNotDistinct": false, + "columns": [ + "upstream_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compensation_rules": { + "name": "compensation_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "capabilities": { + "name": "capabilities", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "target_header": { + "name": "target_header", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "sources": { + "name": "sources", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'missing_only'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "compensation_rules_enabled_idx": { + "name": "compensation_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "compensation_rules_name_unique": { + "name": "compensation_rules_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_billing_snapshots": { + "name": "request_billing_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_log_id": { + "name": "request_log_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "unbillable_reason": { + "name": "unbillable_reason", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "price_source": { + "name": "price_source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "base_input_price_per_million": { + "name": "base_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "base_output_price_per_million": { + "name": "base_output_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "base_cache_read_input_price_per_million": { + "name": "base_cache_read_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "base_cache_write_input_price_per_million": { + "name": "base_cache_write_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "matched_rule_type": { + "name": "matched_rule_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "matched_rule_display_label": { + "name": "matched_rule_display_label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "applied_tier_threshold": { + "name": "applied_tier_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model_max_input_tokens": { + "name": "model_max_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model_max_output_tokens": { + "name": "model_max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_multiplier": { + "name": "input_multiplier", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "output_multiplier": { + "name": "output_multiplier", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_cost": { + "name": "cache_read_cost", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "cache_write_cost": { + "name": "cache_write_cost", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "final_cost": { + "name": "final_cost", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billed_at": { + "name": "billed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "request_billing_snapshots_request_log_id_idx": { + "name": "request_billing_snapshots_request_log_id_idx", + "columns": [ + { + "expression": "request_log_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_billing_snapshots_billing_status_idx": { + "name": "request_billing_snapshots_billing_status_idx", + "columns": [ + { + "expression": "billing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_billing_snapshots_model_idx": { + "name": "request_billing_snapshots_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_billing_snapshots_created_at_idx": { + "name": "request_billing_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "request_billing_snapshots_request_log_id_request_logs_id_fk": { + "name": "request_billing_snapshots_request_log_id_request_logs_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "request_logs", + "columnsFrom": [ + "request_log_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "request_billing_snapshots_api_key_id_api_keys_id_fk": { + "name": "request_billing_snapshots_api_key_id_api_keys_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "request_billing_snapshots_upstream_id_upstreams_id_fk": { + "name": "request_billing_snapshots_upstream_id_upstreams_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "request_billing_snapshots_request_log_id_unique": { + "name": "request_billing_snapshots_request_log_id_unique", + "nullsNotDistinct": false, + "columns": [ + "request_log_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_logs": { + "name": "request_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_tokens": { + "name": "cached_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_tokens": { + "name": "cache_creation_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_5m_tokens": { + "name": "cache_creation_5m_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_1h_tokens": { + "name": "cache_creation_1h_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "routing_duration_ms": { + "name": "routing_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_type": { + "name": "routing_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "group_name": { + "name": "group_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "lb_strategy": { + "name": "lb_strategy", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "priority_tier": { + "name": "priority_tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "failover_attempts": { + "name": "failover_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failover_history": { + "name": "failover_history", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_decision": { + "name": "routing_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thinking_config": { + "name": "thinking_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "affinity_hit": { + "name": "affinity_hit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "affinity_migrated": { + "name": "affinity_migrated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_stream": { + "name": "is_stream", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "session_id_compensated": { + "name": "session_id_compensated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "header_diff": { + "name": "header_diff", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "request_logs_api_key_id_idx": { + "name": "request_logs_api_key_id_idx", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_logs_upstream_id_idx": { + "name": "request_logs_upstream_id_idx", + "columns": [ + { + "expression": "upstream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_logs_created_at_idx": { + "name": "request_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_logs_routing_type_idx": { + "name": "request_logs_routing_type_idx", + "columns": [ + { + "expression": "routing_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "request_logs_api_key_id_api_keys_id_fk": { + "name": "request_logs_api_key_id_api_keys_id_fk", + "tableFrom": "request_logs", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "request_logs_upstream_id_upstreams_id_fk": { + "name": "request_logs_upstream_id_upstreams_id_fk", + "tableFrom": "request_logs", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upstream_health": { + "name": "upstream_health", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_healthy": { + "name": "is_healthy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_check_at": { + "name": "last_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "upstream_health_upstream_id_idx": { + "name": "upstream_health_upstream_id_idx", + "columns": [ + { + "expression": "upstream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upstream_health_is_healthy_idx": { + "name": "upstream_health_is_healthy_idx", + "columns": [ + { + "expression": "is_healthy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upstream_health_upstream_id_upstreams_id_fk": { + "name": "upstream_health_upstream_id_upstreams_id_fk", + "tableFrom": "upstream_health", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "upstream_health_upstream_id_unique": { + "name": "upstream_health_upstream_id_unique", + "nullsNotDistinct": false, + "columns": [ + "upstream_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upstreams": { + "name": "upstreams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "official_website_url": { + "name": "official_website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_concurrency": { + "name": "max_concurrency", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "route_capabilities": { + "name": "route_capabilities", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "affinity_migration": { + "name": "affinity_migration", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "billing_input_multiplier": { + "name": "billing_input_multiplier", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "billing_output_multiplier": { + "name": "billing_output_multiplier", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "spending_rules": { + "name": "spending_rules", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upstreams_name_idx": { + "name": "upstreams_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upstreams_is_active_idx": { + "name": "upstreams_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upstreams_priority_idx": { + "name": "upstreams_priority_idx", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "upstreams_name_unique": { + "name": "upstreams_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e7e5115..211f7d4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1773927435294, "tag": "0023_api_keys_access_mode", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1773979771393, + "tag": "0024_tiny_brother_voodoo", + "breakpoints": true } ] } diff --git a/openspec/changes/add-api-key-spending-quota/tasks.md b/openspec/changes/add-api-key-spending-quota/tasks.md index 7e48e88..fd6b598 100644 --- a/openspec/changes/add-api-key-spending-quota/tasks.md +++ b/openspec/changes/add-api-key-spending-quota/tasks.md @@ -1,31 +1,31 @@ ## 1. Schema 与类型扩展 -- [ ] 1.1 为 PostgreSQL 与 SQLite 的 `api_keys` 表新增 `spending_rules` 字段,并生成对应迁移 -- [ ] 1.2 在 `src/types/api.ts`、服务层输入输出类型与 API transformer 中补齐 `spending_rules`、规则级状态与超额标识字段 -- [ ] 1.3 扩展 API Key 创建与更新请求的服务层参数定义,确保规则结构与上游限额规则的校验口径一致 +- [x] 1.1 为 PostgreSQL 与 SQLite 的 `api_keys` 表新增 `spending_rules` 字段,并生成对应迁移 +- [x] 1.2 在 `src/types/api.ts`、服务层输入输出类型与 API transformer 中补齐 `spending_rules`、规则级状态与超额标识字段 +- [x] 1.3 扩展 API Key 创建与更新请求的服务层参数定义,确保规则结构与上游限额规则的校验口径一致 ## 2. API Key 额度追踪与拒绝链路 -- [ ] 2.1 新增 API Key 额度追踪服务,支持 fixed window、rolling window、规则级状态、预计恢复时间、启动初始化与定期数据库校准 -- [ ] 2.2 在 API Key 管理服务与 Admin API 中接入 `spending_rules` 的创建、编辑、列表返回与立即生效逻辑 -- [ ] 2.3 在 `/api/proxy/v1/[...path]` 中接入 API Key 额度预检查,并在超额时返回 `429` 且阻止上游路由与转发 -- [ ] 2.4 为额度拒绝请求补齐请求日志写入语义,确保其计入密钥请求次数且不归属任何上游 -- [ ] 2.5 在 billed 快照落库后接入 API Key 额度增量累加,并保持 `unbilled` 请求不计入额度 +- [x] 2.1 新增 API Key 额度追踪服务,支持 fixed window、rolling window、规则级状态、预计恢复时间、启动初始化与定期数据库校准 +- [x] 2.2 在 API Key 管理服务与 Admin API 中接入 `spending_rules` 的创建、编辑、列表返回与立即生效逻辑 +- [x] 2.3 在 `/api/proxy/v1/[...path]` 中接入 API Key 额度预检查,并在超额时返回 `429` 且阻止上游路由与转发 +- [x] 2.4 为额度拒绝请求补齐请求日志写入语义,确保其计入密钥请求次数且不归属任何上游 +- [x] 2.5 在 billed 快照落库后接入 API Key 额度增量累加,并保持 `unbilled` 请求不计入额度 ## 3. 密钥管理页配置与状态展示 -- [ ] 3.1 在创建与编辑密钥对话框中增加动态 `spending_rules` 配置区域,支持多规则添加、删除与 rolling 参数联动 -- [ ] 3.2 在密钥列表表格中增加规则级额度状态展示,显示已用金额、限额金额、占比、超额提示、重置时间或预计恢复时间 -- [ ] 3.3 更新 `use-api-keys` 及相关页面状态处理,确保新增规则配置和规则级状态能够正确回显与刷新 +- [x] 3.1 在创建与编辑密钥对话框中增加动态 `spending_rules` 配置区域,支持多规则添加、删除与 rolling 参数联动 +- [x] 3.2 在密钥列表表格中增加规则级额度状态展示,显示已用金额、限额金额、占比、超额提示、重置时间或预计恢复时间 +- [x] 3.3 更新 `use-api-keys` 及相关页面状态处理,确保新增规则配置和规则级状态能够正确回显与刷新 ## 4. 统计口径与接口一致性 -- [ ] 4.1 调整密钥维度统计与转换逻辑,使额度拒绝请求计入密钥请求次数 -- [ ] 4.2 校准上游维度统计口径,确保额度拒绝请求不计入任何上游请求次数 -- [ ] 4.3 检查并补齐 API 响应中的错误码、错误消息与规则状态字段,保证前后端语义一致 +- [x] 4.1 调整密钥维度统计与转换逻辑,使额度拒绝请求计入密钥请求次数 +- [x] 4.2 校准上游维度统计口径,确保额度拒绝请求不计入任何上游请求次数 +- [x] 4.3 检查并补齐 API 响应中的错误码、错误消息与规则状态字段,保证前后端语义一致 ## 5. 测试与验收 -- [ ] 5.1 为 API Key 管理服务、额度追踪服务与代理拒绝链路补齐单元测试,覆盖多规则、立即生效、rolling 恢复与 `unbilled` 豁免 -- [ ] 5.2 为 Admin API 路由与密钥管理页组件补齐测试,覆盖规则配置、列表状态展示和额度拒绝日志口径 -- [ ] 5.3 运行与本次变更相关的测试和质量检查,确认 proposal 对应实现具备可提交状态 +- [x] 5.1 为 API Key 管理服务、额度追踪服务与代理拒绝链路补齐单元测试,覆盖多规则、立即生效、rolling 恢复与 `unbilled` 豁免 +- [x] 5.2 为 Admin API 路由与密钥管理页组件补齐测试,覆盖规则配置、列表状态展示和额度拒绝日志口径 +- [x] 5.3 运行与本次变更相关的测试和质量检查,确认 proposal 对应实现具备可提交状态 diff --git a/src/app/api/admin/keys/[id]/route.ts b/src/app/api/admin/keys/[id]/route.ts index af83192..749bb49 100644 --- a/src/app/api/admin/keys/[id]/route.ts +++ b/src/app/api/admin/keys/[id]/route.ts @@ -11,6 +11,7 @@ import { import { transformApiKeyToApi } from "@/lib/utils/api-transformers"; import { z } from "zod"; import { createLogger } from "@/lib/utils/logger"; +import { nullableSpendingRulesSchema } from "@/lib/services/spending-rules"; const log = createLogger("admin-keys"); @@ -70,6 +71,7 @@ const updateApiKeySchema = z access_mode: z.enum(["unrestricted", "restricted"]).optional(), expires_at: z.string().datetime().nullable().optional(), upstream_ids: z.array(z.string().uuid()).optional(), + spending_rules: nullableSpendingRulesSchema, }) .strict() .superRefine((data, ctx) => { @@ -129,6 +131,9 @@ export async function PUT(request: NextRequest, context: RouteContext) { if (validated.upstream_ids !== undefined) { input.upstreamIds = validated.upstream_ids; } + if (validated.spending_rules !== undefined) { + input.spendingRules = validated.spending_rules ?? null; + } const result = await updateApiKey(id, input); diff --git a/src/app/api/admin/keys/route.ts b/src/app/api/admin/keys/route.ts index a7031fb..14c210c 100644 --- a/src/app/api/admin/keys/route.ts +++ b/src/app/api/admin/keys/route.ts @@ -8,6 +8,7 @@ import { } from "@/lib/utils/api-transformers"; import { z } from "zod"; import { createLogger } from "@/lib/utils/logger"; +import { nullableSpendingRulesSchema } from "@/lib/services/spending-rules"; const log = createLogger("admin-keys"); @@ -18,6 +19,7 @@ const createApiKeySchema = z upstream_ids: z.array(z.string().uuid()).optional().default([]), description: z.string().nullable().optional(), expires_at: z.string().datetime().nullable().optional(), + spending_rules: nullableSpendingRulesSchema, }) .superRefine((data, ctx) => { const effectiveMode = @@ -71,6 +73,7 @@ export async function POST(request: NextRequest) { upstreamIds: validated.upstream_ids, description: validated.description ?? null, expiresAt: validated.expires_at ? new Date(validated.expires_at) : null, + spendingRules: validated.spending_rules ?? null, }; const result = await createApiKey(input); diff --git a/src/app/api/proxy/v1/[...path]/route.ts b/src/app/api/proxy/v1/[...path]/route.ts index 82f08dc..29b3d3d 100644 --- a/src/app/api/proxy/v1/[...path]/route.ts +++ b/src/app/api/proxy/v1/[...path]/route.ts @@ -94,6 +94,7 @@ import { import { buildCompensations } from "@/lib/services/compensation-service"; import { createLogger } from "@/lib/utils/logger"; import { extractRequestThinkingConfig } from "@/lib/utils/request-thinking-config"; +import { apiKeyQuotaTracker } from "@/lib/services/api-key-quota-tracker"; const log = createLogger("proxy-route"); @@ -130,6 +131,89 @@ async function persistBillingSnapshotSafely(input: { } } +function buildApiKeyQuotaExceededErrorMessage( + apiKeyId: string, + exceededRules: Array<{ + periodType: "daily" | "monthly" | "rolling"; + periodHours: number | null; + currentSpending: number; + spendingLimit: number; + }> +): string { + const rulesSummary = exceededRules + .map((rule) => { + if (rule.periodType === "rolling") { + return `rolling-${rule.periodHours ?? 24}h ${rule.currentSpending}/${rule.spendingLimit}`; + } + return `${rule.periodType} ${rule.currentSpending}/${rule.spendingLimit}`; + }) + .join("; "); + + return `API key spending quota exceeded (api_key_id=${apiKeyId}; rules=${rulesSummary})`; +} + +async function logApiKeyQuotaRejectedRequest(input: { + apiKeyId: string; + request: NextRequest; + path: string; + model: string | null; + reasoningEffort: ReasoningEffort | null; + thinkingConfig: import("@/types/api").RequestThinkingConfig | null; + requestId: string; + startTime: number; + sessionId: string | null; + matchedRouteCapability: RouteCapability | null; + routeMatchSource: RouteMatchSource | null; + errorMessage: string; +}): Promise { + const routingDecision: RoutingDecisionLog = { + original_model: input.model ?? "(path-based)", + resolved_model: input.model ?? "(path-based)", + model_redirect_applied: false, + provider_type: null, + routing_type: "none", + matched_route_capability: input.matchedRouteCapability, + route_match_source: input.routeMatchSource, + capability_candidates_count: 0, + candidates: [], + excluded: [], + candidate_count: 0, + final_candidate_count: 0, + selected_upstream_id: null, + candidate_upstream_id: null, + actual_upstream_id: null, + did_send_upstream: false, + failure_stage: "candidate_selection", + selection_strategy: "weighted", + }; + + await logRequest({ + apiKeyId: input.apiKeyId, + upstreamId: null, + method: input.request.method, + path: input.path, + model: input.model, + reasoningEffort: input.reasoningEffort, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + statusCode: getHttpStatusForError("API_KEY_QUOTA_EXCEEDED"), + durationMs: Date.now() - input.startTime, + routingDurationMs: null, + errorMessage: input.errorMessage, + routingType: null, + priorityTier: null, + failoverAttempts: 0, + failoverHistory: null, + routingDecision, + thinkingConfig: input.thinkingConfig, + sessionId: input.sessionId, + affinityHit: false, + affinityMigrated: false, + isStream: false, + }); +} + /** * Transform ModelRouterResult to RoutingDecisionLog for storage. */ @@ -611,6 +695,9 @@ function getUserHint( if (reason === "CONCURRENCY_FULL") { return "当前所有可选上游均已达到并发上限,请提高上游并发配置或增加可用上游后重试"; } + if (reason === "API_KEY_QUOTA_EXCEEDED") { + return "当前密钥已达到消费限额,请等待额度窗口恢复或联系管理员调整额度规则"; + } if (reason === "NO_HEALTHY_CANDIDATES") { return "当前没有可用上游候选,请检查上游启用状态、熔断状态与路径能力配置"; } @@ -1719,8 +1806,48 @@ async function handleProxy(request: NextRequest, context: RouteContext): Promise request.headers ); const matchedRouteCapability = matchedRouteCapabilityDetails?.capability ?? null; + const matchedRouteMatchSource = matchedRouteCapabilityDetails?.routeMatchSource ?? null; const thinkingConfig = extractRequestThinkingConfig(matchedRouteCapability, bodyJson); + await apiKeyQuotaTracker.initialize(); + const apiKeyQuotaStatus = apiKeyQuotaTracker.getQuotaStatus(validApiKey.id); + if (apiKeyQuotaStatus?.isExceeded) { + const errorCode: UnifiedErrorCode = "API_KEY_QUOTA_EXCEEDED"; + const errorReason: UnifiedErrorReason = "API_KEY_QUOTA_EXCEEDED"; + const exceededRules = apiKeyQuotaStatus.rules.filter((rule) => rule.isExceeded); + const errorMessage = buildApiKeyQuotaExceededErrorMessage(validApiKey.id, exceededRules); + const errorDetails = { + reason: errorReason, + did_send_upstream: false, + request_id: requestId, + user_hint: getUserHint(errorCode, errorReason, matchedRouteCapability ?? "openai_responses"), + } as const; + + try { + await logApiKeyQuotaRejectedRequest({ + apiKeyId: validApiKey.id, + request, + path, + model, + reasoningEffort, + thinkingConfig, + requestId, + startTime, + sessionId: null, + matchedRouteCapability, + routeMatchSource: matchedRouteMatchSource, + errorMessage, + }); + } catch (error) { + log.error( + { err: error, requestId, apiKeyId: validApiKey.id }, + "failed to log API key quota rejection" + ); + } + + return createUnifiedErrorResponse(errorCode, errorDetails); + } + if (!matchedRouteCapability) { const unsupportedDurationMs = Date.now() - startTime; const unsupportedResponse = createUnifiedErrorResponse("NO_UPSTREAMS_CONFIGURED", { @@ -1813,8 +1940,7 @@ async function handleProxy(request: NextRequest, context: RouteContext): Promise let priorityTier: number | null = null; let resolvedModel: string | null = model; let modelRedirectApplied = false; - const routeMatchSource: RouteMatchSource = - matchedRouteCapabilityDetails?.routeMatchSource ?? "path"; + const routeMatchSource: RouteMatchSource = matchedRouteMatchSource ?? "path"; let candidateUpstreamIds: string[] = []; let capabilityCandidates: Upstream[] = []; let finalCapabilityCandidates: Upstream[] = []; diff --git a/src/components/admin/create-key-dialog.tsx b/src/components/admin/create-key-dialog.tsx index 4e77c01..9707cb7 100644 --- a/src/components/admin/create-key-dialog.tsx +++ b/src/components/admin/create-key-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useForm } from "react-hook-form"; +import { useFieldArray, useForm, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Plus, CalendarIcon, Search } from "lucide-react"; @@ -61,6 +61,23 @@ export function CreateKeyDialog() { access_mode: z.enum(["unrestricted", "restricted"]), upstream_ids: z.array(z.string()), expires_at: z.date().optional(), + spending_rules: z.array( + z + .object({ + period_type: z.enum(["daily", "monthly", "rolling"]), + limit: z.number().positive(t("quotaLimitPositive")), + period_hours: z.number().int().min(1).max(8760).optional(), + }) + .superRefine((rule, ctx) => { + if (rule.period_type === "rolling" && rule.period_hours == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["period_hours"], + message: t("quotaPeriodHoursRequired"), + }); + } + }) + ), }) .superRefine((data, ctx) => { if (data.access_mode === "restricted" && data.upstream_ids.length === 0) { @@ -82,11 +99,26 @@ export function CreateKeyDialog() { access_mode: "unrestricted", upstream_ids: [], expires_at: undefined, + spending_rules: [], }, }); - - const accessMode = form.watch("access_mode"); - const selectedUpstreamIds = form.watch("upstream_ids"); + const spendingRulesFieldArray = useFieldArray({ + control: form.control, + name: "spending_rules", + }); + const spendingRules = useWatch({ + control: form.control, + name: "spending_rules", + }); + const accessMode = useWatch({ + control: form.control, + name: "access_mode", + }); + const selectedUpstreamIds = + useWatch({ + control: form.control, + name: "upstream_ids", + }) ?? []; const normalizedUpstreamSearchQuery = upstreamSearchQuery.trim().toLowerCase(); const filteredUpstreams = (upstreams ?? []).filter((upstream) => { if (!normalizedUpstreamSearchQuery) { @@ -112,6 +144,7 @@ export function CreateKeyDialog() { access_mode: data.access_mode, upstream_ids: data.access_mode === "restricted" ? data.upstream_ids : [], expires_at: data.expires_at ? data.expires_at.toISOString() : null, + spending_rules: data.spending_rules.length > 0 ? data.spending_rules : null, }); setCreatedKey(result); @@ -347,6 +380,157 @@ export function CreateKeyDialog() { /> )} +
+
+
+

+ {t("spendingRules")} +

+

+ {t("spendingRulesDesc")} +

+
+ +
+ + {spendingRulesFieldArray.fields.length === 0 ? ( +

+ {t("spendingRulesEmpty")} +

+ ) : ( +
+ {spendingRulesFieldArray.fields.map((field, index) => { + const rulePeriodType = spendingRules?.[index]?.period_type; + return ( +
+
+
+ {t("spendingRuleLabel", { index: index + 1 })} +
+ +
+ + ( + + {t("quotaPeriodType")} +
+ {(["daily", "monthly", "rolling"] as const).map((value) => ( + + ))} +
+ +
+ )} + /> + +
+ ( + + {t("quotaLimitUsd")} + + + field.onChange( + event.target.value === "" + ? undefined + : Number(event.target.value) + ) + } + placeholder={t("quotaLimitPlaceholder")} + /> + + + + )} + /> + + {rulePeriodType === "rolling" && ( + ( + + {t("quotaPeriodHours")} + + + field.onChange( + event.target.value === "" + ? undefined + : Number(event.target.value) + ) + } + placeholder={t("quotaPeriodHoursPlaceholder")} + /> + + + + )} + /> + )} +
+
+ ); + })} +
+ )} +
+ { + if (rule.period_type === "rolling" && rule.period_hours == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["period_hours"], + message: t("quotaPeriodHoursRequired"), + }); + } + }) + ), }) .superRefine((data, ctx) => { if (data.access_mode === "restricted" && data.upstream_ids.length === 0) { @@ -86,11 +103,26 @@ export function EditKeyDialog({ apiKey, open, onOpenChange }: EditKeyDialogProps access_mode: apiKey.access_mode, upstream_ids: apiKey.upstream_ids, expires_at: apiKey.expires_at ? new Date(apiKey.expires_at) : null, + spending_rules: apiKey.spending_rules ?? [], }, }); - - const accessMode = form.watch("access_mode"); - const selectedUpstreamIds = form.watch("upstream_ids"); + const spendingRulesFieldArray = useFieldArray({ + control: form.control, + name: "spending_rules", + }); + const spendingRules = useWatch({ + control: form.control, + name: "spending_rules", + }); + const accessMode = useWatch({ + control: form.control, + name: "access_mode", + }); + const selectedUpstreamIds = + useWatch({ + control: form.control, + name: "upstream_ids", + }) ?? []; const normalizedUpstreamSearchQuery = upstreamSearchQuery.trim().toLowerCase(); const filteredUpstreams = (upstreams ?? []).filter((upstream) => { if (!normalizedUpstreamSearchQuery) { @@ -117,15 +149,16 @@ export function EditKeyDialog({ apiKey, open, onOpenChange }: EditKeyDialogProps access_mode: apiKey.access_mode, upstream_ids: apiKey.upstream_ids, expires_at: apiKey.expires_at ? new Date(apiKey.expires_at) : null, + spending_rules: apiKey.spending_rules ?? [], }); - setUpstreamSearchQuery(""); }, [apiKey, form]); - useEffect(() => { - if (!open) { + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { setUpstreamSearchQuery(""); } - }, [open]); + onOpenChange(nextOpen); + }; const onSubmit = async (data: EditKeyForm) => { try { @@ -138,6 +171,7 @@ export function EditKeyDialog({ apiKey, open, onOpenChange }: EditKeyDialogProps access_mode: data.access_mode, upstream_ids: data.access_mode === "restricted" ? data.upstream_ids : [], expires_at: data.expires_at ? data.expires_at.toISOString() : null, + spending_rules: data.spending_rules.length > 0 ? data.spending_rules : null, }, }); @@ -152,7 +186,7 @@ export function EditKeyDialog({ apiKey, open, onOpenChange }: EditKeyDialogProps }; return ( - + {t("editKeyTitle")} @@ -366,6 +400,157 @@ export function EditKeyDialog({ apiKey, open, onOpenChange }: EditKeyDialogProps /> )} +
+
+
+

+ {t("spendingRules")} +

+

+ {t("spendingRulesDesc")} +

+
+ +
+ + {spendingRulesFieldArray.fields.length === 0 ? ( +

+ {t("spendingRulesEmpty")} +

+ ) : ( +
+ {spendingRulesFieldArray.fields.map((field, index) => { + const rulePeriodType = spendingRules?.[index]?.period_type; + return ( +
+
+
+ {t("spendingRuleLabel", { index: index + 1 })} +
+ +
+ + ( + + {t("quotaPeriodType")} +
+ {(["daily", "monthly", "rolling"] as const).map((value) => ( + + ))} +
+ +
+ )} + /> + +
+ ( + + {t("quotaLimitUsd")} + + + field.onChange( + event.target.value === "" + ? undefined + : Number(event.target.value) + ) + } + placeholder={t("quotaLimitPlaceholder")} + /> + + + + )} + /> + + {rulePeriodType === "rolling" && ( + ( + + {t("quotaPeriodHours")} + + + field.onChange( + event.target.value === "" + ? undefined + : Number(event.target.value) + ) + } + placeholder={t("quotaPeriodHoursPlaceholder")} + /> + + + + )} + /> + )} +
+
+ ); + })} +
+ )} +
+ +): string { + if (rule.period_type === "rolling") { + return t("quotaPeriodRollingWithHours", { hours: rule.period_hours ?? 24 }); + } + + return t(`quotaPeriodType_${rule.period_type}`); +} + export function KeysTable({ keys, onRevoke, onEdit }: KeysTableProps) { const [copiedId, setCopiedId] = useState(null); const [visibleKeyIds, setVisibleKeyIds] = useState>(new Set()); @@ -49,6 +60,12 @@ export function KeysTable({ keys, onRevoke, onEdit }: KeysTableProps) { const tCommon = useTranslations("common"); const locale = useLocale(); const dateLocale = getDateLocale(locale); + const currencyFormatter = new Intl.NumberFormat(locale, { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 4, + }); const filteredKeys = keys.filter((key) => key.name.toLowerCase().includes(searchQuery.toLowerCase()) @@ -170,6 +187,93 @@ export function KeysTable({ keys, onRevoke, onEdit }: KeysTableProps) { } }; + const formatQuotaAmount = (value: number) => currencyFormatter.format(value); + + const renderQuotaRules = (key: APIKey) => { + if (!key.spending_rules || key.spending_rules.length === 0) { + return null; + } + + if (key.spending_rule_statuses.length === 0) { + return ( +
+

{t("quotaStatusPending")}

+
+ ); + } + + return ( +
+ {key.spending_rule_statuses.map((rule, index) => { + const timeText = + rule.period_type === "rolling" + ? rule.estimated_recovery_at + ? t("quotaRecoveryTime", { + time: formatDistanceToNow(new Date(rule.estimated_recovery_at), { + addSuffix: true, + locale: dateLocale, + }), + }) + : t("quotaRecoveryPending") + : rule.resets_at + ? t("quotaResetTime", { + time: formatDistanceToNow(new Date(rule.resets_at), { + addSuffix: true, + locale: dateLocale, + }), + }) + : null; + + return ( +
+
+
+

+ {formatQuotaPeriodLabel(rule, t)} +

+

+ {formatQuotaAmount(rule.current_spending)} /{" "} + {formatQuotaAmount(rule.spending_limit)} +

+
+ + {t("quotaPercentUsed", { percent: rule.percent_used.toFixed(1) })} + +
+ +
+
+
+ +
+ + {rule.is_exceeded ? t("quotaExceeded") : t("quotaWithinLimit")} + + {timeText ? ( + {timeText} + ) : null} +
+
+ ); + })} +
+ ); + }; + if (keys.length === 0) { return (
@@ -224,9 +328,16 @@ export function KeysTable({ keys, onRevoke, onEdit }: KeysTableProps) {

{key.name}

{key.description || "-"}

- - {key.is_active ? t("enabled") : t("disabled")} - +
+ + {key.is_active ? t("enabled") : t("disabled")} + + {key.is_quota_exceeded ? ( + + {t("quotaExceeded")} + + ) : null} +
@@ -296,6 +407,13 @@ export function KeysTable({ keys, onRevoke, onEdit }: KeysTableProps) {
+ {key.spending_rules && key.spending_rules.length > 0 ? ( +
+

{t("spendingRules")}

+ {renderQuotaRules(key)} +
+ ) : null} +
{key.is_active ? t("enabled") : t("disabled")} + {key.is_quota_exceeded ? ( + + {t("quotaExceeded")} + + ) : null}
+ {key.spending_rules && key.spending_rules.length > 0 ? ( +
{renderQuotaRules(key)}
+ ) : null}
diff --git a/src/lib/db/schema-pg.ts b/src/lib/db/schema-pg.ts index 4788eca..ac064d0 100644 --- a/src/lib/db/schema-pg.ts +++ b/src/lib/db/schema-pg.ts @@ -27,6 +27,10 @@ export const apiKeys = pgTable( description: text("description"), userId: uuid("user_id"), // Reserved for future user system accessMode: varchar("access_mode", { length: 16 }).notNull().default("unrestricted"), + spendingRules: json("spending_rules").$type< + | { period_type: "daily" | "monthly" | "rolling"; limit: number; period_hours?: number }[] + | null + >(), isActive: boolean("is_active").notNull().default(true), expiresAt: timestamp("expires_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/src/lib/db/schema-sqlite.ts b/src/lib/db/schema-sqlite.ts index 5792de0..8f47ccc 100644 --- a/src/lib/db/schema-sqlite.ts +++ b/src/lib/db/schema-sqlite.ts @@ -18,6 +18,10 @@ export const apiKeys = sqliteTable( description: text("description"), userId: text("user_id"), // Reserved for future user system accessMode: text("access_mode").notNull().default("unrestricted"), + spendingRules: text("spending_rules", { mode: "json" }).$type< + | { period_type: "daily" | "monthly" | "rolling"; limit: number; period_hours?: number }[] + | null + >(), isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), expiresAt: integer("expires_at", { mode: "timestamp_ms" }), createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().defaultNow(), diff --git a/src/lib/services/api-key-quota-tracker.ts b/src/lib/services/api-key-quota-tracker.ts new file mode 100644 index 0000000..aa18e8f --- /dev/null +++ b/src/lib/services/api-key-quota-tracker.ts @@ -0,0 +1,402 @@ +import { and, eq, gte, isNotNull, lt, sum } from "drizzle-orm"; +import { apiKeys, db, requestBillingSnapshots, type ApiKey } from "@/lib/db"; +import { + type RuleStatus, + type SpendingRule, + getPeriodStartForRule, + getResetsAtForRule, + getRollingWindowStart, +} from "@/lib/services/upstream-quota-tracker"; +import { normalizeSpendingRules } from "@/lib/services/spending-rules"; + +interface ApiKeyQuotaStatus { + apiKeyId: string; + apiKeyName: string; + rules: RuleStatus[]; + isExceeded: boolean; +} + +interface RuleCacheEntry { + periodType: SpendingRule["period_type"]; + periodHours: number | null; + limit: number; + currentSpending: number; + lastSyncedAt: Date; +} + +const NORMAL_SYNC_INTERVAL_MS = 5 * 60 * 1000; +const URGENT_SYNC_INTERVAL_MS = 1 * 60 * 1000; +const URGENT_THRESHOLD_PERCENT = 80; +const HOUR_MS = 60 * 60 * 1000; + +function extractSpendingRulesFromApiKey(apiKey: Pick): SpendingRule[] { + return normalizeSpendingRules(apiKey.spendingRules) ?? []; +} + +class ApiKeyQuotaTracker { + private cache = new Map(); + private rulesCache = new Map(); + private nameCache = new Map(); + private syncTimer: ReturnType | null = null; + private initialized = false; + private initPromise: Promise | null = null; + + async initialize(): Promise { + if (this.initialized) { + return; + } + if (!this.initPromise) { + this.initPromise = (async () => { + await this.syncFromDb(); + this.startSyncTimer(); + this.initialized = true; + })().catch((error) => { + this.initPromise = null; + throw error; + }); + } + + await this.initPromise; + } + + isWithinQuota(apiKeyId: string): boolean { + const rules = this.rulesCache.get(apiKeyId); + if (!rules || rules.length === 0) { + return true; + } + + const entries = this.cache.get(apiKeyId); + if (!entries || entries.length === 0) { + return true; + } + + return entries.every((entry) => entry.currentSpending < entry.limit); + } + + adjustSpending(apiKeyId: string, delta: number): void { + if (!Number.isFinite(delta) || delta === 0) { + return; + } + + const entries = this.cache.get(apiKeyId); + if (!entries) { + return; + } + + for (const entry of entries) { + const nextSpending = entry.currentSpending + delta; + entry.currentSpending = nextSpending > 0 ? Number(nextSpending.toFixed(10)) : 0; + } + } + + getQuotaStatus(apiKeyId: string): ApiKeyQuotaStatus | null { + const rules = this.rulesCache.get(apiKeyId); + if (!rules || rules.length === 0) { + return null; + } + + const entries = this.cache.get(apiKeyId); + const now = new Date(); + let isExceeded = false; + + const statuses: RuleStatus[] = rules.map((rule) => { + const entry = entries?.find( + (candidate) => + candidate.periodType === rule.period_type && + candidate.periodHours === (rule.period_hours ?? null) + ); + const currentSpending = entry?.currentSpending ?? 0; + const percentUsed = rule.limit > 0 ? Math.min(999, (currentSpending / rule.limit) * 100) : 0; + const ruleExceeded = currentSpending >= rule.limit; + if (ruleExceeded) { + isExceeded = true; + } + + return { + periodType: rule.period_type, + periodHours: rule.period_hours ?? null, + currentSpending, + spendingLimit: rule.limit, + percentUsed: Number(percentUsed.toFixed(1)), + isExceeded: ruleExceeded, + resetsAt: getResetsAtForRule(rule, now), + estimatedRecoveryAt: null, + }; + }); + + return { + apiKeyId, + apiKeyName: this.nameCache.get(apiKeyId) ?? apiKeyId, + rules: statuses, + isExceeded, + }; + } + + async syncFromDb(): Promise { + const quotaKeys = await db.query.apiKeys.findMany({ + where: isNotNull(apiKeys.spendingRules), + columns: { + id: true, + name: true, + spendingRules: true, + }, + }); + + const activeIds = new Set(); + for (const key of quotaKeys) { + const rules = extractSpendingRulesFromApiKey(key); + if (rules.length === 0) { + continue; + } + + this.rulesCache.set(key.id, rules); + this.nameCache.set(key.id, key.name); + activeIds.add(key.id); + } + + for (const id of this.rulesCache.keys()) { + if (!activeIds.has(id)) { + this.rulesCache.delete(id); + this.cache.delete(id); + this.nameCache.delete(id); + } + } + + const now = new Date(); + for (const key of quotaKeys) { + const rules = this.rulesCache.get(key.id); + if (!rules) { + continue; + } + + const entries: RuleCacheEntry[] = []; + for (const rule of rules) { + const periodStart = getPeriodStartForRule(rule, now); + const [row] = await db + .select({ totalCost: sum(requestBillingSnapshots.finalCost) }) + .from(requestBillingSnapshots) + .where( + and( + eq(requestBillingSnapshots.apiKeyId, key.id), + eq(requestBillingSnapshots.billingStatus, "billed"), + gte(requestBillingSnapshots.billedAt, periodStart) + ) + ); + + const totalCost = row?.totalCost ? Number(row.totalCost) : 0; + entries.push({ + periodType: rule.period_type, + periodHours: rule.period_hours ?? null, + limit: rule.limit, + currentSpending: Number.isNaN(totalCost) ? 0 : totalCost, + lastSyncedAt: now, + }); + } + + this.cache.set(key.id, entries); + } + } + + async syncApiKeyFromDb( + apiKeyId: string, + apiKeyName: string, + spendingRules: SpendingRule[] | null + ): Promise { + const rules = normalizeSpendingRules(spendingRules); + if (!rules || rules.length === 0) { + this.rulesCache.delete(apiKeyId); + this.cache.delete(apiKeyId); + this.nameCache.delete(apiKeyId); + return; + } + + this.rulesCache.set(apiKeyId, rules); + this.nameCache.set(apiKeyId, apiKeyName); + + const now = new Date(); + const entries: RuleCacheEntry[] = []; + for (const rule of rules) { + const periodStart = getPeriodStartForRule(rule, now); + const [row] = await db + .select({ totalCost: sum(requestBillingSnapshots.finalCost) }) + .from(requestBillingSnapshots) + .where( + and( + eq(requestBillingSnapshots.apiKeyId, apiKeyId), + eq(requestBillingSnapshots.billingStatus, "billed"), + gte(requestBillingSnapshots.billedAt, periodStart) + ) + ); + + const totalCost = row?.totalCost ? Number(row.totalCost) : 0; + entries.push({ + periodType: rule.period_type, + periodHours: rule.period_hours ?? null, + limit: rule.limit, + currentSpending: Number.isNaN(totalCost) ? 0 : totalCost, + lastSyncedAt: now, + }); + } + + this.cache.set(apiKeyId, entries); + } + + async estimateRecoveryTime(apiKeyId: string, rule: SpendingRule): Promise { + if (rule.period_type !== "rolling") { + return null; + } + + const entries = this.cache.get(apiKeyId); + const entry = entries?.find( + (candidate) => + candidate.periodType === "rolling" && candidate.periodHours === (rule.period_hours ?? null) + ); + if (!entry || entry.currentSpending < rule.limit) { + return null; + } + + const configuredHours = rule.period_hours ?? 24; + const windowHours = + Number.isFinite(configuredHours) && configuredHours > 0 ? Math.floor(configuredHours) : 24; + const now = new Date(); + const windowStart = getRollingWindowStart(windowHours, now); + const excessAmount = entry.currentSpending - rule.limit; + const scanHours = Math.min(windowHours, 8760); + if (scanHours <= 0) { + return null; + } + + const rows = await db + .select({ + billedAt: requestBillingSnapshots.billedAt, + finalCost: requestBillingSnapshots.finalCost, + }) + .from(requestBillingSnapshots) + .where( + and( + eq(requestBillingSnapshots.apiKeyId, apiKeyId), + eq(requestBillingSnapshots.billingStatus, "billed"), + gte(requestBillingSnapshots.billedAt, windowStart), + lt(requestBillingSnapshots.billedAt, now) + ) + ); + + const windowStartMs = windowStart.getTime(); + const hourlySlideOutCosts = new Array(scanHours).fill(0); + for (const row of rows) { + const billedAtValue = row?.billedAt; + if (!billedAtValue) { + continue; + } + + const billedAtMs = + billedAtValue instanceof Date ? billedAtValue.getTime() : new Date(billedAtValue).getTime(); + if (!Number.isFinite(billedAtMs) || billedAtMs < windowStartMs) { + continue; + } + + const cost = Number(row?.finalCost ?? 0); + if (!Number.isFinite(cost) || cost <= 0) { + continue; + } + + const hourIndex = Math.floor((billedAtMs - windowStartMs) / HOUR_MS); + if (hourIndex < 0 || hourIndex >= scanHours) { + continue; + } + + hourlySlideOutCosts[hourIndex] += cost; + } + + let cumulativeSlideOut = 0; + for (let hourIndex = 0; hourIndex < scanHours; hourIndex += 1) { + cumulativeSlideOut += hourlySlideOutCosts[hourIndex] ?? 0; + if (cumulativeSlideOut > excessAmount) { + const sliceEnd = new Date(windowStartMs + (hourIndex + 1) * HOUR_MS); + return new Date(sliceEnd.getTime() + windowHours * HOUR_MS); + } + } + + return null; + } + + reset(): void { + this.stopSyncTimer(); + this.cache.clear(); + this.rulesCache.clear(); + this.nameCache.clear(); + this.initialized = false; + this.initPromise = null; + } + + setRules(apiKeyId: string, rules: SpendingRule[], name?: string): void { + const normalizedRules = normalizeSpendingRules(rules) ?? []; + this.rulesCache.set(apiKeyId, normalizedRules); + if (name) { + this.nameCache.set(apiKeyId, name); + } + this.cache.set( + apiKeyId, + normalizedRules.map((rule) => ({ + periodType: rule.period_type, + periodHours: rule.period_hours ?? null, + limit: rule.limit, + currentSpending: 0, + lastSyncedAt: new Date(), + })) + ); + } + + getCacheEntries(apiKeyId: string): RuleCacheEntry[] | undefined { + return this.cache.get(apiKeyId); + } + + private startSyncTimer(): void { + if (this.syncTimer) { + return; + } + this.syncTimer = setInterval(() => { + this.tickSync().catch(() => undefined); + }, URGENT_SYNC_INTERVAL_MS); + } + + private stopSyncTimer(): void { + if (!this.syncTimer) { + return; + } + clearInterval(this.syncTimer); + this.syncTimer = null; + } + + private async tickSync(): Promise { + if (this.shouldSyncNow()) { + await this.syncFromDb(); + } + } + + private shouldSyncNow(): boolean { + const now = Date.now(); + for (const [apiKeyId, entries] of this.cache) { + if (!this.rulesCache.has(apiKeyId)) { + continue; + } + + for (const entry of entries) { + const elapsed = now - entry.lastSyncedAt.getTime(); + const percentUsed = (entry.currentSpending / entry.limit) * 100; + + if (percentUsed >= URGENT_THRESHOLD_PERCENT || entry.currentSpending >= entry.limit) { + if (elapsed >= URGENT_SYNC_INTERVAL_MS) { + return true; + } + } else if (elapsed >= NORMAL_SYNC_INTERVAL_MS) { + return true; + } + } + } + + return false; + } +} + +export const apiKeyQuotaTracker = new ApiKeyQuotaTracker(); diff --git a/src/lib/services/billing-cost-service.ts b/src/lib/services/billing-cost-service.ts index 3f585b1..1b2c843 100644 --- a/src/lib/services/billing-cost-service.ts +++ b/src/lib/services/billing-cost-service.ts @@ -7,6 +7,7 @@ import { } from "@/lib/services/billing-price-service"; import { getProviderTypeForModel } from "@/lib/services/model-router"; import { quotaTracker } from "@/lib/services/upstream-quota-tracker"; +import { apiKeyQuotaTracker } from "@/lib/services/api-key-quota-tracker"; export type UnbillableReason = | "model_missing" @@ -47,6 +48,7 @@ interface NormalizedBillingUsage { } interface ExistingBillingSnapshot { + apiKeyId: string | null; upstreamId: string | null; billingStatus: string; finalCost: number | null; @@ -60,10 +62,12 @@ function parseSnapshotCost(value: number | null): number { function applyQuotaDeltaAfterSnapshotUpsert( previousSnapshot: ExistingBillingSnapshot | null, nextBillingStatus: "billed" | "unbilled", + nextApiKeyId: string | null, nextUpstreamId: string | null, nextFinalCost: number | null ): void { const deltaByUpstream = new Map(); + const deltaByApiKey = new Map(); if (previousSnapshot?.billingStatus === "billed" && previousSnapshot.upstreamId) { const previousCost = parseSnapshotCost(previousSnapshot.finalCost); @@ -74,6 +78,15 @@ function applyQuotaDeltaAfterSnapshotUpsert( ); } } + if (previousSnapshot?.billingStatus === "billed" && previousSnapshot.apiKeyId) { + const previousCost = parseSnapshotCost(previousSnapshot.finalCost); + if (previousCost > 0) { + deltaByApiKey.set( + previousSnapshot.apiKeyId, + (deltaByApiKey.get(previousSnapshot.apiKeyId) ?? 0) - previousCost + ); + } + } const normalizedFinalCost = parseSnapshotCost(nextFinalCost); if (nextBillingStatus === "billed" && nextUpstreamId && normalizedFinalCost > 0) { @@ -82,6 +95,9 @@ function applyQuotaDeltaAfterSnapshotUpsert( (deltaByUpstream.get(nextUpstreamId) ?? 0) + normalizedFinalCost ); } + if (nextBillingStatus === "billed" && nextApiKeyId && normalizedFinalCost > 0) { + deltaByApiKey.set(nextApiKeyId, (deltaByApiKey.get(nextApiKeyId) ?? 0) + normalizedFinalCost); + } for (const [upstreamId, delta] of deltaByUpstream) { const normalizedDelta = Number(delta.toFixed(10)); @@ -89,6 +105,12 @@ function applyQuotaDeltaAfterSnapshotUpsert( quotaTracker.adjustSpending(upstreamId, normalizedDelta); } } + for (const [apiKeyId, delta] of deltaByApiKey) { + const normalizedDelta = Number(delta.toFixed(10)); + if (normalizedDelta !== 0) { + apiKeyQuotaTracker.adjustSpending(apiKeyId, normalizedDelta); + } + } } function normalizeUsage(usage: BillingUsageInput): NormalizedBillingUsage { @@ -113,6 +135,7 @@ async function upsertUnbilledSnapshot( const previousSnapshot = await db.query.requestBillingSnapshots.findFirst({ where: eq(requestBillingSnapshots.requestLogId, input.requestLogId), columns: { + apiKeyId: true, upstreamId: true, billingStatus: true, finalCost: true, @@ -185,7 +208,13 @@ async function upsertUnbilledSnapshot( }, }); - applyQuotaDeltaAfterSnapshotUpsert(previousSnapshot ?? null, "unbilled", input.upstreamId, null); + applyQuotaDeltaAfterSnapshotUpsert( + previousSnapshot ?? null, + "unbilled", + input.apiKeyId, + input.upstreamId, + null + ); return { status: "unbilled", @@ -293,6 +322,7 @@ export async function calculateAndPersistRequestBillingSnapshot( const previousSnapshot = await db.query.requestBillingSnapshots.findFirst({ where: eq(requestBillingSnapshots.requestLogId, input.requestLogId), columns: { + apiKeyId: true, upstreamId: true, billingStatus: true, finalCost: true, @@ -368,6 +398,7 @@ export async function calculateAndPersistRequestBillingSnapshot( applyQuotaDeltaAfterSnapshotUpsert( previousSnapshot ?? null, "billed", + input.apiKeyId, input.upstreamId, cost.finalCost ); diff --git a/src/lib/services/key-manager.ts b/src/lib/services/key-manager.ts index b10b187..f63a6dd 100644 --- a/src/lib/services/key-manager.ts +++ b/src/lib/services/key-manager.ts @@ -4,6 +4,9 @@ import { db, apiKeys, apiKeyUpstreams, upstreams, type ApiKey } from "../db"; import { hashApiKey, verifyApiKey } from "../utils/auth"; import { encrypt, decrypt, EncryptionError } from "../utils/encryption"; import { createLogger } from "../utils/logger"; +import { apiKeyQuotaTracker } from "@/lib/services/api-key-quota-tracker"; +import { parseSpendingRules } from "@/lib/services/spending-rules"; +import type { SpendingRule } from "@/lib/services/upstream-quota-tracker"; const log = createLogger("key-manager"); @@ -35,6 +38,18 @@ export interface ApiKeyCreateInput { accessMode?: ApiKeyAccessMode; description?: string | null; expiresAt?: Date | null; + spendingRules?: SpendingRule[] | null; +} + +export interface ApiKeySpendingRuleStatus { + periodType: SpendingRule["period_type"]; + periodHours: number | null; + currentSpending: number; + spendingLimit: number; + percentUsed: number; + isExceeded: boolean; + resetsAt: Date | null; + estimatedRecoveryAt: Date | null; } export interface ApiKeyCreateResult { @@ -45,6 +60,9 @@ export interface ApiKeyCreateResult { description: string | null; accessMode: ApiKeyAccessMode; upstreamIds: string[]; + spendingRules: SpendingRule[] | null; + spendingRuleStatuses: ApiKeySpendingRuleStatus[]; + isQuotaExceeded: boolean; isActive: boolean; expiresAt: Date | null; createdAt: Date; @@ -58,6 +76,9 @@ export interface ApiKeyListItem { description: string | null; accessMode: ApiKeyAccessMode; upstreamIds: string[]; + spendingRules: SpendingRule[] | null; + spendingRuleStatuses: ApiKeySpendingRuleStatus[]; + isQuotaExceeded: boolean; isActive: boolean; expiresAt: Date | null; createdAt: Date; @@ -86,6 +107,7 @@ export interface ApiKeyUpdateInput { accessMode?: ApiKeyAccessMode; expiresAt?: Date | null; upstreamIds?: string[]; + spendingRules?: SpendingRule[] | null; } function normalizeAccessMode( @@ -99,6 +121,101 @@ function normalizeAccessMode( return upstreamIds.length > 0 ? "restricted" : "unrestricted"; } +async function resolveSpendingRuleStatuses( + apiKeyId: string, + spendingRules: SpendingRule[] | null +): Promise<{ + spendingRuleStatuses: ApiKeySpendingRuleStatus[]; + isQuotaExceeded: boolean; +}> { + if (!spendingRules || spendingRules.length === 0) { + return { + spendingRuleStatuses: [], + isQuotaExceeded: false, + }; + } + + await apiKeyQuotaTracker.initialize(); + const status = apiKeyQuotaTracker.getQuotaStatus(apiKeyId); + if (!status) { + return { + spendingRuleStatuses: spendingRules.map((rule) => ({ + periodType: rule.period_type, + periodHours: rule.period_hours ?? null, + currentSpending: 0, + spendingLimit: rule.limit, + percentUsed: 0, + isExceeded: false, + resetsAt: null, + estimatedRecoveryAt: null, + })), + isQuotaExceeded: false, + }; + } + + const spendingRuleStatuses = await Promise.all( + status.rules.map(async (rule) => ({ + periodType: rule.periodType, + periodHours: rule.periodHours, + currentSpending: rule.currentSpending, + spendingLimit: rule.spendingLimit, + percentUsed: rule.percentUsed, + isExceeded: rule.isExceeded, + resetsAt: rule.resetsAt, + estimatedRecoveryAt: + rule.periodType === "rolling" && rule.isExceeded + ? await apiKeyQuotaTracker.estimateRecoveryTime(apiKeyId, { + period_type: "rolling", + limit: rule.spendingLimit, + ...(rule.periodHours != null ? { period_hours: rule.periodHours } : {}), + }) + : null, + })) + ); + + return { + spendingRuleStatuses, + isQuotaExceeded: status.isExceeded, + }; +} + +async function buildApiKeyListItem( + key: Pick< + ApiKey, + | "id" + | "keyPrefix" + | "name" + | "description" + | "accessMode" + | "spendingRules" + | "isActive" + | "expiresAt" + | "createdAt" + | "updatedAt" + >, + upstreamIds: string[] +): Promise { + const accessMode = normalizeAccessMode(key.accessMode, upstreamIds); + const spendingRules = parseSpendingRules(key.spendingRules); + const quotaState = await resolveSpendingRuleStatuses(key.id, spendingRules); + + return { + id: key.id, + keyPrefix: key.keyPrefix, + name: key.name, + description: key.description, + accessMode, + upstreamIds: accessMode === "restricted" ? upstreamIds : [], + spendingRules, + spendingRuleStatuses: quotaState.spendingRuleStatuses, + isQuotaExceeded: quotaState.isQuotaExceeded, + isActive: key.isActive, + expiresAt: key.expiresAt, + createdAt: key.createdAt, + updatedAt: key.updatedAt, + }; +} + /** * Generate a random API key using the `sk-auto-[base64-random]` format. */ @@ -114,6 +231,7 @@ export async function createApiKey(input: ApiKeyCreateInput): Promise { } await db.delete(apiKeys).where(eq(apiKeys.id, keyId)); + await apiKeyQuotaTracker.syncApiKeyFromDb(keyId, existing.name, null); log.info({ keyPrefix: existing.keyPrefix, name: existing.name }, "deleted API key"); } @@ -229,6 +361,7 @@ export async function listApiKeys( limit: pageSize, offset, }); + await apiKeyQuotaTracker.initialize(); // For each API key, fetch authorized upstream IDs const items: ApiKeyListItem[] = await Promise.all( @@ -237,20 +370,7 @@ export async function listApiKeys( where: eq(apiKeyUpstreams.apiKeyId, key.id), }); const upstreamIds = upstreamLinks.map((link) => link.upstreamId); - const accessMode = normalizeAccessMode(key.accessMode, upstreamIds); - - return { - id: key.id, - keyPrefix: key.keyPrefix, - name: key.name, - description: key.description, - accessMode, - upstreamIds: accessMode === "restricted" ? upstreamIds : [], - isActive: key.isActive, - expiresAt: key.expiresAt, - createdAt: key.createdAt, - updatedAt: key.updatedAt, - }; + return buildApiKeyListItem(key, upstreamIds); }) ); @@ -318,20 +438,8 @@ export async function getApiKeyById(keyId: string): Promise link.upstreamId); - const accessMode = normalizeAccessMode(apiKey.accessMode, upstreamIds); - - return { - id: apiKey.id, - keyPrefix: apiKey.keyPrefix, - name: apiKey.name, - description: apiKey.description, - accessMode, - upstreamIds: accessMode === "restricted" ? upstreamIds : [], - isActive: apiKey.isActive, - expiresAt: apiKey.expiresAt, - createdAt: apiKey.createdAt, - updatedAt: apiKey.updatedAt, - }; + await apiKeyQuotaTracker.initialize(); + return buildApiKeyListItem(apiKey, upstreamIds); } /** @@ -374,8 +482,10 @@ export async function updateApiKey( ): Promise { const { name, description, isActive, accessMode, expiresAt, upstreamIds } = input; const now = new Date(); + const parsedSpendingRules = + input.spendingRules !== undefined ? parseSpendingRules(input.spendingRules) : undefined; - return db.transaction(async (tx) => { + const updatedResult = await db.transaction(async (tx) => { // Check if key exists const existing = await tx.query.apiKeys.findFirst({ where: eq(apiKeys.id, keyId), @@ -431,6 +541,9 @@ export async function updateApiKey( isActive: boolean; accessMode: ApiKeyAccessMode; expiresAt: Date | null; + spendingRules: + | { period_type: "daily" | "monthly" | "rolling"; limit: number; period_hours?: number }[] + | null; updatedAt: Date; }> = { updatedAt: now }; @@ -449,6 +562,9 @@ export async function updateApiKey( if (expiresAt !== undefined) { updateData.expiresAt = expiresAt; } + if (parsedSpendingRules !== undefined) { + updateData.spendingRules = parsedSpendingRules; + } // Update the API key record const [updatedKey] = await tx @@ -492,7 +608,17 @@ export async function updateApiKey( } } - log.info({ keyPrefix: updatedKey.keyPrefix, name: updatedKey.name }, "updated API key"); + log.info( + { + keyPrefix: updatedKey.keyPrefix, + name: updatedKey.name, + spendingRules: + parsedSpendingRules !== undefined + ? (parsedSpendingRules?.length ?? 0) + : (existing.spendingRules?.length ?? 0), + }, + "updated API key" + ); const resolvedAccessMode = normalizeAccessMode(updatedKey.accessMode, currentUpstreamIds); @@ -503,10 +629,33 @@ export async function updateApiKey( description: updatedKey.description, accessMode: resolvedAccessMode, upstreamIds: resolvedAccessMode === "restricted" ? currentUpstreamIds : [], + spendingRules: parseSpendingRules(updatedKey.spendingRules), isActive: updatedKey.isActive, expiresAt: updatedKey.expiresAt, createdAt: updatedKey.createdAt, updatedAt: updatedKey.updatedAt, }; }); + + await apiKeyQuotaTracker.syncApiKeyFromDb( + updatedResult.id, + updatedResult.name, + updatedResult.spendingRules + ); + + return buildApiKeyListItem( + { + id: updatedResult.id, + keyPrefix: updatedResult.keyPrefix, + name: updatedResult.name, + description: updatedResult.description, + accessMode: updatedResult.accessMode, + spendingRules: updatedResult.spendingRules, + isActive: updatedResult.isActive, + expiresAt: updatedResult.expiresAt, + createdAt: updatedResult.createdAt, + updatedAt: updatedResult.updatedAt, + }, + updatedResult.upstreamIds + ); } diff --git a/src/lib/services/spending-rules.ts b/src/lib/services/spending-rules.ts new file mode 100644 index 0000000..99ded99 --- /dev/null +++ b/src/lib/services/spending-rules.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import type { SpendingRule } from "@/lib/services/upstream-quota-tracker"; + +export const MAX_SPENDING_RULE_PERIOD_HOURS = 8760; + +export const spendingRuleSchema = z.object({ + period_type: z.enum(["daily", "monthly", "rolling"]), + limit: z.number().positive(), + period_hours: z.number().int().min(1).max(MAX_SPENDING_RULE_PERIOD_HOURS).optional(), +}); + +export const nullableSpendingRulesSchema = z + .array(spendingRuleSchema) + .nullable() + .optional() + .superRefine((rules, ctx) => { + if (!rules) { + return; + } + + for (const [index, rule] of rules.entries()) { + if (rule.period_type === "rolling" && rule.period_hours == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [index, "period_hours"], + message: "period_hours is required when period_type is 'rolling'", + }); + } + } + }); + +/** + * Normalize persisted or submitted spending rules into the canonical runtime shape. + */ +export function normalizeSpendingRules( + rules: SpendingRule[] | null | undefined +): SpendingRule[] | null { + if (!rules || rules.length === 0) { + return null; + } + + return rules.map((rule) => { + if (rule.period_type === "rolling") { + return { + period_type: "rolling", + limit: Number(rule.limit), + period_hours: rule.period_hours ?? 24, + }; + } + + return { + period_type: rule.period_type, + limit: Number(rule.limit), + }; + }); +} + +/** + * Parse unknown input with shared validation and return normalized spending rules. + */ +export function parseSpendingRules(input: unknown): SpendingRule[] | null { + const parsed = nullableSpendingRulesSchema.parse(input); + return normalizeSpendingRules(parsed ?? null); +} diff --git a/src/lib/services/unified-error.ts b/src/lib/services/unified-error.ts index f5b1f15..0d7beac 100644 --- a/src/lib/services/unified-error.ts +++ b/src/lib/services/unified-error.ts @@ -14,6 +14,7 @@ export type UnifiedErrorCode = | "ALL_UPSTREAMS_UNAVAILABLE" | "NO_AUTHORIZED_UPSTREAMS" | "NO_UPSTREAMS_CONFIGURED" + | "API_KEY_QUOTA_EXCEEDED" | "SERVICE_UNAVAILABLE" | "REQUEST_TIMEOUT" | "CLIENT_DISCONNECTED" @@ -28,6 +29,7 @@ export type UnifiedErrorType = "service_unavailable" | "timeout" | "client_error * Optional error reason details for better diagnostics. */ export type UnifiedErrorReason = + | "API_KEY_QUOTA_EXCEEDED" | "NO_AUTHORIZED_UPSTREAMS" | "CLIENT_DISCONNECTED" | "NO_HEALTHY_CANDIDATES" @@ -65,6 +67,7 @@ const ERROR_MESSAGES: Record = { ALL_UPSTREAMS_UNAVAILABLE: "服务暂时不可用,请稍后重试", NO_AUTHORIZED_UPSTREAMS: "当前密钥未绑定可用上游,请先完成授权配置", NO_UPSTREAMS_CONFIGURED: "服务暂时不可用,请稍后重试", + API_KEY_QUOTA_EXCEEDED: "当前密钥已达到消费限额,请稍后重试", SERVICE_UNAVAILABLE: "服务暂时不可用,请稍后重试", REQUEST_TIMEOUT: "请求超时,请稍后重试", CLIENT_DISCONNECTED: "客户端已断开连接", @@ -78,6 +81,7 @@ const ERROR_TYPES: Record = { ALL_UPSTREAMS_UNAVAILABLE: "service_unavailable", NO_AUTHORIZED_UPSTREAMS: "client_error", NO_UPSTREAMS_CONFIGURED: "service_unavailable", + API_KEY_QUOTA_EXCEEDED: "client_error", SERVICE_UNAVAILABLE: "service_unavailable", REQUEST_TIMEOUT: "timeout", CLIENT_DISCONNECTED: "client_error", @@ -91,6 +95,7 @@ const HTTP_STATUS_CODES: Record = { ALL_UPSTREAMS_UNAVAILABLE: 503, NO_AUTHORIZED_UPSTREAMS: 403, NO_UPSTREAMS_CONFIGURED: 503, + API_KEY_QUOTA_EXCEEDED: 429, SERVICE_UNAVAILABLE: 503, REQUEST_TIMEOUT: 504, CLIENT_DISCONNECTED: 499, // Client Closed Request (nginx convention) diff --git a/src/lib/utils/api-transformers.ts b/src/lib/utils/api-transformers.ts index f83f587..cacf6e0 100644 --- a/src/lib/utils/api-transformers.ts +++ b/src/lib/utils/api-transformers.ts @@ -188,6 +188,20 @@ export interface ApiKeyApiResponse { description: string | null; access_mode: "unrestricted" | "restricted"; upstream_ids: string[]; + spending_rules: + | { period_type: "daily" | "monthly" | "rolling"; limit: number; period_hours?: number }[] + | null; + spending_rule_statuses: { + period_type: "daily" | "monthly" | "rolling"; + period_hours: number | null; + current_spending: number; + spending_limit: number; + percent_used: number; + is_exceeded: boolean; + resets_at: string | null; + estimated_recovery_at: string | null; + }[]; + is_quota_exceeded: boolean; is_active: boolean; expires_at: string | null; created_at: string; @@ -227,6 +241,18 @@ export function transformApiKeyToApi(apiKey: ApiKeyListItem): ApiKeyApiResponse description: apiKey.description, access_mode: apiKey.accessMode, upstream_ids: apiKey.upstreamIds, + spending_rules: apiKey.spendingRules, + spending_rule_statuses: apiKey.spendingRuleStatuses.map((rule) => ({ + period_type: rule.periodType, + period_hours: rule.periodHours, + current_spending: rule.currentSpending, + spending_limit: rule.spendingLimit, + percent_used: rule.percentUsed, + is_exceeded: rule.isExceeded, + resets_at: toISOStringOrNull(rule.resetsAt), + estimated_recovery_at: toISOStringOrNull(rule.estimatedRecoveryAt), + })), + is_quota_exceeded: apiKey.isQuotaExceeded, is_active: apiKey.isActive, expires_at: toISOStringOrNull(apiKey.expiresAt), created_at: apiKey.createdAt.toISOString(), @@ -247,6 +273,18 @@ export function transformApiKeyCreateToApi(result: ApiKeyCreateResult): ApiKeyCr description: result.description, access_mode: result.accessMode, upstream_ids: result.upstreamIds, + spending_rules: result.spendingRules, + spending_rule_statuses: result.spendingRuleStatuses.map((rule) => ({ + period_type: rule.periodType, + period_hours: rule.periodHours, + current_spending: rule.currentSpending, + spending_limit: rule.spendingLimit, + percent_used: rule.percentUsed, + is_exceeded: rule.isExceeded, + resets_at: toISOStringOrNull(rule.resetsAt), + estimated_recovery_at: toISOStringOrNull(rule.estimatedRecoveryAt), + })), + is_quota_exceeded: result.isQuotaExceeded, is_active: result.isActive, expires_at: toISOStringOrNull(result.expiresAt), created_at: result.createdAt.toISOString(), diff --git a/src/messages/en.json b/src/messages/en.json index 65b1e24..4a302ec 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -170,6 +170,29 @@ "deselectFilteredUpstreams": "Deselect visible results", "filteredUpstreamsSelected": "{selected} of {total} visible upstreams selected", "noMatchingUpstreams": "No upstreams match the current search", + "spendingRules": "Spending Rules", + "spendingRulesDesc": "Configure amount-based limits for this key. Any exceeded rule will block later requests.", + "spendingRulesEmpty": "No spending rules configured", + "addSpendingRule": "Add rule", + "spendingRuleLabel": "Rule {index}", + "quotaPeriodType": "Period Type", + "quotaPeriodType_daily": "Daily", + "quotaPeriodType_monthly": "Monthly", + "quotaPeriodType_rolling": "Rolling Window", + "quotaPeriodRollingWithHours": "Rolling {hours}h", + "quotaLimitUsd": "Limit Amount (USD)", + "quotaLimitPlaceholder": "e.g. 50", + "quotaLimitPositive": "Limit amount must be greater than 0", + "quotaPeriodHours": "Rolling Window Hours", + "quotaPeriodHoursPlaceholder": "e.g. 6", + "quotaPeriodHoursRequired": "Rolling rules require period hours", + "quotaStatusPending": "Quota status is initializing. Refresh in a moment.", + "quotaExceeded": "Exceeded", + "quotaWithinLimit": "Available", + "quotaPercentUsed": "{percent}% used", + "quotaResetTime": "Resets {time}", + "quotaRecoveryTime": "Recovers {time}", + "quotaRecoveryPending": "Recovery time is being calculated", "expirationDate": "Expiration Date (Optional)", "expirationDateDesc": "Leave empty for no expiration", "selectDate": "Select date", diff --git a/src/messages/zh-CN.json b/src/messages/zh-CN.json index 21f2208..5fc5271 100644 --- a/src/messages/zh-CN.json +++ b/src/messages/zh-CN.json @@ -170,6 +170,29 @@ "deselectFilteredUpstreams": "取消当前结果", "filteredUpstreamsSelected": "当前结果已选 {selected} / {total}", "noMatchingUpstreams": "没有匹配当前搜索的上游", + "spendingRules": "消费限额规则", + "spendingRulesDesc": "为此密钥配置金额限额。任一规则达到上限后,后续请求会被拒绝。", + "spendingRulesEmpty": "当前未配置消费限额规则", + "addSpendingRule": "添加规则", + "spendingRuleLabel": "规则 {index}", + "quotaPeriodType": "周期类型", + "quotaPeriodType_daily": "每日", + "quotaPeriodType_monthly": "每月", + "quotaPeriodType_rolling": "滚动窗口", + "quotaPeriodRollingWithHours": "滚动 {hours} 小时", + "quotaLimitUsd": "限额金额(USD)", + "quotaLimitPlaceholder": "例如 50", + "quotaLimitPositive": "限额金额必须大于 0", + "quotaPeriodHours": "滚动窗口时长(小时)", + "quotaPeriodHoursPlaceholder": "例如 6", + "quotaPeriodHoursRequired": "滚动窗口规则必须填写小时数", + "quotaStatusPending": "额度状态正在初始化,请稍后刷新", + "quotaExceeded": "已超额", + "quotaWithinLimit": "可用", + "quotaPercentUsed": "已用 {percent}%", + "quotaResetTime": "重置时间:{time}", + "quotaRecoveryTime": "预计恢复:{time}", + "quotaRecoveryPending": "预计恢复时间计算中", "expirationDate": "过期时间(可选)", "expirationDateDesc": "不设置则永不过期", "selectDate": "选择日期", diff --git a/src/types/api.ts b/src/types/api.ts index 6a321c0..742082a 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -10,12 +10,30 @@ import type { RouteCapability, RouteMatchSource } from "@/lib/route-capabilities export type APIKeyAccessMode = "unrestricted" | "restricted"; +export interface APIKeySpendingRule { + period_type: "daily" | "monthly" | "rolling"; + limit: number; + period_hours?: number; +} + +export interface APIKeySpendingRuleStatus { + period_type: "daily" | "monthly" | "rolling"; + period_hours: number | null; + current_spending: number; + spending_limit: number; + percent_used: number; + is_exceeded: boolean; + resets_at: string | null; + estimated_recovery_at: string | null; +} + export interface APIKeyCreate { name: string; description?: string | null; access_mode?: APIKeyAccessMode; upstream_ids: string[]; // UUID[] expires_at?: string | null; // ISO 8601 date string + spending_rules?: APIKeySpendingRule[] | null; } export interface APIKeyUpdate { @@ -25,6 +43,7 @@ export interface APIKeyUpdate { access_mode?: APIKeyAccessMode; expires_at?: string | null; // ISO 8601 date string upstream_ids?: string[]; // UUID[] + spending_rules?: APIKeySpendingRule[] | null; } export interface APIKeyResponse { @@ -34,6 +53,9 @@ export interface APIKeyResponse { description: string | null; access_mode: APIKeyAccessMode; upstream_ids: string[]; // UUID[] + spending_rules: APIKeySpendingRule[] | null; + spending_rule_statuses: APIKeySpendingRuleStatus[]; + is_quota_exceeded: boolean; is_active: boolean; expires_at: string | null; // ISO 8601 date string created_at: string; // ISO 8601 date string diff --git a/tests/components/create-key-dialog.test.tsx b/tests/components/create-key-dialog.test.tsx index 0d44b01..05e7ce8 100644 --- a/tests/components/create-key-dialog.test.tsx +++ b/tests/components/create-key-dialog.test.tsx @@ -163,6 +163,9 @@ describe("CreateKeyDialog", () => { description: null, access_mode: "restricted", upstream_ids: ["upstream-2"], + spending_rules: null, + spending_rule_statuses: [], + is_quota_exceeded: false, is_active: true, expires_at: null, created_at: "2024-01-01T00:00:00Z", @@ -196,6 +199,7 @@ describe("CreateKeyDialog", () => { access_mode: "restricted", upstream_ids: ["upstream-2"], expires_at: null, + spending_rules: null, }); }); }); @@ -210,6 +214,48 @@ describe("CreateKeyDialog", () => { expect(screen.getByText("create")).toBeInTheDocument(); }); }); + + it("allows adding a spending rule and submits it", async () => { + mockCreateMutateAsync.mockResolvedValueOnce({ + id: "key-2", + key_value: "sk-auto-quota", + key_prefix: "sk-auto-quota", + name: "Quota Key", + description: null, + access_mode: "unrestricted", + upstream_ids: [], + spending_rules: [{ period_type: "daily", limit: 12.5 }], + spending_rule_statuses: [], + is_quota_exceeded: false, + is_active: true, + expires_at: null, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }); + + render(, { wrapper: Wrapper }); + + fireEvent.click(screen.getByText("createKey")); + fireEvent.click(screen.getByText("addSpendingRule")); + fireEvent.change(screen.getByPlaceholderText("keyNamePlaceholder"), { + target: { value: "Quota Key" }, + }); + fireEvent.change(screen.getByPlaceholderText("quotaLimitPlaceholder"), { + target: { value: "12.5" }, + }); + fireEvent.click(screen.getByText("create")); + + await waitFor(() => { + expect(mockCreateMutateAsync).toHaveBeenCalledWith({ + name: "Quota Key", + description: null, + access_mode: "unrestricted", + upstream_ids: [], + expires_at: null, + spending_rules: [{ period_type: "daily", limit: 12.5 }], + }); + }); + }); }); describe("Form Validation", () => { @@ -241,6 +287,9 @@ describe("CreateKeyDialog", () => { description: null, access_mode: "unrestricted", upstream_ids: [], + spending_rules: null, + spending_rule_statuses: [], + is_quota_exceeded: false, is_active: true, expires_at: null, created_at: "2024-01-01T00:00:00Z", @@ -267,6 +316,7 @@ describe("CreateKeyDialog", () => { access_mode: "unrestricted", upstream_ids: [], expires_at: null, + spending_rules: null, }); }); }); @@ -301,6 +351,9 @@ describe("CreateKeyDialog", () => { description: null, access_mode: "restricted", upstream_ids: ["upstream-1"], + spending_rules: null, + spending_rule_statuses: [], + is_quota_exceeded: false, is_active: true, expires_at: null, created_at: "2024-01-01T00:00:00Z", @@ -332,6 +385,7 @@ describe("CreateKeyDialog", () => { access_mode: "restricted", upstream_ids: ["upstream-1"], expires_at: null, + spending_rules: null, }); }); }); diff --git a/tests/components/edit-key-dialog.test.tsx b/tests/components/edit-key-dialog.test.tsx index 09edc5b..3635d04 100644 --- a/tests/components/edit-key-dialog.test.tsx +++ b/tests/components/edit-key-dialog.test.tsx @@ -73,6 +73,9 @@ describe("EditKeyDialog", () => { description: "Test description", access_mode: "restricted", upstream_ids: ["upstream-1", "upstream-2"], + spending_rules: null, + spending_rule_statuses: [], + is_quota_exceeded: false, is_active: true, expires_at: null, created_at: "2024-01-01T00:00:00Z", @@ -192,6 +195,7 @@ describe("EditKeyDialog", () => { data: expect.objectContaining({ access_mode: "restricted", upstream_ids: ["upstream-1", "upstream-2", "upstream-3"], + spending_rules: null, }), }); }); @@ -226,6 +230,46 @@ describe("EditKeyDialog", () => { expect(screen.getByText("cancel")).toBeInTheDocument(); expect(screen.getByText("save")).toBeInTheDocument(); }); + + it("renders existing spending rules and preserves them on submit", async () => { + mockUpdateMutateAsync.mockResolvedValueOnce({}); + + const quotaApiKey: APIKeyResponse = { + ...mockApiKey, + spending_rules: [{ period_type: "rolling", limit: 15, period_hours: 6 }], + spending_rule_statuses: [ + { + period_type: "rolling", + period_hours: 6, + current_spending: 8, + spending_limit: 15, + percent_used: 53.3, + is_exceeded: false, + resets_at: null, + estimated_recovery_at: null, + }, + ], + }; + + render(, { + wrapper: Wrapper, + }); + + expect(screen.getByText("spendingRuleLabel")).toBeInTheDocument(); + expect(screen.getByDisplayValue("15")).toBeInTheDocument(); + expect(screen.getByDisplayValue("6")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("save")); + + await waitFor(() => { + expect(mockUpdateMutateAsync).toHaveBeenCalledWith({ + id: "key-123", + data: expect.objectContaining({ + spending_rules: [{ period_type: "rolling", limit: 15, period_hours: 6 }], + }), + }); + }); + }); }); describe("Form Validation", () => { @@ -281,6 +325,7 @@ describe("EditKeyDialog", () => { data: expect.objectContaining({ access_mode: "unrestricted", upstream_ids: [], + spending_rules: null, }), }); }); @@ -311,6 +356,7 @@ describe("EditKeyDialog", () => { is_active: true, access_mode: "restricted", upstream_ids: ["upstream-1", "upstream-2"], + spending_rules: null, }), }); }); diff --git a/tests/components/keys-table.test.tsx b/tests/components/keys-table.test.tsx index c41766a..9dbe3fe 100644 --- a/tests/components/keys-table.test.tsx +++ b/tests/components/keys-table.test.tsx @@ -57,6 +57,9 @@ describe("KeysTable", () => { description: "Test description", access_mode: "restricted", upstream_ids: ["upstream-1", "upstream-2"], + spending_rules: null, + spending_rule_statuses: [], + is_quota_exceeded: false, is_active: true, expires_at: null, created_at: "2024-06-01T10:00:00Z", @@ -151,6 +154,39 @@ describe("KeysTable", () => { expect(screen.getByText("unrestrictedAccess")).toBeInTheDocument(); }); + + it("renders rule-level quota status when spending data is present", () => { + render( + + ); + + expect(screen.getByText("quotaPeriodRollingWithHours")).toBeInTheDocument(); + expect(screen.getAllByText("quotaExceeded").length).toBeGreaterThan(0); + expect(screen.getByText("quotaPercentUsed")).toBeInTheDocument(); + expect(screen.getByText("quotaRecoveryTime")).toBeInTheDocument(); + }); }); describe("Expiry Formatting", () => { diff --git a/tests/unit/api/admin/keys/route.test.ts b/tests/unit/api/admin/keys/route.test.ts new file mode 100644 index 0000000..c233287 --- /dev/null +++ b/tests/unit/api/admin/keys/route.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/utils/auth", () => ({ + validateAdminAuth: vi.fn((authHeader: string | null) => authHeader === "Bearer test-admin-token"), +})); + +const mockCreateApiKey = vi.fn(); +const mockUpdateApiKey = vi.fn(); + +class ApiKeyNotFoundError extends Error {} + +vi.mock("@/lib/services/key-manager", () => ({ + createApiKey: (...args: unknown[]) => mockCreateApiKey(...args), + updateApiKey: (...args: unknown[]) => mockUpdateApiKey(...args), + listApiKeys: vi.fn(), + getApiKeyById: vi.fn(), + deleteApiKey: vi.fn(), + ApiKeyNotFoundError, +})); + +const QUOTA_RULE = { period_type: "rolling" as const, limit: 15, period_hours: 6 }; +const KEY_ID = "11111111-1111-4111-8111-111111111111"; +const UPSTREAM_ID = "22222222-2222-4222-8222-222222222222"; + +function buildServiceApiKey(overrides?: Record) { + return { + id: KEY_ID, + keyPrefix: "sk-auto-test", + keyValue: "sk-auto-test-full", + name: "Quota Key", + description: "quota aware", + accessMode: "restricted" as const, + upstreamIds: [UPSTREAM_ID], + spendingRules: [QUOTA_RULE], + spendingRuleStatuses: [ + { + periodType: "rolling" as const, + periodHours: 6, + currentSpending: 8, + spendingLimit: 15, + percentUsed: 53.3, + isExceeded: false, + resetsAt: null, + estimatedRecoveryAt: new Date("2024-01-01T06:00:00.000Z"), + }, + ], + isQuotaExceeded: false, + isActive: true, + expiresAt: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + ...overrides, + }; +} + +describe("admin keys routes spending rules", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("POST /api/admin/keys should pass spending_rules into createApiKey and return quota fields", async () => { + const { POST } = await import("@/app/api/admin/keys/route"); + mockCreateApiKey.mockResolvedValueOnce(buildServiceApiKey()); + + const request = new NextRequest("http://localhost:3000/api/admin/keys", { + method: "POST", + headers: { + authorization: "Bearer test-admin-token", + "content-type": "application/json", + }, + body: JSON.stringify({ + name: "Quota Key", + access_mode: "restricted", + upstream_ids: [UPSTREAM_ID], + spending_rules: [QUOTA_RULE], + }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(mockCreateApiKey).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Quota Key", + accessMode: "restricted", + upstreamIds: [UPSTREAM_ID], + spendingRules: [QUOTA_RULE], + }) + ); + expect(data).toEqual( + expect.objectContaining({ + spending_rules: [QUOTA_RULE], + spending_rule_statuses: [ + expect.objectContaining({ + period_type: "rolling", + period_hours: 6, + current_spending: 8, + spending_limit: 15, + estimated_recovery_at: "2024-01-01T06:00:00.000Z", + }), + ], + is_quota_exceeded: false, + }) + ); + }); + + it("POST /api/admin/keys should reject rolling rules without period_hours", async () => { + const { POST } = await import("@/app/api/admin/keys/route"); + + const request = new NextRequest("http://localhost:3000/api/admin/keys", { + method: "POST", + headers: { + authorization: "Bearer test-admin-token", + "content-type": "application/json", + }, + body: JSON.stringify({ + name: "Broken Quota Key", + access_mode: "restricted", + upstream_ids: [UPSTREAM_ID], + spending_rules: [{ period_type: "rolling", limit: 15 }], + }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("period_hours"); + expect(mockCreateApiKey).not.toHaveBeenCalled(); + }); + + it("PUT /api/admin/keys/[id] should pass spending_rules into updateApiKey and return quota fields", async () => { + const { PUT } = await import("@/app/api/admin/keys/[id]/route"); + mockUpdateApiKey.mockResolvedValueOnce( + buildServiceApiKey({ + spendingRuleStatuses: [ + { + periodType: "rolling" as const, + periodHours: 6, + currentSpending: 15, + spendingLimit: 15, + percentUsed: 100, + isExceeded: true, + resetsAt: null, + estimatedRecoveryAt: new Date("2024-01-01T08:00:00.000Z"), + }, + ], + isQuotaExceeded: true, + }) + ); + + const request = new NextRequest(`http://localhost:3000/api/admin/keys/${KEY_ID}`, { + method: "PUT", + headers: { + authorization: "Bearer test-admin-token", + "content-type": "application/json", + }, + body: JSON.stringify({ + access_mode: "restricted", + upstream_ids: [UPSTREAM_ID], + spending_rules: [QUOTA_RULE], + }), + }); + + const response = await PUT(request, { + params: Promise.resolve({ id: KEY_ID }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(mockUpdateApiKey).toHaveBeenCalledWith( + KEY_ID, + expect.objectContaining({ + accessMode: "restricted", + upstreamIds: [UPSTREAM_ID], + spendingRules: [QUOTA_RULE], + }) + ); + expect(data).toEqual( + expect.objectContaining({ + spending_rules: [QUOTA_RULE], + spending_rule_statuses: [ + expect.objectContaining({ + current_spending: 15, + spending_limit: 15, + is_exceeded: true, + estimated_recovery_at: "2024-01-01T08:00:00.000Z", + }), + ], + is_quota_exceeded: true, + }) + ); + }); +}); diff --git a/tests/unit/api/proxy/route.test.ts b/tests/unit/api/proxy/route.test.ts index 6058cab..25f62bf 100644 --- a/tests/unit/api/proxy/route.test.ts +++ b/tests/unit/api/proxy/route.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; +const { mockApiKeyQuotaTracker } = vi.hoisted(() => ({ + mockApiKeyQuotaTracker: { + initialize: vi.fn(async () => {}), + getQuotaStatus: vi.fn(() => null), + }, +})); + vi.mock("@/lib/utils/auth", () => ({ extractApiKey: vi.fn((authHeader: string | null) => { if (!authHeader) return null; @@ -102,6 +109,10 @@ vi.mock("@/lib/services/billing-cost-service", () => ({ })), })); +vi.mock("@/lib/services/api-key-quota-tracker", () => ({ + apiKeyQuotaTracker: mockApiKeyQuotaTracker, +})); + // Mock load-balancer module vi.mock("@/lib/services/load-balancer", () => { const selectFromUpstreamCandidates = vi.fn(); @@ -312,6 +323,8 @@ describe("proxy route upstream selection", () => { beforeEach(async () => { vi.resetAllMocks(); vi.resetModules(); + mockApiKeyQuotaTracker.initialize.mockResolvedValue(undefined); + mockApiKeyQuotaTracker.getQuotaStatus.mockReturnValue(null); delete process.env.RECORDER_ENABLED; delete process.env.RECORDER_MODE; const routeModule = await import("@/app/api/proxy/v1/[...path]/route"); @@ -530,6 +543,77 @@ describe("proxy route upstream selection", () => { }); }); + it("should reject requests before upstream routing when API key quota is exceeded", async () => { + const { db } = await import("@/lib/db"); + const { forwardRequest } = await import("@/lib/services/proxy-client"); + const { logRequest } = await import("@/lib/services/request-logger"); + const { routeByModel } = await import("@/lib/services/model-router"); + + vi.mocked(db.query.apiKeys.findMany).mockResolvedValueOnce([ + { id: "key-1", keyHash: "hash-1", expiresAt: null, isActive: true }, + ]); + mockApiKeyQuotaTracker.getQuotaStatus.mockReturnValueOnce({ + apiKeyId: "key-1", + apiKeyName: "Quota Key", + isExceeded: true, + rules: [ + { + periodType: "daily", + periodHours: null, + currentSpending: 25, + spendingLimit: 25, + percentUsed: 100, + isExceeded: true, + resetsAt: new Date("2024-01-02T00:00:00Z"), + estimatedRecoveryAt: null, + }, + ], + }); + + const request = new NextRequest("http://localhost/api/proxy/v1/responses", { + method: "POST", + headers: { + authorization: "Bearer sk-test", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-5.2", + input: "hello", + }), + }); + + const response = await POST(request, { + params: Promise.resolve({ path: ["responses"] }), + }); + const data = await response.json(); + + expect(response.status).toBe(429); + expect(data).toEqual({ + error: expect.objectContaining({ + code: "API_KEY_QUOTA_EXCEEDED", + reason: "API_KEY_QUOTA_EXCEEDED", + did_send_upstream: false, + }), + }); + expect(routeByModel).not.toHaveBeenCalled(); + expect(forwardRequest).not.toHaveBeenCalled(); + expect(logRequest).toHaveBeenCalledWith( + expect.objectContaining({ + apiKeyId: "key-1", + upstreamId: null, + path: "responses", + model: "gpt-5.2", + statusCode: 429, + errorMessage: expect.stringContaining("API key spending quota exceeded"), + routingDecision: expect.objectContaining({ + matched_route_capability: "openai_responses", + did_send_upstream: false, + actual_upstream_id: null, + }), + }) + ); + }); + it("should route path capability request without model when route is matched", async () => { const { db } = await import("@/lib/db"); const { forwardRequest, prepareUpstreamForProxy } = await import("@/lib/services/proxy-client"); diff --git a/tests/unit/services/api-key-quota-tracker.test.ts b/tests/unit/services/api-key-quota-tracker.test.ts new file mode 100644 index 0000000..4e15022 --- /dev/null +++ b/tests/unit/services/api-key-quota-tracker.test.ts @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { findManyMock, selectMock, fromMock, whereMock } = vi.hoisted(() => ({ + findManyMock: vi.fn(async () => [] as unknown[]), + selectMock: vi.fn(), + fromMock: vi.fn(), + whereMock: vi.fn(), +})); + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + eq: vi.fn(() => ({ __op: "eq" })), + gte: vi.fn(() => ({ __op: "gte" })), + lt: vi.fn(() => ({ __op: "lt" })), + and: vi.fn((...args: unknown[]) => ({ __op: "and", args })), + isNotNull: vi.fn(() => ({ __op: "isNotNull" })), + sum: vi.fn(() => ({ __op: "sum" })), + }; +}); + +vi.mock("@/lib/db", () => ({ + db: { + query: { + apiKeys: { + findMany: findManyMock, + }, + }, + select: selectMock, + }, + requestBillingSnapshots: { + apiKeyId: "api_key_id", + billingStatus: "billing_status", + billedAt: "billed_at", + finalCost: "final_cost", + }, + apiKeys: { + id: "id", + name: "name", + spendingRules: "spending_rules", + }, +})); + +import { apiKeyQuotaTracker } from "@/lib/services/api-key-quota-tracker"; + +describe("api-key-quota-tracker", () => { + beforeEach(() => { + vi.clearAllMocks(); + apiKeyQuotaTracker.reset(); + }); + + describe("isWithinQuota", () => { + it("returns true when no rules are configured", () => { + expect(apiKeyQuotaTracker.isWithinQuota("missing-key")).toBe(true); + }); + + it("returns false when any rule is exceeded", () => { + apiKeyQuotaTracker.setRules( + "key-1", + [ + { period_type: "daily", limit: 100 }, + { period_type: "rolling", limit: 30, period_hours: 6 }, + ], + "Quota Key" + ); + apiKeyQuotaTracker.adjustSpending("key-1", 35); + + expect(apiKeyQuotaTracker.isWithinQuota("key-1")).toBe(false); + }); + }); + + describe("adjustSpending", () => { + it("applies deltas across all cached rules without dropping below zero", () => { + apiKeyQuotaTracker.setRules("key-2", [{ period_type: "daily", limit: 100 }], "Adjust Key"); + apiKeyQuotaTracker.adjustSpending("key-2", 40); + apiKeyQuotaTracker.adjustSpending("key-2", -55); + + const entries = apiKeyQuotaTracker.getCacheEntries("key-2"); + expect(entries).toHaveLength(1); + expect(entries?.[0]?.currentSpending).toBe(0); + }); + }); + + describe("getQuotaStatus", () => { + it("returns multi-rule status with AND semantics", () => { + apiKeyQuotaTracker.setRules( + "key-3", + [ + { period_type: "daily", limit: 200 }, + { period_type: "rolling", limit: 50, period_hours: 6 }, + ], + "Status Key" + ); + apiKeyQuotaTracker.adjustSpending("key-3", 80); + + const status = apiKeyQuotaTracker.getQuotaStatus("key-3"); + + expect(status?.apiKeyName).toBe("Status Key"); + expect(status?.rules).toHaveLength(2); + expect(status?.rules[0]).toEqual( + expect.objectContaining({ + periodType: "daily", + currentSpending: 80, + spendingLimit: 200, + isExceeded: false, + }) + ); + expect(status?.rules[1]).toEqual( + expect.objectContaining({ + periodType: "rolling", + periodHours: 6, + currentSpending: 80, + spendingLimit: 50, + isExceeded: true, + }) + ); + expect(status?.isExceeded).toBe(true); + }); + }); + + describe("syncFromDb", () => { + it("loads configured API keys and aggregates billed spending per rule", async () => { + findManyMock.mockResolvedValue([ + { + id: "key-4", + name: "Sync Key", + spendingRules: [ + { period_type: "daily", limit: 100 }, + { period_type: "rolling", limit: 30, period_hours: 5 }, + ], + }, + ]); + whereMock.mockResolvedValue([{ totalCost: "42.50" }]); + fromMock.mockReturnValue({ where: whereMock }); + selectMock.mockReturnValue({ from: fromMock }); + + await apiKeyQuotaTracker.syncFromDb(); + + const status = apiKeyQuotaTracker.getQuotaStatus("key-4"); + expect(status?.apiKeyName).toBe("Sync Key"); + expect(status?.rules).toHaveLength(2); + expect(status?.rules[0]?.currentSpending).toBe(42.5); + expect(status?.rules[1]?.currentSpending).toBe(42.5); + }); + }); + + describe("syncApiKeyFromDb", () => { + it("loads a single API key and clears cache when rules are removed", async () => { + whereMock.mockResolvedValue([{ totalCost: "15.25" }]); + fromMock.mockReturnValue({ where: whereMock }); + selectMock.mockReturnValue({ from: fromMock }); + + await apiKeyQuotaTracker.syncApiKeyFromDb("key-5", "Single Key", [ + { period_type: "daily", limit: 100 }, + ]); + expect(apiKeyQuotaTracker.getQuotaStatus("key-5")?.rules[0]?.currentSpending).toBe(15.25); + + await apiKeyQuotaTracker.syncApiKeyFromDb("key-5", "Single Key", null); + expect(apiKeyQuotaTracker.getQuotaStatus("key-5")).toBeNull(); + expect(apiKeyQuotaTracker.getCacheEntries("key-5")).toBeUndefined(); + }); + }); + + describe("estimateRecoveryTime", () => { + it("estimates rolling recovery time from billed snapshot history", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-15T14:30:00Z")); + + apiKeyQuotaTracker.setRules( + "key-6", + [{ period_type: "rolling", limit: 100, period_hours: 24 }], + "Rolling Key" + ); + apiKeyQuotaTracker.adjustSpending("key-6", 150); + + whereMock.mockResolvedValueOnce([ + { billedAt: new Date("2025-06-14T14:40:00Z"), finalCost: 60 }, + ]); + fromMock.mockReturnValue({ where: whereMock }); + selectMock.mockReturnValue({ from: fromMock }); + + const recovery = await apiKeyQuotaTracker.estimateRecoveryTime("key-6", { + period_type: "rolling", + limit: 100, + period_hours: 24, + }); + + expect(recovery).toEqual(new Date("2025-06-15T15:30:00Z")); + + vi.useRealTimers(); + }); + }); +}); diff --git a/tests/unit/services/key-manager.test.ts b/tests/unit/services/key-manager.test.ts index 69303de..4c62118 100644 --- a/tests/unit/services/key-manager.test.ts +++ b/tests/unit/services/key-manager.test.ts @@ -1,6 +1,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { generateApiKey, ApiKeyNotFoundError, LegacyApiKeyError } from "@/lib/services/key-manager"; +const { mockApiKeyQuotaTracker } = vi.hoisted(() => ({ + mockApiKeyQuotaTracker: { + initialize: vi.fn(async () => {}), + getQuotaStatus: vi.fn(() => null), + estimateRecoveryTime: vi.fn(async () => null), + syncApiKeyFromDb: vi.fn(async () => {}), + reset: vi.fn(), + }, +})); + // Mock the database module vi.mock("@/lib/db", () => { // Create db methods that can be reused for both db and transaction @@ -54,6 +64,10 @@ vi.mock("@/lib/db", () => { }; }); +vi.mock("@/lib/services/api-key-quota-tracker", () => ({ + apiKeyQuotaTracker: mockApiKeyQuotaTracker, +})); + // Mock auth utilities vi.mock("@/lib/utils/auth", () => ({ hashApiKey: vi.fn().mockResolvedValue("hashed-key"), diff --git a/tests/unit/utils/api-transformers.test.ts b/tests/unit/utils/api-transformers.test.ts index 8458c17..17d7a51 100644 --- a/tests/unit/utils/api-transformers.test.ts +++ b/tests/unit/utils/api-transformers.test.ts @@ -206,6 +206,20 @@ describe("api-transformers", () => { description: "Main production API key", accessMode: "restricted", upstreamIds: ["upstream-1", "upstream-2"], + spendingRules: [{ period_type: "daily", limit: 25 }], + spendingRuleStatuses: [ + { + periodType: "daily", + periodHours: null, + currentSpending: 12.5, + spendingLimit: 25, + percentUsed: 50, + isExceeded: false, + resetsAt: new Date("2025-01-16T00:00:00.000Z"), + estimatedRecoveryAt: null, + }, + ], + isQuotaExceeded: false, isActive: true, expiresAt: new Date("2025-01-15T00:00:00.000Z"), createdAt: new Date("2024-01-15T10:00:00.000Z"), @@ -221,6 +235,20 @@ describe("api-transformers", () => { description: "Main production API key", access_mode: "restricted", upstream_ids: ["upstream-1", "upstream-2"], + spending_rules: [{ period_type: "daily", limit: 25 }], + spending_rule_statuses: [ + { + period_type: "daily", + period_hours: null, + current_spending: 12.5, + spending_limit: 25, + percent_used: 50, + is_exceeded: false, + resets_at: "2025-01-16T00:00:00.000Z", + estimated_recovery_at: null, + }, + ], + is_quota_exceeded: false, is_active: true, expires_at: "2025-01-15T00:00:00.000Z", created_at: "2024-01-15T10:00:00.000Z", @@ -236,6 +264,9 @@ describe("api-transformers", () => { description: null, accessMode: "unrestricted", upstreamIds: [], + spendingRules: null, + spendingRuleStatuses: [], + isQuotaExceeded: false, isActive: false, expiresAt: null, createdAt: new Date("2024-01-10T08:00:00.000Z"), @@ -262,6 +293,20 @@ describe("api-transformers", () => { description: "Newly created key", accessMode: "restricted", upstreamIds: ["upstream-1"], + spendingRules: [{ period_type: "rolling", limit: 10, period_hours: 6 }], + spendingRuleStatuses: [ + { + periodType: "rolling", + periodHours: 6, + currentSpending: 10, + spendingLimit: 10, + percentUsed: 100, + isExceeded: true, + resetsAt: null, + estimatedRecoveryAt: new Date("2024-01-15T16:00:00.000Z"), + }, + ], + isQuotaExceeded: true, isActive: true, expiresAt: new Date("2025-06-01T00:00:00.000Z"), createdAt: new Date("2024-01-15T10:00:00.000Z"), @@ -278,6 +323,20 @@ describe("api-transformers", () => { description: "Newly created key", access_mode: "restricted", upstream_ids: ["upstream-1"], + spending_rules: [{ period_type: "rolling", limit: 10, period_hours: 6 }], + spending_rule_statuses: [ + { + period_type: "rolling", + period_hours: 6, + current_spending: 10, + spending_limit: 10, + percent_used: 100, + is_exceeded: true, + resets_at: null, + estimated_recovery_at: "2024-01-15T16:00:00.000Z", + }, + ], + is_quota_exceeded: true, is_active: true, expires_at: "2025-06-01T00:00:00.000Z", created_at: "2024-01-15T10:00:00.000Z", @@ -294,6 +353,9 @@ describe("api-transformers", () => { description: null, accessMode: "unrestricted", upstreamIds: [], + spendingRules: null, + spendingRuleStatuses: [], + isQuotaExceeded: false, isActive: true, expiresAt: null, createdAt: new Date("2024-01-15T10:00:00.000Z"), @@ -337,7 +399,22 @@ describe("api-transformers", () => { keyPrefix: "ar_live_", name: "Key 1", description: "First key", + accessMode: "restricted", upstreamIds: ["upstream-1"], + spendingRules: [{ period_type: "monthly", limit: 100 }], + spendingRuleStatuses: [ + { + periodType: "monthly", + periodHours: null, + currentSpending: 12, + spendingLimit: 100, + percentUsed: 12, + isExceeded: false, + resetsAt: new Date("2024-02-01T00:00:00.000Z"), + estimatedRecoveryAt: null, + }, + ], + isQuotaExceeded: false, isActive: true, expiresAt: null, createdAt: new Date("2024-01-15T10:00:00.000Z"), @@ -358,6 +435,8 @@ describe("api-transformers", () => { expect(result.page_size).toBe(15); expect(result.total_pages).toBe(4); expect(result.items[0].key_prefix).toBe("ar_live_"); + expect(result.items[0].spending_rules).toEqual([{ period_type: "monthly", limit: 100 }]); + expect(result.items[0].is_quota_exceeded).toBe(false); }); }); From b097912913c8b4e28b5a5a43c302374110031374 Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 20 Mar 2026 13:12:05 +0800 Subject: [PATCH 4/6] test: cover api key quota regressions --- tests/unit/hooks/use-api-keys.test.ts | 116 ++++++++++++++++++++ tests/unit/services/stats-service.test.ts | 124 ++++++++++++++++++++++ 2 files changed, 240 insertions(+) diff --git a/tests/unit/hooks/use-api-keys.test.ts b/tests/unit/hooks/use-api-keys.test.ts index 34c2725..2107df2 100644 --- a/tests/unit/hooks/use-api-keys.test.ts +++ b/tests/unit/hooks/use-api-keys.test.ts @@ -86,6 +86,9 @@ describe("use-api-keys hooks", () => { description: null, access_mode: "restricted", upstream_ids: ["upstream-1"], + spending_rules: null, + spending_rule_statuses: [], + is_quota_exceeded: false, is_active: true, expires_at: null, created_at: "2024-01-01T00:00:00Z", @@ -194,6 +197,64 @@ describe("use-api-keys hooks", () => { expect(mockToastError).toHaveBeenCalledWith("createFailed: Creation failed"); }); + + it("preserves spending quota fields in create payload and response", async () => { + const spendingRules = [ + { period_type: "daily" as const, limit: 25 }, + { period_type: "rolling" as const, limit: 10, period_hours: 6 }, + ]; + const spendingRuleStatuses = [ + { + period_type: "daily" as const, + period_hours: null, + current_spending: 12.5, + spending_limit: 25, + percent_used: 50, + is_exceeded: false, + resets_at: "2024-01-02T00:00:00Z", + estimated_recovery_at: null, + }, + { + period_type: "rolling" as const, + period_hours: 6, + current_spending: 10, + spending_limit: 10, + percent_used: 100, + is_exceeded: true, + resets_at: null, + estimated_recovery_at: "2024-01-01T06:30:00Z", + }, + ]; + const mockResponse = { + ...makeApiKey({ + id: "new-key-id", + key_prefix: "sk-auto-newk", + name: "New Key", + spending_rules: spendingRules, + spending_rule_statuses: spendingRuleStatuses, + is_quota_exceeded: true, + }), + key_value: "sk-auto-newkey123", + }; + mockPost.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useCreateAPIKey(), { wrapper }); + + result.current.mutate({ + name: "New Key", + upstream_ids: ["upstream-1"], + spending_rules: spendingRules, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(mockPost).toHaveBeenCalledWith("/admin/keys", { + name: "New Key", + upstream_ids: ["upstream-1"], + spending_rules: spendingRules, + }); + expect(result.current.data).toEqual(mockResponse); + }); }); describe("useRevealAPIKey", () => { @@ -401,6 +462,61 @@ describe("use-api-keys hooks", () => { upstream_ids: ["upstream-2", "upstream-3"], }); }); + + it("preserves spending quota fields in update payload and response", async () => { + const spendingRules = [ + { period_type: "monthly" as const, limit: 200 }, + { period_type: "rolling" as const, limit: 40, period_hours: 12 }, + ]; + const spendingRuleStatuses = [ + { + period_type: "monthly" as const, + period_hours: null, + current_spending: 120, + spending_limit: 200, + percent_used: 60, + is_exceeded: false, + resets_at: "2024-02-01T00:00:00Z", + estimated_recovery_at: null, + }, + { + period_type: "rolling" as const, + period_hours: 12, + current_spending: 45, + spending_limit: 40, + percent_used: 112.5, + is_exceeded: true, + resets_at: null, + estimated_recovery_at: "2024-01-02T03:00:00Z", + }, + ]; + const mockResponse = makeApiKey({ + id: "key-1", + name: "Updated Key", + description: "Updated description", + updated_at: "2024-01-02T00:00:00Z", + spending_rules: spendingRules, + spending_rule_statuses: spendingRuleStatuses, + is_quota_exceeded: true, + }); + mockPut.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useUpdateAPIKey(), { wrapper }); + + result.current.mutate({ + id: "key-1", + data: { + spending_rules: spendingRules, + }, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(mockPut).toHaveBeenCalledWith("/admin/keys/key-1", { + spending_rules: spendingRules, + }); + expect(result.current.data).toEqual(mockResponse); + }); }); describe("useToggleAPIKeyActive", () => { diff --git a/tests/unit/services/stats-service.test.ts b/tests/unit/services/stats-service.test.ts index edb3826..3c30afe 100644 --- a/tests/unit/services/stats-service.test.ts +++ b/tests/unit/services/stats-service.test.ts @@ -895,6 +895,130 @@ describe("stats-service", () => { expect(result.apiKeys[0].totalTokens).toBe(0); }); + it("should include quota-rejected requests for api keys but exclude them from upstreams", async () => { + const { db } = await import("@/lib/db"); + const { getLeaderboardStats } = await import("@/lib/services/stats-service"); + + const emptyMainQuery = { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + groupBy: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + }), + } as unknown as ReturnType; + + vi.mocked(db.select) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + groupBy: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi + .fn() + .mockResolvedValue([ + { apiKeyId: "key-1", requestCount: 3, totalTokens: "100" }, + ]), + }), + }), + }), + }), + } as unknown as ReturnType) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + groupBy: vi.fn().mockResolvedValue([{ apiKeyId: "key-1", totalCost: "1.25" }]), + }), + }), + }), + } as unknown as ReturnType) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + groupBy: vi.fn().mockResolvedValue([{ groupKey: "key-1", name: "gpt-4o", cnt: 2 }]), + }), + }), + } as unknown as ReturnType) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + groupBy: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + upstreamId: "upstream-1", + requestCount: 2, + totalTokens: "80", + avgTtft: null, + totalCompletionTokens: 0, + totalDurationMs: 0, + totalCacheReadTokens: null, + totalEffectivePromptTokens: 0, + }, + ]), + }), + }), + }), + }), + } as unknown as ReturnType) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + groupBy: vi + .fn() + .mockResolvedValue([{ upstreamId: "upstream-1", totalCost: "1.25" }]), + }), + }), + }), + } as unknown as ReturnType) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + groupBy: vi + .fn() + .mockResolvedValue([{ groupKey: "upstream-1", name: "gpt-4o", cnt: 2 }]), + }), + }), + } as unknown as ReturnType) + .mockReturnValueOnce(emptyMainQuery); + + vi.mocked(db.query.apiKeys.findMany).mockResolvedValueOnce([ + { id: "key-1", name: "Quota Key", keyPrefix: "sk-quota-" }, + ]); + vi.mocked(db.query.upstreams.findMany).mockResolvedValueOnce([ + { + id: "upstream-1", + name: "Primary Upstream", + routeCapabilities: ["openai_chat_compatible"], + }, + ]); + + const result = await getLeaderboardStats("7d", 5); + + expect(result.apiKeys).toHaveLength(1); + expect(result.apiKeys[0]).toMatchObject({ + id: "key-1", + requestCount: 3, + totalTokens: 100, + totalCostUsd: 1.25, + modelDistribution: [{ name: "gpt-4o", count: 2 }], + }); + + expect(result.upstreams).toHaveLength(1); + expect(result.upstreams[0]).toMatchObject({ + id: "upstream-1", + requestCount: 2, + totalTokens: 80, + totalCostUsd: 1.25, + modelDistribution: [{ name: "gpt-4o", count: 2 }], + }); + }); + it("should calculate upstream avgTps from aggregated stream fields", async () => { const { db } = await import("@/lib/db"); const { getLeaderboardStats } = await import("@/lib/services/stats-service"); From 961b1180b98d3816a0fe83a785569a7586bf7c2d Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 20 Mar 2026 13:47:36 +0800 Subject: [PATCH 5/6] fix: preserve unbilled quota bypass semantics --- src/app/api/proxy/v1/[...path]/route.ts | 112 +++++++--- src/lib/services/key-manager.ts | 117 ++++++---- tests/unit/api/proxy/route.test.ts | 278 +++++++++++++++++++++++- tests/unit/services/key-manager.test.ts | 153 +++++++++++++ 4 files changed, 570 insertions(+), 90 deletions(-) diff --git a/src/app/api/proxy/v1/[...path]/route.ts b/src/app/api/proxy/v1/[...path]/route.ts index 29b3d3d..03294ba 100644 --- a/src/app/api/proxy/v1/[...path]/route.ts +++ b/src/app/api/proxy/v1/[...path]/route.ts @@ -95,6 +95,7 @@ import { buildCompensations } from "@/lib/services/compensation-service"; import { createLogger } from "@/lib/utils/logger"; import { extractRequestThinkingConfig } from "@/lib/utils/request-thinking-config"; import { apiKeyQuotaTracker } from "@/lib/services/api-key-quota-tracker"; +import { resolveBillingModelPrice } from "@/lib/services/billing-price-service"; const log = createLogger("proxy-route"); @@ -131,6 +132,38 @@ async function persistBillingSnapshotSafely(input: { } } +async function shouldRejectExceededApiKeyQuotaBeforeProxy(input: { + quotaStatus: ReturnType; + model: string | null; + requestedStream: boolean; + requestId: string; +}): Promise { + if (!input.quotaStatus?.isExceeded) { + return false; + } + + // Requests whose billability is only known after proxying must still reach snapshot persistence. + if (!input.requestedStream) { + return false; + } + + const normalizedModel = input.model?.trim() ?? ""; + if (!normalizedModel) { + return false; + } + + try { + const resolvedPrice = await resolveBillingModelPrice(normalizedModel); + return resolvedPrice !== null; + } catch (error) { + log.error( + { err: error, requestId: input.requestId, model: normalizedModel }, + "failed to resolve billing price before API key quota check" + ); + return false; + } +} + function buildApiKeyQuotaExceededErrorMessage( apiKeyId: string, exceededRules: Array<{ @@ -1811,42 +1844,6 @@ async function handleProxy(request: NextRequest, context: RouteContext): Promise await apiKeyQuotaTracker.initialize(); const apiKeyQuotaStatus = apiKeyQuotaTracker.getQuotaStatus(validApiKey.id); - if (apiKeyQuotaStatus?.isExceeded) { - const errorCode: UnifiedErrorCode = "API_KEY_QUOTA_EXCEEDED"; - const errorReason: UnifiedErrorReason = "API_KEY_QUOTA_EXCEEDED"; - const exceededRules = apiKeyQuotaStatus.rules.filter((rule) => rule.isExceeded); - const errorMessage = buildApiKeyQuotaExceededErrorMessage(validApiKey.id, exceededRules); - const errorDetails = { - reason: errorReason, - did_send_upstream: false, - request_id: requestId, - user_hint: getUserHint(errorCode, errorReason, matchedRouteCapability ?? "openai_responses"), - } as const; - - try { - await logApiKeyQuotaRejectedRequest({ - apiKeyId: validApiKey.id, - request, - path, - model, - reasoningEffort, - thinkingConfig, - requestId, - startTime, - sessionId: null, - matchedRouteCapability, - routeMatchSource: matchedRouteMatchSource, - errorMessage, - }); - } catch (error) { - log.error( - { err: error, requestId, apiKeyId: validApiKey.id }, - "failed to log API key quota rejection" - ); - } - - return createUnifiedErrorResponse(errorCode, errorDetails); - } if (!matchedRouteCapability) { const unsupportedDurationMs = Date.now() - startTime; @@ -2056,6 +2053,49 @@ async function handleProxy(request: NextRequest, context: RouteContext): Promise selectedCandidate )); + const shouldRejectApiKeyQuotaBeforeProxy = await shouldRejectExceededApiKeyQuotaBeforeProxy({ + quotaStatus: apiKeyQuotaStatus, + model: resolvedModel, + requestedStream, + requestId, + }); + if (shouldRejectApiKeyQuotaBeforeProxy) { + const errorCode: UnifiedErrorCode = "API_KEY_QUOTA_EXCEEDED"; + const errorReason: UnifiedErrorReason = "API_KEY_QUOTA_EXCEEDED"; + const exceededRules = apiKeyQuotaStatus!.rules.filter((rule) => rule.isExceeded); + const errorMessage = buildApiKeyQuotaExceededErrorMessage(validApiKey.id, exceededRules); + const errorDetails = { + reason: errorReason, + did_send_upstream: false, + request_id: requestId, + user_hint: getUserHint(errorCode, errorReason, matchedRouteCapability), + } as const; + + try { + await logApiKeyQuotaRejectedRequest({ + apiKeyId: validApiKey.id, + request, + path, + model: resolvedModel, + reasoningEffort, + thinkingConfig, + requestId, + startTime, + sessionId: null, + matchedRouteCapability, + routeMatchSource: matchedRouteMatchSource, + errorMessage, + }); + } catch (error) { + log.error( + { err: error, requestId, apiKeyId: validApiKey.id }, + "failed to log API key quota rejection" + ); + } + + return createUnifiedErrorResponse(errorCode, errorDetails); + } + log.debug( { requestId, diff --git a/src/lib/services/key-manager.ts b/src/lib/services/key-manager.ts index f63a6dd..891c011 100644 --- a/src/lib/services/key-manager.ts +++ b/src/lib/services/key-manager.ts @@ -121,25 +121,13 @@ function normalizeAccessMode( return upstreamIds.length > 0 ? "restricted" : "unrestricted"; } -async function resolveSpendingRuleStatuses( - apiKeyId: string, - spendingRules: SpendingRule[] | null -): Promise<{ +function buildFallbackQuotaState(spendingRules: SpendingRule[] | null): { spendingRuleStatuses: ApiKeySpendingRuleStatus[]; isQuotaExceeded: boolean; -}> { - if (!spendingRules || spendingRules.length === 0) { - return { - spendingRuleStatuses: [], - isQuotaExceeded: false, - }; - } - - await apiKeyQuotaTracker.initialize(); - const status = apiKeyQuotaTracker.getQuotaStatus(apiKeyId); - if (!status) { - return { - spendingRuleStatuses: spendingRules.map((rule) => ({ +} { + return { + spendingRuleStatuses: + spendingRules?.map((rule) => ({ periodType: rule.period_type, periodHours: rule.period_hours ?? null, currentSpending: 0, @@ -148,35 +136,72 @@ async function resolveSpendingRuleStatuses( isExceeded: false, resetsAt: null, estimatedRecoveryAt: null, - })), - isQuotaExceeded: false, - }; + })) ?? [], + isQuotaExceeded: false, + }; +} + +async function syncApiKeyQuotaStateBestEffort( + apiKeyId: string, + apiKeyName: string, + spendingRules: SpendingRule[] | null +): Promise { + try { + await apiKeyQuotaTracker.syncApiKeyFromDb(apiKeyId, apiKeyName, spendingRules); + } catch (error) { + log.error( + { err: error, apiKeyId, apiKeyName }, + "failed to sync API key quota tracker after persisted mutation" + ); } +} - const spendingRuleStatuses = await Promise.all( - status.rules.map(async (rule) => ({ - periodType: rule.periodType, - periodHours: rule.periodHours, - currentSpending: rule.currentSpending, - spendingLimit: rule.spendingLimit, - percentUsed: rule.percentUsed, - isExceeded: rule.isExceeded, - resetsAt: rule.resetsAt, - estimatedRecoveryAt: - rule.periodType === "rolling" && rule.isExceeded - ? await apiKeyQuotaTracker.estimateRecoveryTime(apiKeyId, { - period_type: "rolling", - limit: rule.spendingLimit, - ...(rule.periodHours != null ? { period_hours: rule.periodHours } : {}), - }) - : null, - })) - ); +async function resolveSpendingRuleStatuses( + apiKeyId: string, + spendingRules: SpendingRule[] | null +): Promise<{ + spendingRuleStatuses: ApiKeySpendingRuleStatus[]; + isQuotaExceeded: boolean; +}> { + if (!spendingRules || spendingRules.length === 0) { + return buildFallbackQuotaState(spendingRules); + } - return { - spendingRuleStatuses, - isQuotaExceeded: status.isExceeded, - }; + try { + await apiKeyQuotaTracker.initialize(); + const status = apiKeyQuotaTracker.getQuotaStatus(apiKeyId); + if (!status) { + return buildFallbackQuotaState(spendingRules); + } + + const spendingRuleStatuses = await Promise.all( + status.rules.map(async (rule) => ({ + periodType: rule.periodType, + periodHours: rule.periodHours, + currentSpending: rule.currentSpending, + spendingLimit: rule.spendingLimit, + percentUsed: rule.percentUsed, + isExceeded: rule.isExceeded, + resetsAt: rule.resetsAt, + estimatedRecoveryAt: + rule.periodType === "rolling" && rule.isExceeded + ? await apiKeyQuotaTracker.estimateRecoveryTime(apiKeyId, { + period_type: "rolling", + limit: rule.spendingLimit, + ...(rule.periodHours != null ? { period_hours: rule.periodHours } : {}), + }) + : null, + })) + ); + + return { + spendingRuleStatuses, + isQuotaExceeded: status.isExceeded, + }; + } catch (error) { + log.error({ err: error, apiKeyId }, "failed to resolve API key quota statuses"); + return buildFallbackQuotaState(spendingRules); + } } async function buildApiKeyListItem( @@ -301,7 +326,7 @@ export async function createApiKey(input: ApiKeyCreateInput): Promise { } await db.delete(apiKeys).where(eq(apiKeys.id, keyId)); - await apiKeyQuotaTracker.syncApiKeyFromDb(keyId, existing.name, null); + await syncApiKeyQuotaStateBestEffort(keyId, existing.name, null); log.info({ keyPrefix: existing.keyPrefix, name: existing.name }, "deleted API key"); } @@ -637,7 +662,7 @@ export async function updateApiKey( }; }); - await apiKeyQuotaTracker.syncApiKeyFromDb( + await syncApiKeyQuotaStateBestEffort( updatedResult.id, updatedResult.name, updatedResult.spendingRules diff --git a/tests/unit/api/proxy/route.test.ts b/tests/unit/api/proxy/route.test.ts index 25f62bf..db31d2c 100644 --- a/tests/unit/api/proxy/route.test.ts +++ b/tests/unit/api/proxy/route.test.ts @@ -1,11 +1,24 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; -const { mockApiKeyQuotaTracker } = vi.hoisted(() => ({ +const { mockApiKeyQuotaTracker, mockResolveBillingModelPrice } = vi.hoisted(() => ({ mockApiKeyQuotaTracker: { initialize: vi.fn(async () => {}), getQuotaStatus: vi.fn(() => null), }, + mockResolveBillingModelPrice: vi.fn(async () => ({ + model: "gpt-5.2", + source: "manual", + inputPricePerMillion: 1, + outputPricePerMillion: 1, + cacheReadInputPricePerMillion: null, + cacheWriteInputPricePerMillion: null, + matchedRuleType: "flat", + matchedRuleDisplayLabel: null, + appliedTierThreshold: null, + modelMaxInputTokens: null, + modelMaxOutputTokens: null, + })), })); vi.mock("@/lib/utils/auth", () => ({ @@ -109,6 +122,10 @@ vi.mock("@/lib/services/billing-cost-service", () => ({ })), })); +vi.mock("@/lib/services/billing-price-service", () => ({ + resolveBillingModelPrice: mockResolveBillingModelPrice, +})); + vi.mock("@/lib/services/api-key-quota-tracker", () => ({ apiKeyQuotaTracker: mockApiKeyQuotaTracker, })); @@ -325,6 +342,19 @@ describe("proxy route upstream selection", () => { vi.resetModules(); mockApiKeyQuotaTracker.initialize.mockResolvedValue(undefined); mockApiKeyQuotaTracker.getQuotaStatus.mockReturnValue(null); + mockResolveBillingModelPrice.mockResolvedValue({ + model: "gpt-5.2", + source: "manual", + inputPricePerMillion: 1, + outputPricePerMillion: 1, + cacheReadInputPricePerMillion: null, + cacheWriteInputPricePerMillion: null, + matchedRuleType: "flat", + matchedRuleDisplayLabel: null, + appliedTierThreshold: null, + modelMaxInputTokens: null, + modelMaxOutputTokens: null, + }); delete process.env.RECORDER_ENABLED; delete process.env.RECORDER_MODE; const routeModule = await import("@/app/api/proxy/v1/[...path]/route"); @@ -543,16 +573,46 @@ describe("proxy route upstream selection", () => { }); }); - it("should reject requests before upstream routing when API key quota is exceeded", async () => { + it("should reject streaming requests before upstream routing when API key quota is exceeded", async () => { const { db } = await import("@/lib/db"); const { forwardRequest } = await import("@/lib/services/proxy-client"); const { logRequest } = await import("@/lib/services/request-logger"); const { routeByModel } = await import("@/lib/services/model-router"); + mockResolveBillingModelPrice.mockResolvedValueOnce({ + model: "gpt-5.2", + source: "manual", + inputPricePerMillion: 1, + outputPricePerMillion: 1, + cacheReadInputPricePerMillion: null, + cacheWriteInputPricePerMillion: null, + matchedRuleType: "flat", + matchedRuleDisplayLabel: null, + appliedTierThreshold: null, + modelMaxInputTokens: null, + modelMaxOutputTokens: null, + }); vi.mocked(db.query.apiKeys.findMany).mockResolvedValueOnce([ { id: "key-1", keyHash: "hash-1", expiresAt: null, isActive: true }, ]); - mockApiKeyQuotaTracker.getQuotaStatus.mockReturnValueOnce({ + vi.mocked(db.query.apiKeyUpstreams.findMany).mockResolvedValueOnce([ + { upstreamId: "up-openai" }, + ]); + vi.mocked(db.query.upstreams.findMany).mockResolvedValueOnce([ + { + id: "up-openai", + name: "openai-main", + providerType: "openai", + baseUrl: "https://api.openai.com", + isDefault: false, + isActive: true, + timeout: 60, + priority: 0, + weight: 1, + routeCapabilities: ["openai_chat_compatible"], + }, + ]); + mockApiKeyQuotaTracker.getQuotaStatus.mockReturnValue({ apiKeyId: "key-1", apiKeyName: "Quota Key", isExceeded: true, @@ -570,7 +630,7 @@ describe("proxy route upstream selection", () => { ], }); - const request = new NextRequest("http://localhost/api/proxy/v1/responses", { + const request = new NextRequest("http://localhost/api/proxy/v1/chat/completions", { method: "POST", headers: { authorization: "Bearer sk-test", @@ -578,12 +638,13 @@ describe("proxy route upstream selection", () => { }, body: JSON.stringify({ model: "gpt-5.2", - input: "hello", + messages: [{ role: "user", content: "hello" }], + stream: true, }), }); const response = await POST(request, { - params: Promise.resolve({ path: ["responses"] }), + params: Promise.resolve({ path: ["chat", "completions"] }), }); const data = await response.json(); @@ -601,12 +662,12 @@ describe("proxy route upstream selection", () => { expect.objectContaining({ apiKeyId: "key-1", upstreamId: null, - path: "responses", + path: "chat/completions", model: "gpt-5.2", statusCode: 429, errorMessage: expect.stringContaining("API key spending quota exceeded"), routingDecision: expect.objectContaining({ - matched_route_capability: "openai_responses", + matched_route_capability: "openai_chat_compatible", did_send_upstream: false, actual_upstream_id: null, }), @@ -614,6 +675,207 @@ describe("proxy route upstream selection", () => { ); }); + it("should allow over-quota requests to proceed when billing later resolves as usage_missing", async () => { + const { db } = await import("@/lib/db"); + const { forwardRequest } = await import("@/lib/services/proxy-client"); + const { selectFromProviderType } = await import("@/lib/services/load-balancer"); + const { calculateAndPersistRequestBillingSnapshot } = + await import("@/lib/services/billing-cost-service"); + + vi.mocked(calculateAndPersistRequestBillingSnapshot).mockResolvedValueOnce({ + status: "unbilled", + unbillableReason: "usage_missing", + finalCost: null, + source: null, + }); + + const openaiUpstream = { + id: "up-openai", + name: "openai-main", + providerType: "openai", + baseUrl: "https://api.openai.com", + isDefault: false, + isActive: true, + timeout: 60, + priority: 0, + weight: 1, + }; + + vi.mocked(db.query.apiKeys.findMany).mockResolvedValueOnce([ + { id: "key-1", keyHash: "hash-1", expiresAt: null, isActive: true }, + ]); + mockApiKeyQuotaTracker.getQuotaStatus.mockReturnValueOnce({ + apiKeyId: "key-1", + apiKeyName: "Quota Key", + isExceeded: true, + rules: [ + { + periodType: "daily", + periodHours: null, + currentSpending: 25, + spendingLimit: 25, + percentUsed: 100, + isExceeded: true, + resetsAt: new Date("2024-01-02T00:00:00Z"), + estimatedRecoveryAt: null, + }, + ], + }); + vi.mocked(db.query.apiKeyUpstreams.findMany).mockResolvedValueOnce([ + { upstreamId: "up-openai" }, + ]); + vi.mocked(db.query.upstreams.findMany).mockResolvedValueOnce([ + { + ...openaiUpstream, + routeCapabilities: ["openai_chat_compatible"], + }, + ]); + vi.mocked(db.query.upstreamHealth.findMany).mockResolvedValueOnce([]); + vi.mocked(selectFromProviderType).mockResolvedValueOnce({ + upstream: openaiUpstream, + providerType: "openai", + selectedTier: 0, + circuitBreakerFiltered: 0, + totalCandidates: 1, + }); + vi.mocked(forwardRequest).mockResolvedValueOnce({ + statusCode: 200, + headers: new Headers(), + body: new Uint8Array(), + isStream: false, + usage: { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + }, + }); + + const request = new NextRequest("http://localhost/api/proxy/v1/chat/completions", { + method: "POST", + headers: { + authorization: "Bearer sk-test", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-5.2", + messages: [{ role: "user", content: "hello" }], + }), + }); + + const response = await POST(request, { + params: Promise.resolve({ path: ["chat", "completions"] }), + }); + + expect(response.status).toBe(200); + expect(forwardRequest).toHaveBeenCalledTimes(1); + expect(calculateAndPersistRequestBillingSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + apiKeyId: "key-1", + upstreamId: "up-openai", + usage: { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }) + ); + }); + + it("should allow over-quota streaming requests when the resolved model has no billing price", async () => { + const { db } = await import("@/lib/db"); + const { forwardRequest } = await import("@/lib/services/proxy-client"); + const { selectFromProviderType } = await import("@/lib/services/load-balancer"); + const { resolveBillingModelPrice } = await import("@/lib/services/billing-price-service"); + + const openaiUpstream = { + id: "up-openai", + name: "openai-main", + providerType: "openai", + baseUrl: "https://api.openai.com", + isDefault: false, + isActive: true, + timeout: 60, + priority: 0, + weight: 1, + modelRedirects: { + "gpt-5.2": "gpt-unpriced", + }, + }; + + vi.mocked(resolveBillingModelPrice).mockResolvedValueOnce(null); + vi.mocked(db.query.apiKeys.findMany).mockResolvedValueOnce([ + { id: "key-1", keyHash: "hash-1", expiresAt: null, isActive: true }, + ]); + mockApiKeyQuotaTracker.getQuotaStatus.mockReturnValueOnce({ + apiKeyId: "key-1", + apiKeyName: "Quota Key", + isExceeded: true, + rules: [ + { + periodType: "daily", + periodHours: null, + currentSpending: 25, + spendingLimit: 25, + percentUsed: 100, + isExceeded: true, + resetsAt: new Date("2024-01-02T00:00:00Z"), + estimatedRecoveryAt: null, + }, + ], + }); + vi.mocked(db.query.apiKeyUpstreams.findMany).mockResolvedValueOnce([ + { upstreamId: "up-openai" }, + ]); + vi.mocked(db.query.upstreams.findMany).mockResolvedValueOnce([ + { + ...openaiUpstream, + routeCapabilities: ["openai_chat_compatible"], + }, + ]); + vi.mocked(db.query.upstreamHealth.findMany).mockResolvedValueOnce([]); + vi.mocked(selectFromProviderType).mockResolvedValueOnce({ + upstream: openaiUpstream, + providerType: "openai", + selectedTier: 0, + circuitBreakerFiltered: 0, + totalCandidates: 1, + }); + vi.mocked(forwardRequest).mockResolvedValueOnce({ + statusCode: 200, + headers: new Headers(), + body: new Uint8Array(), + isStream: false, + usage: { + promptTokens: 32, + completionTokens: 16, + totalTokens: 48, + }, + }); + + const request = new NextRequest("http://localhost/api/proxy/v1/chat/completions", { + method: "POST", + headers: { + authorization: "Bearer sk-test", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-5.2", + messages: [{ role: "user", content: "hello" }], + stream: true, + }), + }); + + const response = await POST(request, { + params: Promise.resolve({ path: ["chat", "completions"] }), + }); + + expect(response.status).toBe(200); + expect(resolveBillingModelPrice).toHaveBeenCalledWith("gpt-unpriced"); + expect(forwardRequest).toHaveBeenCalledTimes(1); + }); + it("should route path capability request without model when route is matched", async () => { const { db } = await import("@/lib/db"); const { forwardRequest, prepareUpstreamForProxy } = await import("@/lib/services/proxy-client"); diff --git a/tests/unit/services/key-manager.test.ts b/tests/unit/services/key-manager.test.ts index 4c62118..875a219 100644 --- a/tests/unit/services/key-manager.test.ts +++ b/tests/unit/services/key-manager.test.ts @@ -260,6 +260,84 @@ describe("key-manager", () => { expect(result.upstreamIds).toEqual(["upstream-1"]); }); + it("should still return the created key when quota tracker sync fails after persistence", async () => { + const { db } = await import("@/lib/db"); + const { createApiKey } = await import("@/lib/services/key-manager"); + + const mockApiKey = { + id: "key-1", + keyHash: "hashed-key", + keyValueEncrypted: "encrypted:sk-auto-test123", + keyPrefix: "sk-auto-test", + name: "Test Key", + description: "Test description", + accessMode: "restricted", + spendingRules: null, + isActive: true, + expiresAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(db.query.upstreams.findMany).mockResolvedValueOnce([ + { id: "upstream-1", name: "OpenAI" }, + ] as never); + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([mockApiKey]), + }), + } as unknown as ReturnType); + mockApiKeyQuotaTracker.syncApiKeyFromDb.mockRejectedValueOnce(new Error("sync failed")); + + const result = await createApiKey({ + name: "Test Key", + upstreamIds: ["upstream-1"], + description: "Test description", + }); + + expect(result.name).toBe("Test Key"); + expect(result.upstreamIds).toEqual(["upstream-1"]); + expect(result.spendingRuleStatuses).toEqual([]); + expect(result.isQuotaExceeded).toBe(false); + }); + + it("should still return created key when quota tracker sync fails after persistence", async () => { + const { db } = await import("@/lib/db"); + const { createApiKey } = await import("@/lib/services/key-manager"); + + const mockApiKey = { + id: "key-1", + keyHash: "hashed-key", + keyValueEncrypted: "encrypted:sk-auto-test123", + keyPrefix: "sk-auto-test", + name: "Test Key", + description: null, + accessMode: "unrestricted", + spendingRules: null, + isActive: true, + expiresAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([mockApiKey]), + }), + } as unknown as ReturnType); + mockApiKeyQuotaTracker.syncApiKeyFromDb.mockRejectedValueOnce(new Error("sync failed")); + + const result = await createApiKey({ + name: "Test Key", + upstreamIds: [], + accessMode: "unrestricted", + }); + + expect(result.id).toBe("key-1"); + expect(result.spendingRuleStatuses).toEqual([]); + expect(result.isQuotaExceeded).toBe(false); + }); + it("should create API key with expiration date", async () => { const { db } = await import("@/lib/db"); const { createApiKey } = await import("@/lib/services/key-manager"); @@ -348,6 +426,26 @@ describe("key-manager", () => { await expect(deleteApiKey("key-1")).resolves.toBeUndefined(); expect(db.delete).toHaveBeenCalled(); }); + + it("should still resolve delete when quota tracker sync fails after deletion", async () => { + const { db } = await import("@/lib/db"); + const { deleteApiKey } = await import("@/lib/services/key-manager"); + + vi.mocked(db.query.apiKeys.findFirst).mockResolvedValueOnce({ + id: "key-1", + keyPrefix: "sk-auto-test", + name: "Test Key", + } as never); + + const mockWhere = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.delete).mockReturnValue({ + where: mockWhere, + } as unknown as ReturnType); + mockApiKeyQuotaTracker.syncApiKeyFromDb.mockRejectedValueOnce(new Error("sync failed")); + + await expect(deleteApiKey("key-1")).resolves.toBeUndefined(); + expect(db.delete).toHaveBeenCalled(); + }); }); describe("listApiKeys", () => { @@ -898,6 +996,61 @@ describe("key-manager", () => { expect(result.description).toBe("New description"); }); + it("should still return updated key when quota status hydration fails after update", async () => { + const { db } = await import("@/lib/db"); + const { updateApiKey } = await import("@/lib/services/key-manager"); + + const mockExistingKey = { + id: "key-1", + keyPrefix: "sk-auto-test", + name: "Test Key", + description: null, + accessMode: "restricted", + spendingRules: [{ period_type: "daily", limit: 50 }], + isActive: true, + expiresAt: null, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + }; + + const mockUpdatedKey = { + ...mockExistingKey, + name: "Updated Key", + updatedAt: new Date(), + }; + + vi.mocked(db.query.apiKeys.findFirst).mockResolvedValueOnce(mockExistingKey as never); + + const mockReturning = vi.fn().mockResolvedValue([mockUpdatedKey]); + const mockWhere = vi.fn(() => ({ returning: mockReturning })); + const mockSet = vi.fn(() => ({ where: mockWhere })); + vi.mocked(db.update).mockReturnValue({ set: mockSet } as unknown as ReturnType< + typeof db.update + >); + + vi.mocked(db.query.apiKeyUpstreams.findMany).mockResolvedValueOnce([ + { apiKeyId: "key-1", upstreamId: "upstream-1" }, + ] as never); + mockApiKeyQuotaTracker.initialize.mockRejectedValueOnce(new Error("quota init failed")); + + const result = await updateApiKey("key-1", { name: "Updated Key" }); + + expect(result.name).toBe("Updated Key"); + expect(result.isQuotaExceeded).toBe(false); + expect(result.spendingRuleStatuses).toEqual([ + { + periodType: "daily", + periodHours: null, + currentSpending: 0, + spendingLimit: 50, + percentUsed: 0, + isExceeded: false, + resetsAt: null, + estimatedRecoveryAt: null, + }, + ]); + }); + it("should update isActive status", async () => { const { db } = await import("@/lib/db"); const { updateApiKey } = await import("@/lib/services/key-manager"); From 3ed43525843833fca1fb560e65abcce2da41e33d Mon Sep 17 00:00:00 2001 From: umaru Date: Fri, 20 Mar 2026 21:06:00 +0800 Subject: [PATCH 6/6] feat(keys): beautify keys page layout with expandable rows and animations - Simplify header: replace Card wrapper with compact inline toolbar - Add expandable row pattern for quota details with enter animation - Redesign quota rules as responsive horizontal grid (1/2/3 columns) - Replace status Badge with compact color dot indicator - Add grid-rows animation for edit dialog section transitions - Upstream selection and rolling period fields animate on toggle - Update tests to match new expandable row behavior --- src/app/[locale]/(dashboard)/keys/page.tsx | 31 +- src/components/admin/edit-key-dialog.tsx | 282 ++++++++------- src/components/admin/keys-table.tsx | 385 ++++++++++++--------- tests/components/keys-table.test.tsx | 11 +- 4 files changed, 388 insertions(+), 321 deletions(-) diff --git a/src/app/[locale]/(dashboard)/keys/page.tsx b/src/app/[locale]/(dashboard)/keys/page.tsx index 7da04aa..5e93318 100644 --- a/src/app/[locale]/(dashboard)/keys/page.tsx +++ b/src/app/[locale]/(dashboard)/keys/page.tsx @@ -73,33 +73,20 @@ export default function KeysPage() { <> -
- - -
-
-
-

{t("managementDesc")}

-
- -
-
+
+
+
+
+ +
{isLoading ? ( ) : ( <> - - - - - + {data && data.total_pages > 1 && ( diff --git a/src/components/admin/edit-key-dialog.tsx b/src/components/admin/edit-key-dialog.tsx index db9e6ec..33fdd24 100644 --- a/src/components/admin/edit-key-dialog.tsx +++ b/src/components/admin/edit-key-dialog.tsx @@ -291,114 +291,123 @@ export function EditKeyDialog({ apiKey, open, onOpenChange }: EditKeyDialogProps )} /> - {accessMode === "restricted" && ( - ( - - {t("selectUpstreams")} * - {t("selectUpstreamsDesc")} -
-
- - setUpstreamSearchQuery(event.target.value)} - placeholder={t("searchUpstreams")} - aria-label={t("searchUpstreams")} - className="border-surface-400/70 bg-surface-200/70 pl-9 transition-colors duration-cf-fast hover:border-surface-400 focus-visible:border-amber-400/45 focus-visible:ring-amber-400/20" - /> -
- -
- {!upstreamsLoading && !!upstreams?.length && ( -

- {t("filteredUpstreamsSelected", { - selected: selectedFilteredCount, - total: filteredUpstreamIds.length, - })} -

- )} -
- {upstreamsLoading ? ( -
- {tCommon("loading")} -
- ) : !upstreams || upstreams.length === 0 ? ( -
- {tCommon("noData")} -
- ) : filteredUpstreams.length === 0 ? ( -
- {t("noMatchingUpstreams")} -
- ) : ( - filteredUpstreams.map((upstream) => ( - ( - - - { - const updated = checked - ? [...(field.value || []), upstream.id] - : field.value?.filter((id) => id !== upstream.id); - field.onChange(updated); - }} - /> - -
- - {upstream.description && ( -

- {upstream.description} -

- )} -
-
- )} - /> - )) + }} + > + {t( + allFilteredUpstreamsSelected + ? "deselectFilteredUpstreams" + : "selectFilteredUpstreams" + )} + +
+ {!upstreamsLoading && !!upstreams?.length && ( +

+ {t("filteredUpstreamsSelected", { + selected: selectedFilteredCount, + total: filteredUpstreamIds.length, + })} +

)} -
- - - )} - /> - )} +
+ {upstreamsLoading ? ( +
+ {tCommon("loading")} +
+ ) : !upstreams || upstreams.length === 0 ? ( +
+ {tCommon("noData")} +
+ ) : filteredUpstreams.length === 0 ? ( +
+ {t("noMatchingUpstreams")} +
+ ) : ( + filteredUpstreams.map((upstream) => ( + ( + + + { + const updated = checked + ? [...(field.value || []), upstream.id] + : field.value?.filter((id) => id !== upstream.id); + field.onChange(updated); + }} + /> + +
+ + {upstream.description && ( +

+ {upstream.description} +

+ )} +
+
+ )} + /> + )) + )} +
+ + + )} + /> +
+
@@ -486,40 +495,45 @@ export function EditKeyDialog({ apiKey, open, onOpenChange }: EditKeyDialogProps )} /> -
- ( - - {t("quotaLimitUsd")} - - - field.onChange( - event.target.value === "" - ? undefined - : Number(event.target.value) - ) - } - placeholder={t("quotaLimitPlaceholder")} - /> - - - - )} - /> + ( + + {t("quotaLimitUsd")} + + + field.onChange( + event.target.value === "" + ? undefined + : Number(event.target.value) + ) + } + placeholder={t("quotaLimitPlaceholder")} + /> + + + + )} + /> - {rulePeriodType === "rolling" && ( +
+
( - + {t("quotaPeriodHours")} )} /> - )} +
); diff --git a/src/components/admin/keys-table.tsx b/src/components/admin/keys-table.tsx index 610927c..1efc53a 100644 --- a/src/components/admin/keys-table.tsx +++ b/src/components/admin/keys-table.tsx @@ -2,8 +2,8 @@ import { formatDistanceToNow } from "date-fns"; import { useLocale, useTranslations } from "next-intl"; -import { Trash2, Copy, Check, Key, Eye, EyeOff, Pencil } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Trash2, Copy, Check, Key, Eye, EyeOff, Pencil, ChevronRight } from "lucide-react"; +import { Fragment, useEffect, useState } from "react"; import type { APIKey } from "@/types/api"; import { useRevealAPIKey } from "@/hooks/use-api-keys"; import { useToggleAPIKeyActive } from "@/hooks/use-api-keys"; @@ -53,6 +53,7 @@ export function KeysTable({ keys, onRevoke, onEdit }: KeysTableProps) { const [visibleKeyIds, setVisibleKeyIds] = useState>(new Set()); const [revealedKeys, setRevealedKeys] = useState>(new Map()); const [searchQuery, setSearchQuery] = useState(""); + const [expandedKeys, setExpandedKeys] = useState>(new Set()); const [isMobileLayout, setIsMobileLayout] = useState(false); const { mutateAsync: revealKey, isPending: isRevealing } = useRevealAPIKey(); const toggleActiveMutation = useToggleAPIKeyActive(); @@ -86,6 +87,15 @@ export function KeysTable({ keys, onRevoke, onEdit }: KeysTableProps) { }; }, []); + const toggleExpand = (keyId: string) => { + setExpandedKeys((prev) => { + const next = new Set(prev); + if (next.has(keyId)) next.delete(keyId); + else next.add(keyId); + return next; + }); + }; + const maskKey = (keyPrefix: string) => { if (keyPrefix.length < 12) return keyPrefix; const start = keyPrefix.slice(0, 8); @@ -203,7 +213,16 @@ export function KeysTable({ keys, onRevoke, onEdit }: KeysTableProps) { } return ( -
+
{key.spending_rule_statuses.map((rule, index) => { const timeText = rule.period_type === "rolling" @@ -234,22 +253,21 @@ export function KeysTable({ keys, onRevoke, onEdit }: KeysTableProps) { : "border-divider/80 bg-surface-300/70" )} > -
-
-

- {formatQuotaPeriodLabel(rule, t)} -

-

- {formatQuotaAmount(rule.current_spending)} /{" "} - {formatQuotaAmount(rule.spending_limit)} -

-
- - {t("quotaPercentUsed", { percent: rule.percent_used.toFixed(1) })} - +
+ + {formatQuotaPeriodLabel(rule, t)} + + + {rule.percent_used.toFixed(1)}% +
-
+
-
- - {rule.is_exceeded ? t("quotaExceeded") : t("quotaWithinLimit")} - +
+ + {formatQuotaAmount(rule.current_spending)} /{" "} + {formatQuotaAmount(rule.spending_limit)} + {timeText ? ( - {timeText} + {timeText} ) : null}
@@ -289,13 +308,18 @@ export function KeysTable({ keys, onRevoke, onEdit }: KeysTableProps) { if (filteredKeys.length === 0) { return (
- setSearchQuery(e.target.value)} - className="max-w-md" - /> +
+ setSearchQuery(e.target.value)} + className="max-w-sm" + /> + + {filteredKeys.length} / {keys.length} + +