feat(token): 支持无限额度的总配额设置

- 修改 TokenConfig 接口,允许 total_quota 为 -1 表示无限额度
- 更新 TokenUpsertEndpoint,处理新的 total_quota 验证逻辑
- 添加 TokenUtils 中的 normalizeQuota 方法,规范化配额值
- 在模型处理逻辑中检查剩余配额,确保按需收费
- 更新相关文档以反映新的配额逻辑
This commit is contained in:
Jeff Gu committed 2026-04-14 17:59:03 +08:00
1 parent c915efe4e9
commit 4d1478feb6
8 files changed
+598 -420

No files matched your search

+30 -31
View File
@@ -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
</div>
<div className="space-y-2">
<Label className="text-sm">渠道权重</Label>
<Select
value={String(formData.weight ?? 0)}
onChange={(event) =>
<ButtonGroup
aria-label="渠道权重"
value={normalizeChannelWeight(formData.weight)}
options={channelWeightOptions}
onValueChange={(value) =>
setFormData({
...formData,
weight: normalizeChannelWeight(Number(event.target.value)),
weight: normalizeChannelWeight(value),
})
}
>
{channelWeightOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
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"
/>
<p className="text-xs text-muted-foreground">相同模型权重优先,相同权重随机分配。</p>
</div>
<div className="">
@@ -1330,7 +1336,9 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
/>
<div>
<div className="text-sm font-medium">自动轮换</div>
<p className="mt-1 text-xs text-muted-foreground">重试时随机切换同渠道其他密钥;关闭后始终重试当前密钥</p>
<p className="mt-1 text-xs text-muted-foreground">
重试时随机切换同渠道其他密钥;关闭后始终重试当前密钥
</p>
</div>
</label>
</div>
@@ -1351,22 +1359,13 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={modelEditorMode === "visual" ? "secondary" : "outline"}
size="sm"
onClick={() => setModelEditorModeWithSync("visual")}
>
可视化
</Button>
<Button
type="button"
variant={modelEditorMode === "json" ? "secondary" : "outline"}
size="sm"
onClick={() => setModelEditorModeWithSync("json")}
>
JSON
</Button>
<ButtonGroup
aria-label="模型配置编辑模式"
value={modelEditorMode}
options={modelEditorModeOptions}
onValueChange={setModelEditorModeWithSync}
activeVariant="secondary"
/>
<Button
type="button"
variant="outline"
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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 {
+13 -7
View File
@@ -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<HonoCustomType>) {
const body = await c.req.json<ApiTokenData>();
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);
+68
View File
@@ -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<HonoCustomType>, key: string, usageAmount: number): Promise<boolean> {
@@ -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<HonoCustomType>,
model: string,
channelConfig: ChannelConfig
): Promise<boolean> {
const pricing = await this.getPricing(c, model, channelConfig);
return pricingRequiresQuota(pricing);
},
async calculateUsageCost(
c: Context<HonoCustomType>,
+5
View File
@@ -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<string>();
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);
}
}
+18 -5
View File
@@ -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,
Vendored
+1 -1
View File
@@ -144,7 +144,7 @@ type SystemConfig = BillingConfig & {
type ApiTokenData = {
name: string;
channel_keys: string[];
total_quota: number; // 原始计费单位整数
total_quota: number; // 原始计费单位整数,-1 表示无限额度
}
type RequestTrackingState = {