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;