From 00415aac0101b3ec5bdd3e4de1f5184d4b035ae1 Mon Sep 17 00:00:00 2001
From: madroid
Date: Fri, 19 Jun 2026 18:51:46 +0800
Subject: [PATCH] feat(api-keys): add daily-spending-limit input and column
Add per-key daily-spending-limit (and the long-missing spending-limit) input fields to the API key edit dialog, a dedicated table column, and i18n entries (zh-CN/en). Mirrors CliRelay backend PR #471 (per-day cost cap that resets daily).
Cost-limit inputs render in Unrestricted/Custom permission modes and are submitted via float parsing. PermissionProfile application zeroes them, consistent with spending-limit (per backend EffectiveAPIKeyRowWithProfiles).
Co-Authored-By: Claude
---
.../endpoints/api-key-permission-profiles.ts | 1 +
packages/api-client/src/endpoints/api-keys.ts | 1 +
packages/i18n/src/locales/en.json | 4 +++
packages/i18n/src/locales/zh-CN.json | 4 +++
pages/api-keys/ApiKeysPage.tsx | 35 ++++++++++++-------
pages/api-keys/apiKeyPageUtils.tsx | 6 ++++
pages/api-keys/components/ApiKeyColumns.tsx | 26 ++++++++++++++
.../api-keys/components/ApiKeyFormFields.tsx | 34 ++++++++++++++++++
.../__tests__/ApiKeyColumns.test.tsx | 33 +++++++++++++++++
pages/api-keys/types.ts | 1 +
10 files changed, 132 insertions(+), 13 deletions(-)
diff --git a/packages/api-client/src/endpoints/api-key-permission-profiles.ts b/packages/api-client/src/endpoints/api-key-permission-profiles.ts
index 7edf6475..c50965dc 100644
--- a/packages/api-client/src/endpoints/api-key-permission-profiles.ts
+++ b/packages/api-client/src/endpoints/api-key-permission-profiles.ts
@@ -112,6 +112,7 @@ export function applyApiKeyPermissionProfile(
"daily-limit": 0,
"total-quota": 0,
"spending-limit": 0,
+ "daily-spending-limit": 0,
"concurrency-limit": 0,
"rpm-limit": 0,
"tpm-limit": 0,
diff --git a/packages/api-client/src/endpoints/api-keys.ts b/packages/api-client/src/endpoints/api-keys.ts
index 429787fb..62475637 100644
--- a/packages/api-client/src/endpoints/api-keys.ts
+++ b/packages/api-client/src/endpoints/api-keys.ts
@@ -8,6 +8,7 @@ export interface ApiKeyEntry {
"daily-limit"?: number;
"total-quota"?: number;
"spending-limit"?: number;
+ "daily-spending-limit"?: number;
"concurrency-limit"?: number;
"rpm-limit"?: number;
"tpm-limit"?: number;
diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json
index 25573835..dfc076fe 100644
--- a/packages/i18n/src/locales/en.json
+++ b/packages/i18n/src/locales/en.json
@@ -2609,7 +2609,9 @@
"col_daily_limit": "Daily limit",
"col_total_quota": "Total quota",
"col_spending_limit": "Spending limit",
+ "col_daily_spending_limit": "Daily spending limit",
"spending_limit_help": "Maximum cumulative API key cost in USD. Empty or 0 means unlimited.",
+ "daily_spending_limit_help": "Maximum daily API key cost in USD. Resets at midnight (project timezone). Empty or 0 means unlimited. Requires model pricing to be configured.",
"col_models": "Models",
"col_channel_groups": "Channel groups",
"col_channels": "Channels",
@@ -2650,6 +2652,8 @@
"form_permission_profile_desc": "Permission configs contain limits, channel/model restrictions, and prompt text. Empty uses the unrestricted default.",
"permission_profile_unrestricted": "Default: unrestricted",
"permission_profile_custom_keep": "Current custom config (keep)",
+ "form_spending_limit": "Spending limit ($)",
+ "form_daily_spending_limit": "Daily spending limit ($)",
"form_daily_limit": "Daily request limit",
"form_total_quota": "Total request quota",
"form_concurrency_limit": "Concurrent request limit",
diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json
index 3e0286f1..a8ed4541 100644
--- a/packages/i18n/src/locales/zh-CN.json
+++ b/packages/i18n/src/locales/zh-CN.json
@@ -2878,7 +2878,9 @@
"col_daily_limit": "每日限额",
"col_total_quota": "总配额",
"col_spending_limit": "消费限额",
+ "col_daily_spending_limit": "每日消费限额",
"spending_limit_help": "该 API 密钥允许累计消费的美元上限,留空或 0 表示不限制。",
+ "daily_spending_limit_help": "该 API 密钥每天允许消费的美元上限,按天重置,留空或 0 表示不限制。需要配置模型定价。",
"col_models": "模型",
"col_channel_groups": "渠道分组",
"col_channels": "渠道",
@@ -2919,6 +2921,8 @@
"form_permission_profile_desc": "权限配置包含限额、渠道/模型权限和提示词;不选择则使用默认无限制。",
"permission_profile_unrestricted": "默认:不限制",
"permission_profile_custom_keep": "当前自定义配置(保留)",
+ "form_spending_limit": "累计消费上限 ($)",
+ "form_daily_spending_limit": "每日消费上限 ($)",
"form_daily_limit": "每日请求限额",
"form_total_quota": "总请求配额",
"form_concurrency_limit": "并发请求限制",
diff --git a/pages/api-keys/ApiKeysPage.tsx b/pages/api-keys/ApiKeysPage.tsx
index dab1b650..ef271fa7 100644
--- a/pages/api-keys/ApiKeysPage.tsx
+++ b/pages/api-keys/ApiKeysPage.tsx
@@ -16,7 +16,7 @@ import {
import { ccSwitchImportConfigsApi } from "@code-proxy/api-client/endpoints/ccswitch-import-configs";
import { detectApiBaseFromLocation } from "@code-proxy/api-client";
import { useOptionalAuth } from "@app/providers/AuthProvider";
-import { generateApiKey, makeEmptyApiKeyForm, maskApiKey } from "./apiKeyPageUtils";
+import { generateApiKey, makeEmptyApiKeyForm, maskApiKey, parseSpendingLimit } from "./apiKeyPageUtils";
import { createApiKeyColumns } from "./components/ApiKeyColumns";
import { DeleteApiKeyModal } from "./components/DeleteApiKeyModal";
import { copyTextToClipboard } from "@code-proxy/ui";
@@ -285,6 +285,7 @@ export function ApiKeysPage() {
dailyLimit: entry["daily-limit"]?.toString() || "",
totalQuota: entry["total-quota"]?.toString() || "",
spendingLimit: entry["spending-limit"]?.toString() || "",
+ dailySpendingLimit: entry["daily-spending-limit"]?.toString() || "",
concurrencyLimit: entry["concurrency-limit"]?.toString() || "",
rpmLimit: entry["rpm-limit"]?.toString() || "",
tpmLimit: entry["tpm-limit"]?.toString() || "",
@@ -312,25 +313,33 @@ export function ApiKeysPage() {
}
setSaving(true);
try {
+ const useCustomLimits =
+ form.permissionProfileId === CUSTOM_PERMISSION_PROFILE_ID ||
+ form.permissionProfileId === "";
await apiKeyEntriesApi.update({
id: entries[editIndex].id,
index: editIndex,
value: {
...(newKey !== originalKey ? { key: newKey } : {}),
name: form.name.trim(),
- ...(form.permissionProfileId === CUSTOM_PERMISSION_PROFILE_ID
+ ...(useCustomLimits
? {
- "permission-profile-id": entries[editIndex]["permission-profile-id"] ?? "",
- "daily-limit": entries[editIndex]["daily-limit"] ?? 0,
- "total-quota": entries[editIndex]["total-quota"] ?? 0,
- "spending-limit": entries[editIndex]["spending-limit"] ?? 0,
- "concurrency-limit": entries[editIndex]["concurrency-limit"] ?? 0,
- "rpm-limit": entries[editIndex]["rpm-limit"] ?? 0,
- "tpm-limit": entries[editIndex]["tpm-limit"] ?? 0,
- "allowed-models": entries[editIndex]["allowed-models"] ?? [],
- "allowed-channels": entries[editIndex]["allowed-channels"] ?? [],
- "allowed-channel-groups": entries[editIndex]["allowed-channel-groups"] ?? [],
- "system-prompt": entries[editIndex]["system-prompt"] ?? "",
+ ...(form.permissionProfileId === ""
+ ? applyApiKeyPermissionProfile({} as ApiKeyEntry, null)
+ : {
+ "permission-profile-id": entries[editIndex]["permission-profile-id"] ?? "",
+ "daily-limit": entries[editIndex]["daily-limit"] ?? 0,
+ "total-quota": entries[editIndex]["total-quota"] ?? 0,
+ "concurrency-limit": entries[editIndex]["concurrency-limit"] ?? 0,
+ "rpm-limit": entries[editIndex]["rpm-limit"] ?? 0,
+ "tpm-limit": entries[editIndex]["tpm-limit"] ?? 0,
+ "allowed-models": entries[editIndex]["allowed-models"] ?? [],
+ "allowed-channels": entries[editIndex]["allowed-channels"] ?? [],
+ "allowed-channel-groups": entries[editIndex]["allowed-channel-groups"] ?? [],
+ "system-prompt": entries[editIndex]["system-prompt"] ?? "",
+ }),
+ "spending-limit": parseSpendingLimit(form.spendingLimit),
+ "daily-spending-limit": parseSpendingLimit(form.dailySpendingLimit),
}
: applyApiKeyPermissionProfile(
{} as ApiKeyEntry,
diff --git a/pages/api-keys/apiKeyPageUtils.tsx b/pages/api-keys/apiKeyPageUtils.tsx
index 7130673c..ddd23a5e 100644
--- a/pages/api-keys/apiKeyPageUtils.tsx
+++ b/pages/api-keys/apiKeyPageUtils.tsx
@@ -60,6 +60,7 @@ export const makeEmptyApiKeyForm = (key = ""): ApiKeyFormValues => ({
dailyLimit: "",
totalQuota: "",
spendingLimit: "",
+ dailySpendingLimit: "",
concurrencyLimit: "",
rpmLimit: "",
tpmLimit: "",
@@ -114,6 +115,11 @@ export const formatApiKeySpendingLimit = (limit: number | undefined) => {
}).format(limit);
};
+export const parseSpendingLimit = (value: string): number => {
+ const parsed = Number.parseFloat(value);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
+};
+
export const normalizeChannelKey = (value: string) => value.trim().toLowerCase();
export const readAuthFileChannelName = (file: AuthFileItem): string => {
diff --git a/pages/api-keys/components/ApiKeyColumns.tsx b/pages/api-keys/components/ApiKeyColumns.tsx
index f83fc10a..e9679e89 100644
--- a/pages/api-keys/components/ApiKeyColumns.tsx
+++ b/pages/api-keys/components/ApiKeyColumns.tsx
@@ -151,6 +151,32 @@ export const createApiKeyColumns = ({
),
},
+ {
+ key: "dailySpendingLimit",
+ label: t("api_keys_page.col_daily_spending_limit"),
+ width: "w-[148px] min-w-[148px]",
+ cellClassName: "whitespace-nowrap text-slate-700 dark:text-white/70",
+ headerRender: () => (
+
+ {t("api_keys_page.col_daily_spending_limit")}
+
+
+ ),
+ render: (row) => (
+
+ {!row["daily-spending-limit"] ? (
+ <>
+ {t("api_keys_page.unlimited")}
+ >
+ ) : (
+ formatApiKeySpendingLimit(row["daily-spending-limit"])
+ )}
+
+ ),
+ },
{
key: "rpmLimit",
label: "RPM",
diff --git a/pages/api-keys/components/ApiKeyFormFields.tsx b/pages/api-keys/components/ApiKeyFormFields.tsx
index bbb5b181..3a8a8282 100644
--- a/pages/api-keys/components/ApiKeyFormFields.tsx
+++ b/pages/api-keys/components/ApiKeyFormFields.tsx
@@ -2,6 +2,7 @@ import { RefreshCw } from "lucide-react";
import { Button } from "@code-proxy/ui";
import { TextInput } from "@code-proxy/ui";
import { Select, type SelectOption } from "@code-proxy/ui";
+import { CUSTOM_PERMISSION_PROFILE_ID } from "@code-proxy/api-client/endpoints/api-key-permission-profiles";
import type { ApiKeyFormValues } from "../types";
export function ApiKeyFormFields({
@@ -67,6 +68,39 @@ export function ApiKeyFormFields({
{t("api_keys_page.form_permission_profile_desc")}
+
+ {editMode &&
+ (form.permissionProfileId === "" ||
+ form.permissionProfileId === CUSTOM_PERMISSION_PROFILE_ID) && (
+
+
+
+ setForm((prev) => ({ ...prev, spendingLimit: e.target.value }))}
+ placeholder={t("api_keys_page.form_unlimited_hint")}
+ />
+
+
+
+ setForm((prev) => ({ ...prev, dailySpendingLimit: e.target.value }))}
+ placeholder={t("api_keys_page.form_unlimited_hint")}
+ />
+
+
+ )}
);
}
diff --git a/pages/api-keys/components/__tests__/ApiKeyColumns.test.tsx b/pages/api-keys/components/__tests__/ApiKeyColumns.test.tsx
index 682db020..b84c9a66 100644
--- a/pages/api-keys/components/__tests__/ApiKeyColumns.test.tsx
+++ b/pages/api-keys/components/__tests__/ApiKeyColumns.test.tsx
@@ -12,6 +12,9 @@ const t = ((key: string) => {
"api_keys_page.col_spending_limit": "Spending limit",
"api_keys_page.spending_limit_help":
"Maximum cumulative API key cost in USD. Empty means unlimited.",
+ "api_keys_page.col_daily_spending_limit": "Daily spending limit",
+ "api_keys_page.daily_spending_limit_help":
+ "Maximum daily API key cost in USD. Empty means unlimited.",
"api_keys_page.unlimited": "Unlimited",
"api_keys_page.view_usage": "View usage",
"api_keys_page.copy_key": "Copy key",
@@ -134,4 +137,34 @@ describe("ApiKeyColumns", () => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Maximum cumulative API key cost");
});
+
+ test("shows API key daily spending limits as a dedicated cost column", async () => {
+ const row: ApiKeyEntry = {
+ key: "sk-test",
+ name: "Test key",
+ "daily-spending-limit": 0.5,
+ "created-at": "2026-04-28T00:00:00Z",
+ };
+ const columns = createApiKeyColumns({
+ t,
+ onCopy: vi.fn(),
+ onDelete: vi.fn(),
+ onEdit: vi.fn(),
+ onImportToCcSwitch: vi.fn(),
+ onToggleDisable: vi.fn(),
+ onViewUsage: vi.fn(),
+ });
+ const dailyColumn = columns.find((column) => column.key === "dailySpendingLimit");
+
+ expect(dailyColumn?.label).toBe("Daily spending limit");
+
+ render(
+ <>
+ {dailyColumn?.headerRender?.()}
+ {dailyColumn?.render(row, 0)}
+ >,
+ );
+
+ expect(screen.getByText("$0.50")).toBeInTheDocument();
+ });
});
diff --git a/pages/api-keys/types.ts b/pages/api-keys/types.ts
index 5e212a14..e36e0c99 100644
--- a/pages/api-keys/types.ts
+++ b/pages/api-keys/types.ts
@@ -5,6 +5,7 @@ export interface ApiKeyFormValues {
dailyLimit: string;
totalQuota: string;
spendingLimit: string;
+ dailySpendingLimit: string;
concurrencyLimit: string;
rpmLimit: string;
tpmLimit: string;