From 4d1478feb6ccfb0b1122413313777d0f9a671ca7 Mon Sep 17 00:00:00 2001
From: Jeff Gu
Date: Tue, 14 Apr 2026 17:59:03 +0800
Subject: [PATCH] =?UTF-8?q?feat(token):=20=E6=94=AF=E6=8C=81=E6=97=A0?=
=?UTF-8?q?=E9=99=90=E9=A2=9D=E5=BA=A6=E7=9A=84=E6=80=BB=E9=85=8D=E9=A2=9D?=
=?UTF-8?q?=E8=AE=BE=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 修改 TokenConfig 接口,允许 total_quota 为 -1 表示无限额度
- 更新 TokenUpsertEndpoint,处理新的 total_quota 验证逻辑
- 添加 TokenUtils 中的 normalizeQuota 方法,规范化配额值
- 在模型处理逻辑中检查剩余配额,确保按需收费
- 更新相关文档以反映新的配额逻辑
---
frontend/src/pages/Channels.tsx | 61 +-
frontend/src/pages/Tokens.tsx | 837 +++++++++++++----------
frontend/src/types/index.ts | 2 +-
src/admin/token_api.ts | 20 +-
src/admin/token_utils.ts | 68 ++
src/providers/models.ts | 5 +
src/providers/shared/channel-resolver.ts | 23 +-
type.d.ts | 2 +-
8 files changed, 598 insertions(+), 420 deletions(-)
diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx
index 1cdf6a8..a00d2a8 100644
--- a/frontend/src/pages/Channels.tsx
+++ b/frontend/src/pages/Channels.tsx
@@ -5,6 +5,7 @@ import { apiClient } from "@/api/client";
import { Channel, ChannelConfig, ChannelModelMapping } from "@/types";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
+import { ButtonGroup } from "@/components/ui/button-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
@@ -49,10 +50,15 @@ const channelTypes = [
];
const channelWeightOptions = Array.from({ length: 6 }, (_, weight) => ({
- value: String(weight),
- label: `权重 ${weight}`,
+ value: weight,
+ label: String(weight),
}));
+const modelEditorModeOptions = [
+ { value: "visual", label: "可视化" },
+ { value: "json", label: "JSON" },
+] as const;
+
const createEmptyModelRow = (): ModelRow => ({
id: "",
name: "",
@@ -1007,7 +1013,9 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
const modelCount = (config.models || []).length;
const enabledModelCount = (config.models || []).filter((model) => model.enabled !== false).length;
const modelSummary =
- enabledModelCount === modelCount ? `${modelCount} 个模型` : `${enabledModelCount}/${modelCount} 个模型`;
+ enabledModelCount === modelCount
+ ? `${modelCount} 个模型`
+ : `${enabledModelCount}/${modelCount} 个模型`;
const isMenuOpen = openMenu === channel.key;
const isEnabled = config.enabled !== false;
const isToggling =
@@ -1226,21 +1234,19 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
-
+ className="flex h-10 items-center px-2 gap-1"
+ buttonClassName="rounded-sm w-8 h-6 data-[state=on]:bg-amber-600 data-[state=on]:text-white"
+ />
相同模型权重优先,相同权重随机分配。
@@ -1330,7 +1336,9 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
/>
自动轮换
-
重试时随机切换同渠道其他密钥;关闭后始终重试当前密钥
+
+ 重试时随机切换同渠道其他密钥;关闭后始终重试当前密钥
+
@@ -1351,22 +1359,13 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
-
-
+
- )
+ );
}
// Form View
@@ -547,181 +625,190 @@ export function Tokens({
返回列表
-
{editingKey ? '编辑令牌' : '添加令牌'}
+ {editingKey ? "编辑令牌" : "添加令牌"}
- {editMode === 'form' ? : }
- {editMode === 'form' ? 'JSON' : '表单'}
+ {editMode === "form" ? : }
+ {editMode === "form" ? "JSON" : "表单"}
- {/* Token Key Section */}
-
-
- 令牌标识
- 用于 API 认证的唯一标识
-
- setTokenKey(e.target.value)}
- placeholder="sk-xxxxxxxxxxxxxxxx"
- disabled={!!editingKey}
- className="font-mono text-sm"
- />
- {!editingKey && (
- setTokenKey(generateTokenKey())}>
-
-
- )}
-
-
-
-
- {editMode === 'form' ? (
- <>
- {/* Basic Info */}
-
-
- 基本信息
-
-
-
- setFormData({ ...formData, name: e.target.value })}
- placeholder="例如:生产环境、测试用户"
- />
-
-
-
-
-
- {/* Channel Access */}
-
-
-
-
-
渠道访问权限
-
- {selectedChannels.length === 0 ? '允许访问所有渠道' : `已选择 ${selectedChannels.length} 个渠道`}
-
-
-
- {availableChannels.length === 0 ? (
-
-
-
-
暂无可用渠道
-
请先在渠道管理中添加渠道
-
-
- ) : (
-
- {availableChannels.map((channelKey) => (
-
- ))}
-
- )}
-
-
-
- {/* Quota */}
-
-
- 可用额度
-
- 令牌可用的总额度,单位为美元,当已用额度达到总额度时,令牌将无法调用模型。
-
-
-
{
- const nextValue = e.target.value
- if (!INTEGER_QUOTA_PATTERN.test(nextValue)) {
- return
- }
- setQuotaInputValue(nextValue)
- setFormData({ ...formData, total_quota: parseQuotaInputValue(nextValue) })
- }}
- placeholder="1"
- />
-
- {quotaPresets.map((preset) => (
- {
- setFormData({ ...formData, total_quota: preset.value })
- setQuotaInputValue(formatQuotaInputValue(preset.value))
- }}
- className={cn(
- "px-4 py-2 rounded-lg text-sm font-medium transition-all",
- formData.total_quota === preset.value
- ? "bg-primary text-primary-foreground shadow-sm"
- : "bg-muted hover:bg-muted/80"
- )}
- >
- {preset.label}
-
- ))}
-
-
-
-
- >
- ) : (
+ {/* Token Key Section */}
- JSON 配置
-
- )}
- {/* Actions */}
-
-
- 取消
-
-
- {saveMutation.isPending ? (
- <>
-
- 保存中...
- >
- ) : (
- <>
-
- 保存令牌
- >
- )}
-
-
+ {editMode === "form" ? (
+ <>
+ {/* Basic Info */}
+
+
+ 基本信息
+
+
+
+ setFormData({ ...formData, name: e.target.value })}
+ placeholder="例如:生产环境、测试用户"
+ />
+
+
+
+
+
+ {/* Channel Access */}
+
+
+
+
+
渠道访问权限
+
+ {selectedChannels.length === 0
+ ? "允许访问所有渠道"
+ : `已选择 ${selectedChannels.length} 个渠道`}
+
+
+
+ {availableChannels.length === 0 ? (
+
+
+
+
暂无可用渠道
+
请先在渠道管理中添加渠道
+
+
+ ) : (
+
+ {availableChannels.map((channelKey) => (
+
+ ))}
+
+ )}
+
+
+
+ {/* Quota */}
+
+
+ 可用额度
+ 额度耗尽后将不可调用付费模型。
+
+
+ {
+ const nextValue = e.target.value;
+ if (!QUOTA_INPUT_PATTERN.test(nextValue)) {
+ return;
+ }
+
+ setQuotaInputValue(nextValue);
+
+ if (nextValue === "-") {
+ return;
+ }
+
+ const parsedQuota = parseQuotaInputValue(nextValue);
+ if (parsedQuota === null) {
+ return;
+ }
+
+ setFormData({ ...formData, total_quota: parsedQuota });
+ }}
+ onBlur={() => {
+ setQuotaInputValue(formatQuotaInputValue(normalizeTokenQuota(formData.total_quota)));
+ }}
+ placeholder="输入美元额度,0 表示无额度,-1 表示无限"
+ />
+ {
+ setFormData({ ...formData, total_quota: value });
+ setQuotaInputValue(formatQuotaInputValue(value));
+ }}
+ className="h-10 items-center flex-nowrap"
+ buttonClassName="h-full w-full text-sm data-[state=on]:bg-green-100! data-[state=on]:text-white"
+ />
+
+
+
+
+ >
+ ) : (
+
+
+ JSON 配置
+
+
+ )}
+
+ {/* Actions */}
+
+
+ 取消
+
+
+ {saveMutation.isPending ? (
+ <>
+
+ 保存中...
+ >
+ ) : (
+ <>
+
+ 保存令牌
+ >
+ )}
+
+
- )
+ );
}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index a4a046d..0b19b99 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -35,7 +35,7 @@ export interface Token {
export interface TokenConfig {
name: string
channel_keys?: string[]
- total_quota: number
+ total_quota: number // 原始计费单位整数,-1 表示无限额度
}
export interface BillingConfig {
diff --git a/src/admin/token_api.ts b/src/admin/token_api.ts
index 0a593cf..db5973f 100644
--- a/src/admin/token_api.ts
+++ b/src/admin/token_api.ts
@@ -3,6 +3,7 @@ import { OpenAPIRoute } from 'chanfana';
import { z } from 'zod';
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
+import { TokenUtils } from "./token_utils";
// Token 列表 API
export class TokenListEndpoint extends OpenAPIRoute {
@@ -48,7 +49,8 @@ export class TokenUpsertEndpoint extends OpenAPIRoute {
schema: z.object({
name: z.string().describe('Token name'),
channel_keys: z.array(z.string()).describe('Channel keys to bind (empty array means access to all channels)'),
- total_quota: z.number().int().nonnegative().describe('Total quota amount in raw billing units'),
+ total_quota: z.union([z.literal(-1), z.number().int().nonnegative()])
+ .describe('Total quota amount in raw billing units, -1 means unlimited'),
}),
},
},
@@ -62,18 +64,22 @@ export class TokenUpsertEndpoint extends OpenAPIRoute {
async handle(c: Context) {
const body = await c.req.json();
+ const normalizedBody: ApiTokenData = {
+ ...body,
+ total_quota: TokenUtils.normalizeQuota(body.total_quota),
+ };
const { key } = c.req.param();
// Validate channels exist using batch query (if channel_keys is not empty)
- if (body.channel_keys && body.channel_keys.length > 0) {
- const channelQuery = body.channel_keys.map(() => '?').join(',');
+ if (normalizedBody.channel_keys && normalizedBody.channel_keys.length > 0) {
+ const channelQuery = normalizedBody.channel_keys.map(() => '?').join(',');
const existingChannels = await c.env.DB.prepare(
`SELECT key FROM channel_config WHERE key IN (${channelQuery})`
- ).bind(...body.channel_keys).all();
+ ).bind(...normalizedBody.channel_keys).all();
- if (!existingChannels.results || existingChannels.results.length !== body.channel_keys.length) {
+ if (!existingChannels.results || existingChannels.results.length !== normalizedBody.channel_keys.length) {
const existingKeys = existingChannels.results?.map((row: any) => row.key) || [];
- const missingKeys = body.channel_keys.filter(key => !existingKeys.includes(key));
+ const missingKeys = normalizedBody.channel_keys.filter(key => !existingKeys.includes(key));
return c.text(`Channels not found: ${missingKeys.join(', ')}`, 400);
}
}
@@ -85,7 +91,7 @@ export class TokenUpsertEndpoint extends OpenAPIRoute {
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = datetime('now')`
- ).bind(key, JSON.stringify(body)).run();
+ ).bind(key, JSON.stringify(normalizedBody)).run();
if (!result.success) {
return c.text('Failed to upsert token', 500);
diff --git a/src/admin/token_utils.ts b/src/admin/token_utils.ts
index 919e0ef..72da353 100644
--- a/src/admin/token_utils.ts
+++ b/src/admin/token_utils.ts
@@ -16,6 +16,8 @@ type UsageCostResult = {
hasPricing: boolean;
}
+const UNLIMITED_TOKEN_QUOTA = -1;
+
const normalizeBillingMode = (value: unknown): PricingBillingMode | undefined => {
if (value === "volume" || value === "request") {
return value;
@@ -28,6 +30,54 @@ const calculateFixedCostRaw = (value: unknown): number => {
return calculateRequestCostRaw(value || 0);
};
+const normalizeTokenQuota = (value: unknown): number => {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ const normalizedValue = Math.round(value);
+ return normalizedValue === UNLIMITED_TOKEN_QUOTA
+ ? UNLIMITED_TOKEN_QUOTA
+ : Math.max(0, normalizedValue);
+ }
+
+ if (typeof value === "string" && value.trim().length > 0) {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) {
+ return normalizeTokenQuota(parsed);
+ }
+ }
+
+ return 0;
+};
+
+const normalizeTokenUsage = (value: unknown): number => {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return Math.max(0, Math.round(value));
+ }
+
+ if (typeof value === "string" && value.trim().length > 0) {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) {
+ return normalizeTokenUsage(parsed);
+ }
+ }
+
+ return 0;
+};
+
+const hasPositivePricingValue = (value: unknown): boolean => {
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
+};
+
+const pricingRequiresQuota = (pricing: ModelPricing | null): boolean => {
+ if (!pricing) {
+ return false;
+ }
+
+ return hasPositivePricingValue(pricing.request)
+ || hasPositivePricingValue(pricing.input)
+ || hasPositivePricingValue(pricing.output)
+ || hasPositivePricingValue(pricing.cache);
+};
+
// Token 工具对象
export const TokenUtils = {
async updateUsage(c: Context, key: string, usageAmount: number): Promise {
@@ -52,6 +102,24 @@ export const TokenUtils = {
const globalPricingMap = await getJsonSetting(c, CONSTANTS.MODEL_PRICING_KEY);
return globalPricingMap?.[model] || null;
},
+ normalizeQuota(value: unknown): number {
+ return normalizeTokenQuota(value);
+ },
+ hasRemainingQuota(totalQuota: unknown, usage: unknown): boolean {
+ const normalizedTotalQuota = normalizeTokenQuota(totalQuota);
+ const normalizedUsage = normalizeTokenUsage(usage);
+
+ return normalizedTotalQuota === UNLIMITED_TOKEN_QUOTA
+ || normalizedUsage < normalizedTotalQuota;
+ },
+ async modelRequiresPaidQuota(
+ c: Context,
+ model: string,
+ channelConfig: ChannelConfig
+ ): Promise {
+ const pricing = await this.getPricing(c, model, channelConfig);
+ return pricingRequiresQuota(pricing);
+ },
async calculateUsageCost(
c: Context,
diff --git a/src/providers/models.ts b/src/providers/models.ts
index 2fa88aa..136e665 100644
--- a/src/providers/models.ts
+++ b/src/providers/models.ts
@@ -4,6 +4,7 @@ import { z } from "zod";
import { getApiKeyFromHeaders, fetchTokenData, fetchChannelsForToken } from "./shared/auth";
import { getChannelModels } from "../utils";
import { normalizeChannelConfig } from "../channel-config";
+import { TokenUtils } from "../admin/token_utils";
export class ModelsEndpoint extends OpenAPIRoute {
schema = {
@@ -56,6 +57,7 @@ export class ModelsEndpoint extends OpenAPIRoute {
}
const modelsSet = new Set();
+ const hasRemainingQuota = TokenUtils.hasRemainingQuota(tokenInfo.tokenData.total_quota, tokenInfo.usage);
for (const row of channelsResult.results) {
const config = normalizeChannelConfig(JSON.parse(row.value) as ChannelConfig);
@@ -63,6 +65,9 @@ export class ModelsEndpoint extends OpenAPIRoute {
continue;
}
for (const model of getChannelModels(config)) {
+ if (!hasRemainingQuota && await TokenUtils.modelRequiresPaidQuota(c, model.name, config)) {
+ continue;
+ }
modelsSet.add(model.name);
}
}
diff --git a/src/providers/shared/channel-resolver.ts b/src/providers/shared/channel-resolver.ts
index 4ee0f50..145224f 100644
--- a/src/providers/shared/channel-resolver.ts
+++ b/src/providers/shared/channel-resolver.ts
@@ -56,10 +56,6 @@ export const resolveChannel = async (
const { tokenData, usage } = tokenInfo;
- if (usage >= tokenData.total_quota) {
- return c.text("Quota exceeded", 402);
- }
-
const channelsResult = await fetchChannelsForToken(c, tokenData);
if (!channelsResult.results || channelsResult.results.length === 0) {
@@ -80,7 +76,7 @@ export const resolveChannel = async (
const policy = getRoutePolicy(routeId);
const allowedTypes = policy.allowedTypes;
- const availableChannels: ResolvedChannelCandidate[] = [];
+ let availableChannels: ResolvedChannelCandidate[] = [];
let hasDisabledMatchingChannel = false;
for (const row of channelsResult.results) {
@@ -124,6 +120,23 @@ export const resolveChannel = async (
return c.text(`Model not supported: ${requestedModel}. Please configure models.`, 400);
}
+ if (!TokenUtils.hasRemainingQuota(tokenData.total_quota, usage)) {
+ const freeChannels = await Promise.all(
+ availableChannels.map(async (channel) => ({
+ channel,
+ requiresPaidQuota: await TokenUtils.modelRequiresPaidQuota(c, requestedModel, channel.config),
+ }))
+ );
+
+ availableChannels = freeChannels
+ .filter((item) => !item.requiresPaidQuota)
+ .map((item) => item.channel);
+
+ if (availableChannels.length === 0) {
+ return c.text("Quota exceeded for paid models", 402);
+ }
+ }
+
const selectedChannel = pickHighestPriorityChannel(availableChannels);
const trackingState: RequestTrackingState = {
retryCount: 0,
diff --git a/type.d.ts b/type.d.ts
index 9f68fa6..d017482 100644
--- a/type.d.ts
+++ b/type.d.ts
@@ -144,7 +144,7 @@ type SystemConfig = BillingConfig & {
type ApiTokenData = {
name: string;
channel_keys: string[];
- total_quota: number; // 原始计费单位整数
+ total_quota: number; // 原始计费单位整数,-1 表示无限额度
}
type RequestTrackingState = {