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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/api-client/src/endpoints/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "渠道",
Expand Down Expand Up @@ -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": "并发请求限制",
Expand Down
35 changes: 22 additions & 13 deletions pages/api-keys/ApiKeysPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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() || "",
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions pages/api-keys/apiKeyPageUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const makeEmptyApiKeyForm = (key = ""): ApiKeyFormValues => ({
dailyLimit: "",
totalQuota: "",
spendingLimit: "",
dailySpendingLimit: "",
concurrencyLimit: "",
rpmLimit: "",
tpmLimit: "",
Expand Down Expand Up @@ -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 => {
Expand Down
26 changes: 26 additions & 0 deletions pages/api-keys/components/ApiKeyColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,32 @@ export const createApiKeyColumns = ({
</span>
),
},
{
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: () => (
<HoverTooltip
content={t("api_keys_page.daily_spending_limit_help")}
className="inline-flex items-center gap-1"
>
<span>{t("api_keys_page.col_daily_spending_limit")}</span>
<Info size={12} className="text-slate-400 dark:text-white/40" />
</HoverTooltip>
),
render: (row) => (
<span className="inline-flex items-center gap-1">
{!row["daily-spending-limit"] ? (
<>
<InfinityIcon size={14} className="text-green-500" /> {t("api_keys_page.unlimited")}
</>
) : (
formatApiKeySpendingLimit(row["daily-spending-limit"])
)}
</span>
),
},
{
key: "rpmLimit",
label: "RPM",
Expand Down
34 changes: 34 additions & 0 deletions pages/api-keys/components/ApiKeyFormFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -67,6 +68,39 @@ export function ApiKeyFormFields({
{t("api_keys_page.form_permission_profile_desc")}
</p>
</div>

{editMode &&
(form.permissionProfileId === "" ||
form.permissionProfileId === CUSTOM_PERMISSION_PROFILE_ID) && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-white/80">
{t("api_keys_page.form_spending_limit")}
</label>
<TextInput
type="number"
min="0"
step="0.01"
value={form.spendingLimit}
onChange={(e) => setForm((prev) => ({ ...prev, spendingLimit: e.target.value }))}
placeholder={t("api_keys_page.form_unlimited_hint")}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-white/80">
{t("api_keys_page.form_daily_spending_limit")}
</label>
<TextInput
type="number"
min="0"
step="0.01"
value={form.dailySpendingLimit}
onChange={(e) => setForm((prev) => ({ ...prev, dailySpendingLimit: e.target.value }))}
placeholder={t("api_keys_page.form_unlimited_hint")}
/>
</div>
</div>
)}
</div>
);
}
33 changes: 33 additions & 0 deletions pages/api-keys/components/__tests__/ApiKeyColumns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
<>
<div>{dailyColumn?.headerRender?.()}</div>
<div>{dailyColumn?.render(row, 0)}</div>
</>,
);

expect(screen.getByText("$0.50")).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions pages/api-keys/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface ApiKeyFormValues {
dailyLimit: string;
totalQuota: string;
spendingLimit: string;
dailySpendingLimit: string;
concurrencyLimit: string;
rpmLimit: string;
tpmLimit: string;
Expand Down