feat(token): 支持无限额度的总配额设置
- 修改 TokenConfig 接口,允许 total_quota 为 -1 表示无限额度 - 更新 TokenUpsertEndpoint,处理新的 total_quota 验证逻辑 - 添加 TokenUtils 中的 normalizeQuota 方法,规范化配额值 - 在模型处理逻辑中检查剩余配额,确保按需收费 - 更新相关文档以反映新的配额逻辑
This commit is contained in:
1 parent
c915efe4e9
commit
4d1478feb6
8 files changed
+598
-420
No files matched your search
@@ -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"
|
||||
|
||||
+462
-375
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user