From 25eeeb7cc6c412a5b6440832c69a497f30eb94e5 Mon Sep 17 00:00:00 2001 From: mci77777 Date: Tue, 5 May 2026 18:40:43 +0800 Subject: [PATCH] feat(my-usage): expose quota compatibility fields --- .../api-key-quota-extractor-compatible.js | 186 +++++++++++++ docs/usage-only-quota-extractor.md | 73 +++++ src/actions/my-usage.ts | 251 +++++++++++++++++- src/app/api/actions/[...route]/route.ts | 61 +++++ tests/api/my-usage-readonly.test.ts | 101 ++++++- .../actions/total-usage-semantics.test.ts | 232 ++++++++++++++++ 6 files changed, 901 insertions(+), 3 deletions(-) create mode 100644 docs/examples/api-key-quota-extractor-compatible.js create mode 100644 docs/usage-only-quota-extractor.md diff --git a/docs/examples/api-key-quota-extractor-compatible.js b/docs/examples/api-key-quota-extractor-compatible.js new file mode 100644 index 000000000..03daea8ae --- /dev/null +++ b/docs/examples/api-key-quota-extractor-compatible.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node +/** + * Claude Code Hub getMyQuota extractor for ccswitch-style quota checks. + * + * Direct usage: + * node docs/examples/api-key-quota-extractor-compatible.js https://cch.example.com sk-your-api-key + * + * ccswitch template usage: + * Import or paste the exported `ccswitchTemplate` object. + * + * The request is: + * POST /api/actions/my-usage/getMyQuota + * Authorization: Bearer + * Content-Type: application/json + * Body: {} + */ + +function toNumber(value, fallback = null) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function toBoolean(value, fallback = false) { + return typeof value === "boolean" ? value : fallback; +} + +function toStringOrNull(value) { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function pickWindow(quotaWindows, name) { + if (!quotaWindows || typeof quotaWindows !== "object") { + return {}; + } + const value = quotaWindows[name]; + return value && typeof value === "object" ? value : {}; +} + +function normalizeQuotaResponse(response) { + const data = + response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const quotaWindows = + data.quotaWindows && typeof data.quotaWindows === "object" ? data.quotaWindows : {}; + const fiveHour = pickWindow(quotaWindows, "fiveHour"); + const daily = pickWindow(quotaWindows, "daily"); + const weekly = pickWindow(quotaWindows, "weekly"); + const monthly = pickWindow(quotaWindows, "monthly"); + const total = pickWindow(quotaWindows, "total"); + + const keyEnabled = toBoolean(data.keyIsEnabled, true); + const userEnabled = toBoolean(data.userIsEnabled, true); + const remaining = toNumber(data.remaining, toNumber(total.remainingUsd, null)); + const todayRemaining = toNumber( + data.todayRemainingUsd, + toNumber(daily.remainingUsd, toNumber(data.remainingDailyUsd, null)) + ); + + return { + ok: response && response.ok === true, + isValid: response && response.ok === true && keyEnabled && userEnabled, + invalidMessage: response && response.ok === true ? undefined : "Quota request failed", + + planName: "Claude Code Hub Usage", + unit: typeof data.unit === "string" ? data.unit : "USD", + + keyName: toStringOrNull(data.keyName), + userName: toStringOrNull(data.userName), + providerGroup: toStringOrNull(data.providerGroup), + keyIsEnabled: keyEnabled, + userIsEnabled: userEnabled, + + remaining, + todayRemaining, + todayUsed: toNumber(data.todayUsedUsd, toNumber(daily.usedUsd, 0)), + todayUsedPercent: toNumber(data.todayUsedPercent, toNumber(daily.usedPercent, null)), + todayRemainingPercent: toNumber( + data.todayRemainingPercent, + toNumber(daily.remainingPercent, null) + ), + remainingPercent: toNumber(data.remainingPercent, toNumber(total.remainingPercent, null)), + + remaining5h: toNumber(fiveHour.remainingUsd, toNumber(data.remaining5hUsd, null)), + remainingDaily: toNumber(daily.remainingUsd, toNumber(data.remainingDailyUsd, null)), + remainingWeekly: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + remainingMonthly: toNumber(monthly.remainingUsd, toNumber(data.remainingMonthlyUsd, null)), + remainingTotal: toNumber(total.remainingUsd, toNumber(data.remainingTotalUsd, null)), + + total: toNumber(total.limitUsd, toNumber(data.limitTotalUsd, null)), + used: toNumber(total.usedUsd, toNumber(data.usedTotalUsd, 0)), + rpmLimit: toNumber(data.rpmLimit, null), + concurrentSessions: toNumber(data.concurrentSessions, 0), + concurrentSessionsLimit: toNumber(data.concurrentSessionsLimit, null), + expiresAt: toStringOrNull(data.expiresAt), + resetMode: toStringOrNull(data.resetMode), + resetTime: toStringOrNull(data.resetTime), + + quotaWindows: { + fiveHour, + daily, + weekly, + monthly, + total, + }, + + balance: remaining, + dailyBalance: todayRemaining, + weeklyBalance: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + monthlyBalance: toNumber(monthly.remainingUsd, toNumber(data.remainingMonthlyUsd, null)), + extra: [ + `5h=${toNumber(fiveHour.remainingUsd, "unlimited")}`, + `daily=${todayRemaining ?? "unlimited"}`, + `weekly=${toNumber(weekly.remainingUsd, "unlimited")}`, + `monthly=${toNumber(monthly.remainingUsd, "unlimited")}`, + `total=${toNumber(total.remainingUsd, remaining ?? "unlimited")}`, + ].join(" "), + }; +} + +const ccswitchTemplate = { + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + Authorization: "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0", + }, + body: "{}", + }, + extractor: normalizeQuotaResponse, +}; + +async function fetchQuota(baseUrl, apiKey) { + const response = await fetch(new URL("/api/actions/my-usage/getMyQuota", baseUrl), { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0", + }, + body: "{}", + }); + + const text = await response.text(); + let payload; + try { + payload = JSON.parse(text); + } catch { + throw new Error(`Quota API did not return JSON: HTTP ${response.status}`); + } + + if (!response.ok || payload.ok !== true) { + throw new Error(payload && typeof payload.error === "string" ? payload.error : `HTTP ${response.status}`); + } + + return normalizeQuotaResponse(payload); +} + +async function main() { + const [, , baseUrl, apiKey] = process.argv; + if (!baseUrl || !apiKey) { + process.stderr.write( + "Usage: node docs/examples/api-key-quota-extractor-compatible.js \n" + ); + process.exitCode = 1; + return; + } + + const quota = await fetchQuota(baseUrl, apiKey); + process.stdout.write(`${JSON.stringify(quota, null, 2)}\n`); +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + ccswitchTemplate, + fetchQuota, + normalizeQuotaResponse, +}; diff --git a/docs/usage-only-quota-extractor.md b/docs/usage-only-quota-extractor.md new file mode 100644 index 000000000..7fe693759 --- /dev/null +++ b/docs/usage-only-quota-extractor.md @@ -0,0 +1,73 @@ +# Usage-Only Quota Extractor + +This note documents the minimal compatibility path for external quota checks. +It does not add a new endpoint or a new authentication surface. + +## Endpoint + +Call the existing action route with the API key as a Bearer token: + +```bash +curl -sS "$CCH_BASE_URL/api/actions/my-usage/getMyQuota" \ + -H "Authorization: Bearer $CCH_API_KEY" \ + -H "Content-Type: application/json" \ + -X POST \ + --data '{}' +``` + +The route is `POST /api/actions/my-usage/getMyQuota`. The request body is `{}`. +It uses the existing `allowReadOnlyAccess` path, so read-only keys can query +their own usage data without gaining access to admin-only actions. + +## Response Fields + +The response shape is the standard action wrapper: + +- `ok`: true when the action succeeds. +- `data`: quota payload for the current key and user. + +Useful compatibility fields under `data` include: + +- `remaining`: the most restrictive remaining USD amount across configured quota windows, or `null` when unlimited. +- `todayRemainingUsd`: remaining USD amount for the daily window. +- `todayUsedUsd`: used USD amount for the daily window. +- `todayRemainingPercent`: remaining percentage for the daily window. +- `remainingPercent`: the most restrictive remaining percentage across configured quota windows. +- `quotaWindows`: structured `fiveHour`, `daily`, `weekly`, `monthly`, and `total` quota windows. +- `remaining5hUsd`, `remainingDailyUsd`, `remainingWeeklyUsd`, `remainingMonthlyUsd`, `remainingTotalUsd`: flat remaining aliases. +- `rpmLimit`, `concurrentSessions`, `concurrentSessionsLimit`: rate and session limits. +- `keyName`, `userName`, `providerGroup`, `keyIsEnabled`, `userIsEnabled`: key and user metadata. + +Each `quotaWindows.*` entry contains: + +- `period` +- `limitUsd` +- `usedUsd` +- `remainingUsd` +- `usedPercent` +- `remainingPercent` +- `isUnlimited` +- `isExhausted` + +## Example Script + +Use `docs/examples/api-key-quota-extractor-compatible.js` as either a direct +Node.js checker or as a ccswitch-style template source. + +Direct check: + +```bash +node docs/examples/api-key-quota-extractor-compatible.js "$CCH_BASE_URL" "$CCH_API_KEY" +``` + +The normalized output includes ccswitch-friendly fields such as `remaining`, +`todayRemaining`, `quotaWindows`, `balance`, `dailyBalance`, `weeklyBalance`, +and `monthlyBalance`. + +## PR Note + +This is a compatibility extension for clients that already consume usage data. +It documents and normalizes the existing `getMyQuota` response fields. It does +not introduce a public quota endpoint, does not accept API keys in request +bodies, and does not bypass the existing `allowReadOnlyAccess` authorization +gate. diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index a1536f2dc..67a943993 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -171,6 +171,17 @@ export interface MyUsageMetadata { billingModelSource: BillingModelSource; } +export interface MyUsageQuotaWindow { + period: "5h" | "daily" | "weekly" | "monthly" | "total"; + limitUsd: number | null; + usedUsd: number; + remainingUsd: number | null; + usedPercent: number | null; + remainingPercent: number | null; + isUnlimited: boolean; + isExhausted: boolean; +} + export interface MyUsageQuota { keyLimit5hUsd: number | null; keyLimitDailyUsd: number | null; @@ -208,12 +219,165 @@ export interface MyUsageQuota { keyName: string; keyIsEnabled: boolean; + providerGroup: string | null; + + limit5hUsd: number | null; + used5hUsd: number; + remaining5hUsd: number | null; + + limitDailyUsd: number | null; + usedDailyUsd: number; + remainingDailyUsd: number | null; + + limitWeeklyUsd: number | null; + usedWeeklyUsd: number; + remainingWeeklyUsd: number | null; + + limitMonthlyUsd: number | null; + usedMonthlyUsd: number; + remainingMonthlyUsd: number | null; + + limitTotalUsd: number | null; + usedTotalUsd: number; + remainingTotalUsd: number | null; + + quotaWindows: { + fiveHour: MyUsageQuotaWindow; + daily: MyUsageQuotaWindow; + weekly: MyUsageQuotaWindow; + monthly: MyUsageQuotaWindow; + total: MyUsageQuotaWindow; + }; + todayUsedUsd: number; + todayRemainingUsd: number | null; + todayUsedPercent: number | null; + todayRemainingPercent: number | null; + remainingPercent: number | null; + + rpmLimit: number | null; + concurrentSessions: number; + concurrentSessionsLimit: number | null; + userAllowedModels: string[]; userAllowedClients: string[]; expiresAt: Date | null; dailyResetMode: "fixed" | "rolling"; dailyResetTime: string; + resetMode: "fixed" | "rolling"; + resetTime: string; + remaining: number | null; + unit: "USD"; +} + +type EffectiveQuotaWindow = { + limit: number | null; + used: number; + remaining: number | null; +}; + +function normalizeQuotaLimit(limit: number | null | undefined): number | null { + if (limit == null || limit <= 0) { + return null; + } + + return limit; +} + +function clampRemaining(limit: number, used: number): number { + return Math.max(limit - used, 0); +} + +function resolveEffectiveQuotaWindow( + candidates: Array<{ limit: number | null | undefined; used: number }> +): EffectiveQuotaWindow { + const boundedCandidates = candidates + .map((candidate) => ({ + limit: normalizeQuotaLimit(candidate.limit), + used: candidate.used, + })) + .filter((candidate): candidate is { limit: number; used: number } => candidate.limit != null) + .map((candidate) => ({ + limit: candidate.limit, + used: candidate.used, + remaining: clampRemaining(candidate.limit, candidate.used), + })); + + if (boundedCandidates.length === 0) { + return { + limit: null, + used: Math.max(...candidates.map((candidate) => candidate.used), 0), + remaining: null, + }; + } + + const mostRestrictive = boundedCandidates.reduce((current, candidate) => { + if (candidate.remaining < current.remaining) { + return candidate; + } + + if (candidate.remaining === current.remaining && candidate.limit < current.limit) { + return candidate; + } + + return current; + }); + + return mostRestrictive; +} + +function resolveOverallRemaining(values: Array): number | null { + const boundedValues = values.filter((value): value is number => value != null); + if (boundedValues.length === 0) { + return null; + } + + return Math.max(Math.min(...boundedValues), 0); +} + +function round2(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +function buildQuotaWindow( + period: MyUsageQuotaWindow["period"], + window: EffectiveQuotaWindow +): MyUsageQuotaWindow { + const limitUsd = window.limit == null ? null : round2(window.limit); + const usedUsd = round2(window.used); + const remainingUsd = window.remaining == null ? null : round2(window.remaining); + const hasPositiveLimit = limitUsd != null && limitUsd > 0; + + return { + period, + limitUsd, + usedUsd, + remainingUsd, + usedPercent: hasPositiveLimit ? round2((window.used / limitUsd) * 100) : null, + remainingPercent: + hasPositiveLimit && remainingUsd != null ? round2((remainingUsd / limitUsd) * 100) : null, + isUnlimited: limitUsd == null, + isExhausted: remainingUsd != null && remainingUsd <= 0, + }; +} + +function resolveOverallRemainingPercent(windows: MyUsageQuotaWindow[]): number | null { + const values = windows + .map((window) => window.remainingPercent) + .filter((value): value is number => value != null); + + if (values.length === 0) { + return null; + } + + return Math.max(Math.min(...values), 0); +} + +function resolveTotalLimitWithMonthlyFallback(params: { + totalLimit: number | null | undefined; + monthlyLimit: number | null | undefined; +}): number | null { + return params.totalLimit ?? params.monthlyLimit ?? null; } export interface MyTodayStats { @@ -475,13 +639,59 @@ export async function getMyQuota(): Promise> { } = userCosts; const resolvedKeyCurrent5hUsd = keyFixed5hUsd ?? keyCurrent5hUsd; const resolvedUserCurrent5hUsd = userFixed5hUsd ?? userCurrent5hUsd; + const keyLimitTotalUsd = resolveTotalLimitWithMonthlyFallback({ + totalLimit: key.limitTotalUsd, + monthlyLimit: key.limitMonthlyUsd, + }); + const userLimitTotalUsd = resolveTotalLimitWithMonthlyFallback({ + totalLimit: user.limitTotalUsd, + monthlyLimit: user.limitMonthlyUsd, + }); + + const effective5h = resolveEffectiveQuotaWindow([ + { limit: key.limit5hUsd, used: resolvedKeyCurrent5hUsd }, + { limit: user.limit5hUsd, used: resolvedUserCurrent5hUsd }, + ]); + const effectiveDaily = resolveEffectiveQuotaWindow([ + { limit: key.limitDailyUsd, used: keyCostDaily }, + { limit: user.dailyQuota, used: userCostDaily }, + ]); + const effectiveWeekly = resolveEffectiveQuotaWindow([ + { limit: key.limitWeeklyUsd, used: keyCostWeekly }, + { limit: user.limitWeeklyUsd, used: userCostWeekly }, + ]); + const effectiveMonthly = resolveEffectiveQuotaWindow([ + { limit: key.limitMonthlyUsd, used: keyCostMonthly }, + { limit: user.limitMonthlyUsd, used: userCostMonthly }, + ]); + const effectiveTotal = resolveEffectiveQuotaWindow([ + { limit: keyLimitTotalUsd, used: keyTotalCost }, + { limit: userLimitTotalUsd, used: userTotalCost }, + ]); + const overallRemaining = resolveOverallRemaining([ + effective5h.remaining, + effectiveDaily.remaining, + effectiveWeekly.remaining, + effectiveMonthly.remaining, + effectiveTotal.remaining, + ]); + const quotaWindows = { + fiveHour: buildQuotaWindow("5h", effective5h), + daily: buildQuotaWindow("daily", effectiveDaily), + weekly: buildQuotaWindow("weekly", effectiveWeekly), + monthly: buildQuotaWindow("monthly", effectiveMonthly), + total: buildQuotaWindow("total", effectiveTotal), + }; + const concurrentSessions = Math.max(keyConcurrent, userKeyConcurrent); + const concurrentSessionsLimit = + effectiveKeyConcurrentLimit > 0 ? effectiveKeyConcurrentLimit : null; const quota: MyUsageQuota = { keyLimit5hUsd: key.limit5hUsd ?? null, keyLimitDailyUsd: key.limitDailyUsd ?? null, keyLimitWeeklyUsd: key.limitWeeklyUsd ?? null, keyLimitMonthlyUsd: key.limitMonthlyUsd ?? null, - keyLimitTotalUsd: key.limitTotalUsd ?? null, + keyLimitTotalUsd, keyLimitConcurrentSessions: effectiveKeyConcurrentLimit, keyCurrent5hUsd: resolvedKeyCurrent5hUsd, keyCurrentDailyUsd: keyCostDaily, @@ -493,7 +703,7 @@ export async function getMyQuota(): Promise> { userLimit5hUsd: user.limit5hUsd ?? null, userLimitWeeklyUsd: user.limitWeeklyUsd ?? null, userLimitMonthlyUsd: user.limitMonthlyUsd ?? null, - userLimitTotalUsd: user.limitTotalUsd ?? null, + userLimitTotalUsd, userLimitConcurrentSessions: user.limitConcurrentSessions ?? null, userRpmLimit: user.rpm ?? null, userCurrent5hUsd: resolvedUserCurrent5hUsd, @@ -513,12 +723,49 @@ export async function getMyQuota(): Promise> { keyName: key.name, keyIsEnabled: key.isEnabled ?? true, + providerGroup: key.providerGroup ?? user.providerGroup ?? null, + + limit5hUsd: effective5h.limit, + used5hUsd: effective5h.used, + remaining5hUsd: effective5h.remaining, + + limitDailyUsd: effectiveDaily.limit, + usedDailyUsd: effectiveDaily.used, + remainingDailyUsd: effectiveDaily.remaining, + + limitWeeklyUsd: effectiveWeekly.limit, + usedWeeklyUsd: effectiveWeekly.used, + remainingWeeklyUsd: effectiveWeekly.remaining, + + limitMonthlyUsd: effectiveMonthly.limit, + usedMonthlyUsd: effectiveMonthly.used, + remainingMonthlyUsd: effectiveMonthly.remaining, + + limitTotalUsd: effectiveTotal.limit, + usedTotalUsd: effectiveTotal.used, + remainingTotalUsd: effectiveTotal.remaining, + + quotaWindows, + todayUsedUsd: quotaWindows.daily.usedUsd, + todayRemainingUsd: quotaWindows.daily.remainingUsd, + todayUsedPercent: quotaWindows.daily.usedPercent, + todayRemainingPercent: quotaWindows.daily.remainingPercent, + remainingPercent: resolveOverallRemainingPercent(Object.values(quotaWindows)), + + rpmLimit: user.rpm ?? null, + concurrentSessions, + concurrentSessionsLimit, + userAllowedModels: user.allowedModels ?? [], userAllowedClients: user.allowedClients ?? [], expiresAt: key.expiresAt ?? null, dailyResetMode: key.dailyResetMode ?? "fixed", dailyResetTime: key.dailyResetTime ?? "00:00", + resetMode: key.dailyResetMode ?? "fixed", + resetTime: key.dailyResetTime ?? "00:00", + remaining: overallRemaining, + unit: "USD", }; return { ok: true, data: quota }; diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index 1e2db649d..4fe32579c 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/src/app/api/actions/[...route]/route.ts @@ -1205,6 +1205,17 @@ const { route: getMyUsageMetadataRoute, handler: getMyUsageMetadataHandler } = c ); app.openapi(getMyUsageMetadataRoute, getMyUsageMetadataHandler); +const myUsageQuotaWindowSchema = z.object({ + period: z.enum(["5h", "daily", "weekly", "monthly", "total"]), + limitUsd: z.number().nullable().describe("该周期有效限额;null 表示不限额"), + usedUsd: z.number().describe("该周期已用金额"), + remainingUsd: z.number().nullable().describe("该周期剩余金额;null 表示不限额"), + usedPercent: z.number().nullable().describe("该周期已用百分比;null 表示不限额或限额为 0"), + remainingPercent: z.number().nullable().describe("该周期剩余百分比;null 表示不限额或限额为 0"), + isUnlimited: z.boolean().describe("该周期是否不限额"), + isExhausted: z.boolean().describe("该周期是否已无剩余额度"), +}); + const { route: getMyQuotaRoute, handler: getMyQuotaHandler } = createActionRoute( "my-usage", "getMyQuota", @@ -1230,6 +1241,7 @@ const { route: getMyQuotaRoute, handler: getMyQuotaHandler } = createActionRoute userLimitMonthlyUsd: z.number().nullable(), userLimitTotalUsd: z.number().nullable(), userLimitConcurrentSessions: z.number().nullable(), + userRpmLimit: z.number().nullable(), userCurrent5hUsd: z.number(), userCurrentDailyUsd: z.number(), userCurrentWeeklyUsd: z.number(), @@ -1247,9 +1259,58 @@ const { route: getMyQuotaRoute, handler: getMyQuotaHandler } = createActionRoute keyName: z.string(), keyIsEnabled: z.boolean(), + providerGroup: z.string().nullable(), + + limit5hUsd: z.number().nullable(), + used5hUsd: z.number(), + remaining5hUsd: z.number().nullable(), + + limitDailyUsd: z.number().nullable(), + usedDailyUsd: z.number(), + remainingDailyUsd: z.number().nullable(), + + limitWeeklyUsd: z.number().nullable(), + usedWeeklyUsd: z.number(), + remainingWeeklyUsd: z.number().nullable(), + + limitMonthlyUsd: z.number().nullable(), + usedMonthlyUsd: z.number(), + remainingMonthlyUsd: z.number().nullable(), + + limitTotalUsd: z.number().nullable(), + usedTotalUsd: z.number(), + remainingTotalUsd: z.number().nullable(), + + quotaWindows: z.object({ + fiveHour: myUsageQuotaWindowSchema, + daily: myUsageQuotaWindowSchema, + weekly: myUsageQuotaWindowSchema, + monthly: myUsageQuotaWindowSchema, + total: myUsageQuotaWindowSchema, + }), + todayUsedUsd: z.number().describe("按当前 Key 日限额窗口计算的已用金额"), + todayRemainingUsd: z.number().nullable().describe("按当前 Key 日限额窗口计算的剩余金额"), + todayUsedPercent: z.number().nullable().describe("按当前 Key 日限额窗口计算的已用百分比"), + todayRemainingPercent: z + .number() + .nullable() + .describe("按当前 Key 日限额窗口计算的剩余百分比"), + remainingPercent: z.number().nullable().describe("所有已配置金额限额中最少的剩余百分比"), + + rpmLimit: z.number().nullable(), + concurrentSessions: z.number(), + concurrentSessionsLimit: z.number().nullable(), + + userAllowedModels: z.array(z.string()), + userAllowedClients: z.array(z.string()), + expiresAt: z.string().nullable(), dailyResetMode: z.enum(["fixed", "rolling"]), dailyResetTime: z.string(), + resetMode: z.enum(["fixed", "rolling"]), + resetTime: z.string(), + remaining: z.number().nullable(), + unit: z.literal("USD"), }), description: "获取当前会话的限额与当前使用量(仅返回自己的数据)", summary: "获取我的限额与用量", diff --git a/tests/api/my-usage-readonly.test.ts b/tests/api/my-usage-readonly.test.ts index ab5a6bb7e..a3a5f4293 100644 --- a/tests/api/my-usage-readonly.test.ts +++ b/tests/api/my-usage-readonly.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; -import { inArray } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys, messageRequest, usageLedger, users } from "@/drizzle/schema"; @@ -349,6 +349,28 @@ describe.skipIf(!process.env.DSN)("my-usage API:只读 Key 自助查询", () = }); createdKeyIds.push(readonlyKey.id); + await db + .update(users) + .set({ + rpmLimit: 60, + dailyLimitUsd: "15", + limit5hUsd: "12", + limitWeeklyUsd: "25", + limitMonthlyUsd: "35", + providerGroup: "default", + }) + .where(eq(users.id, user.id)); + + await db + .update(keys) + .set({ + limit5hUsd: "10", + limitDailyUsd: "20", + limitWeeklyUsd: "30", + limitMonthlyUsd: "40", + }) + .where(eq(keys.id, readonlyKey.id)); + const now = new Date(); const msgId = await createMessage({ userId: user.id, @@ -374,6 +396,83 @@ describe.skipIf(!process.env.DSN)("my-usage API:只读 Key 自助查询", () = expect(stats.json).toMatchObject({ ok: true }); expect((stats.json as any).data.calls).toBe(1); + const quota = await callActionsRoute({ + method: "POST", + pathname: "/api/actions/my-usage/getMyQuota", + headers: { Authorization: currentAuthorization }, + body: {}, + }); + expect(quota.response.status).toBe(200); + expect(quota.json).toMatchObject({ ok: true }); + + const quotaData = (quota.json as { ok: boolean; data: Record }).data; + expect(quotaData.keyName).toBe(readonlyKey.name); + expect(quotaData.userName).toBe(user.name); + expect(quotaData.providerGroup).toBe("default"); + expect(quotaData.keyIsEnabled).toBe(true); + expect(quotaData.userIsEnabled).toBe(true); + expect(quotaData.rpmLimit).toBe(60); + expect(quotaData.unit).toBe("USD"); + expect(quotaData.remaining).toBeTypeOf("number"); + expect(quotaData.remaining5hUsd).toBeTypeOf("number"); + expect(quotaData.remainingDailyUsd).toBeTypeOf("number"); + expect(quotaData.remainingWeeklyUsd).toBeTypeOf("number"); + expect(quotaData.remainingMonthlyUsd).toBeTypeOf("number"); + expect(quotaData.used5hUsd).toBeTypeOf("number"); + expect(quotaData.usedDailyUsd).toBeTypeOf("number"); + expect(quotaData.usedWeeklyUsd).toBeTypeOf("number"); + expect(quotaData.usedMonthlyUsd).toBeTypeOf("number"); + expect(quotaData.limit5hUsd).toBe(10); + expect(quotaData.limitDailyUsd).toBe(15); + expect(quotaData.limitWeeklyUsd).toBe(25); + expect(quotaData.limitMonthlyUsd).toBe(35); + expect(quotaData.limitTotalUsd).toBe(35); + expect(quotaData.todayUsedUsd).toBeCloseTo(0.01, 6); + expect(quotaData.todayRemainingUsd).toBeCloseTo(14.99, 6); + expect(quotaData.todayUsedPercent).toBeCloseTo(0.07, 6); + expect(quotaData.todayRemainingPercent).toBeCloseTo(99.93, 6); + expect(quotaData.remainingPercent).toBeCloseTo(99.9, 6); + expect(quotaData.quotaWindows).toMatchObject({ + fiveHour: { + period: "5h", + limitUsd: 10, + usedUsd: 0.01, + remainingUsd: 9.99, + usedPercent: 0.1, + remainingPercent: 99.9, + isUnlimited: false, + isExhausted: false, + }, + daily: { + period: "daily", + limitUsd: 15, + usedUsd: 0.01, + remainingUsd: 14.99, + usedPercent: 0.07, + remainingPercent: 99.93, + isUnlimited: false, + isExhausted: false, + }, + weekly: { + period: "weekly", + limitUsd: 25, + usedUsd: 0.01, + remainingUsd: 24.99, + }, + monthly: { + period: "monthly", + limitUsd: 35, + usedUsd: 0.01, + remainingUsd: 34.99, + }, + total: { + period: "total", + limitUsd: 35, + usedUsd: 0.01, + remainingUsd: 34.99, + }, + }); + // Issue #687 fix: getUsers 现在也支持 allowReadOnlyAccess const usersApi = await callActionsRoute({ method: "POST", diff --git a/tests/unit/actions/total-usage-semantics.test.ts b/tests/unit/actions/total-usage-semantics.test.ts index 7cd5c924f..c537fcfa2 100644 --- a/tests/unit/actions/total-usage-semantics.test.ts +++ b/tests/unit/actions/total-usage-semantics.test.ts @@ -25,6 +25,7 @@ const sumUserQuotaCostsMock = vi.fn(); const sumUserCostInTimeRangeMock = vi.fn(); const getTimeRangeForPeriodMock = vi.fn(); const getTimeRangeForPeriodWithModeMock = vi.fn(); +const getCurrentCostMock = vi.fn(); const getKeySessionCountMock = vi.fn(); const getUserSessionCountMock = vi.fn(); const findUserByIdMock = vi.fn(); @@ -46,6 +47,12 @@ vi.mock("@/lib/rate-limit/time-utils", () => ({ getTimeRangeForPeriodWithMode: (...args: unknown[]) => getTimeRangeForPeriodWithModeMock(...args), })); +vi.mock("@/lib/rate-limit/service", () => ({ + RateLimitService: { + getCurrentCost: (...args: unknown[]) => getCurrentCostMock(...args), + }, +})); + vi.mock("@/lib/session-tracker", () => ({ SessionTracker: { getKeySessionCount: (...args: unknown[]) => getKeySessionCountMock(...args), @@ -84,6 +91,7 @@ describe("total-usage-semantics", () => { // Default cost mocks sumUserTotalCostMock.mockResolvedValue(0); sumUserCostInTimeRangeMock.mockResolvedValue(0); + getCurrentCostMock.mockResolvedValue(0); getKeySessionCountMock.mockResolvedValue(0); getUserSessionCountMock.mockResolvedValue(0); @@ -200,6 +208,230 @@ describe("total-usage-semantics", () => { null ); }); + + it("should keep local usage API compatibility fields on getMyQuota", async () => { + getSessionMock.mockResolvedValue({ + key: { + id: 1, + key: "test-key-hash", + name: "Test Key", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + limit5hUsd: 10, + limitDailyUsd: 20, + limitWeeklyUsd: 30, + limitMonthlyUsd: 40, + limitTotalUsd: null, + limitConcurrentSessions: 3, + providerGroup: "default", + isEnabled: true, + expiresAt: null, + }, + user: { + id: 1, + name: "Test User", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + limit5hUsd: 12, + dailyQuota: 15, + limitWeeklyUsd: 25, + limitMonthlyUsd: 35, + limitTotalUsd: null, + limitConcurrentSessions: 2, + rpm: 60, + providerGroup: "default", + isEnabled: true, + expiresAt: null, + allowedModels: ["gpt-5.3-codex"], + allowedClients: ["codex-cli"], + }, + }); + sumKeyQuotaCostsByIdMock.mockResolvedValue({ + cost5h: 2, + costDaily: 3, + costWeekly: 4, + costMonthly: 5, + costTotal: 6, + }); + sumUserQuotaCostsMock.mockResolvedValue({ + cost5h: 1, + costDaily: 2, + costWeekly: 3, + costMonthly: 4, + costTotal: 5, + }); + getKeySessionCountMock.mockResolvedValue(1); + getUserSessionCountMock.mockResolvedValue(2); + + const { getMyQuota } = await import("@/actions/my-usage"); + const result = await getMyQuota(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.providerGroup).toBe("default"); + expect(result.data.userRpmLimit).toBe(60); + expect(result.data.rpmLimit).toBe(60); + expect(result.data.unit).toBe("USD"); + expect(result.data.resetMode).toBe("fixed"); + expect(result.data.resetTime).toBe("00:00"); + expect(result.data.userAllowedModels).toEqual(["gpt-5.3-codex"]); + expect(result.data.userAllowedClients).toEqual(["codex-cli"]); + expect(result.data.limit5hUsd).toBe(10); + expect(result.data.used5hUsd).toBe(2); + expect(result.data.remaining5hUsd).toBe(8); + expect(result.data.limitDailyUsd).toBe(15); + expect(result.data.usedDailyUsd).toBe(2); + expect(result.data.remainingDailyUsd).toBe(13); + expect(result.data.limitWeeklyUsd).toBe(25); + expect(result.data.usedWeeklyUsd).toBe(3); + expect(result.data.remainingWeeklyUsd).toBe(22); + expect(result.data.limitMonthlyUsd).toBe(35); + expect(result.data.usedMonthlyUsd).toBe(4); + expect(result.data.remainingMonthlyUsd).toBe(31); + expect(result.data.limitTotalUsd).toBe(35); + expect(result.data.usedTotalUsd).toBe(5); + expect(result.data.remainingTotalUsd).toBe(30); + expect(result.data.remaining).toBe(8); + expect(result.data.remainingPercent).toBeCloseTo(80, 6); + expect(result.data.concurrentSessions).toBe(2); + expect(result.data.concurrentSessionsLimit).toBe(3); + expect(result.data.todayUsedUsd).toBe(result.data.quotaWindows.daily.usedUsd); + expect(result.data.todayRemainingUsd).toBe(result.data.quotaWindows.daily.remainingUsd); + expect(result.data.todayUsedPercent).toBe(result.data.quotaWindows.daily.usedPercent); + expect(result.data.todayRemainingPercent).toBe( + result.data.quotaWindows.daily.remainingPercent + ); + expect(result.data.quotaWindows.fiveHour).toMatchObject({ + period: "5h", + limitUsd: 10, + usedUsd: 2, + remainingUsd: 8, + isUnlimited: false, + isExhausted: false, + }); + expect(result.data.quotaWindows.daily).toMatchObject({ + period: "daily", + limitUsd: 15, + usedUsd: 2, + remainingUsd: 13, + isUnlimited: false, + isExhausted: false, + }); + expect(result.data.quotaWindows.weekly).toMatchObject({ + period: "weekly", + limitUsd: 25, + usedUsd: 3, + remainingUsd: 22, + isUnlimited: false, + isExhausted: false, + }); + expect(result.data.quotaWindows.monthly).toMatchObject({ + period: "monthly", + limitUsd: 35, + usedUsd: 4, + remainingUsd: 31, + isUnlimited: false, + isExhausted: false, + }); + expect(result.data.quotaWindows.total).toMatchObject({ + period: "total", + limitUsd: 35, + usedUsd: 5, + remainingUsd: 30, + isUnlimited: false, + isExhausted: false, + }); + }); + + it("should treat zero quota limits as unlimited in compatibility fields", async () => { + getSessionMock.mockResolvedValue({ + key: { + id: 1, + key: "test-key-hash", + name: "Test Key", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + limit5hUsd: 0, + limitDailyUsd: 0, + limitWeeklyUsd: 0, + limitMonthlyUsd: 0, + limitTotalUsd: 0, + limitConcurrentSessions: 0, + providerGroup: null, + isEnabled: true, + expiresAt: null, + }, + user: { + id: 1, + name: "Test User", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + limit5hUsd: 0, + dailyQuota: 0, + limitWeeklyUsd: 0, + limitMonthlyUsd: 0, + limitTotalUsd: 0, + limitConcurrentSessions: 0, + rpm: null, + providerGroup: null, + isEnabled: true, + expiresAt: null, + allowedModels: [], + allowedClients: [], + }, + }); + sumKeyQuotaCostsByIdMock.mockResolvedValue({ + cost5h: 2, + costDaily: 3, + costWeekly: 4, + costMonthly: 5, + costTotal: 6, + }); + sumUserQuotaCostsMock.mockResolvedValue({ + cost5h: 1, + costDaily: 2, + costWeekly: 3, + costMonthly: 4, + costTotal: 5, + }); + + const { getMyQuota } = await import("@/actions/my-usage"); + const result = await getMyQuota(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.limit5hUsd).toBeNull(); + expect(result.data.limitDailyUsd).toBeNull(); + expect(result.data.limitWeeklyUsd).toBeNull(); + expect(result.data.limitMonthlyUsd).toBeNull(); + expect(result.data.limitTotalUsd).toBeNull(); + expect(result.data.remaining).toBeNull(); + expect(result.data.remainingPercent).toBeNull(); + expect(result.data.todayRemainingUsd).toBeNull(); + expect(result.data.todayUsedPercent).toBeNull(); + expect(result.data.todayRemainingPercent).toBeNull(); + expect(result.data.concurrentSessionsLimit).toBeNull(); + expect(result.data.quotaWindows.fiveHour).toMatchObject({ + limitUsd: null, + usedUsd: 2, + remainingUsd: null, + usedPercent: null, + remainingPercent: null, + isUnlimited: true, + isExhausted: false, + }); + expect(result.data.quotaWindows.daily).toMatchObject({ + limitUsd: null, + usedUsd: 3, + remainingUsd: null, + usedPercent: null, + remainingPercent: null, + isUnlimited: true, + isExhausted: false, + }); + }); }); describe("getUserAllLimitUsage in users.ts", () => {