feat: add gateway billing estimate and rate limit details

This commit is contained in:
2026-05-15 01:53:52 +08:00
parent bdc9be63d5
commit 37d0f919e5
14 changed files with 916 additions and 66 deletions
+3 -2
View File
@@ -19,6 +19,7 @@ import type {
GatewayTenant,
GatewayTenantUpsertRequest,
GatewayNetworkProxyConfig,
GatewayPricingEstimate,
GatewayTask,
GatewayTaskParamPreprocessingLog,
GatewayUser,
@@ -786,8 +787,8 @@ export async function uploadFileToStorage(
export async function estimatePricing(
token: string,
input: Record<string, unknown>,
): Promise<{ items: unknown[]; resolver: string }> {
return request<{ items: unknown[]; resolver: string }>('/api/v1/pricing/estimate', {
): Promise<GatewayPricingEstimate> {
return request<GatewayPricingEstimate>('/api/v1/pricing/estimate', {
body: input,
method: 'POST',
token,
+127 -6
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
import type { GatewayApiKey, GatewayPricingEstimate, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
import { ArrowUp, ChevronDown, MessageSquarePlus, Settings2, Sparkles } from 'lucide-react';
import { Badge, Button, FormDialog, Select, Textarea } from '../components/ui';
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, estimatePricing, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
import type { PlaygroundMode } from '../types';
import {
PlaygroundPromptMentionInput,
@@ -57,6 +57,14 @@ import {
const MEDIA_RUNS_STORAGE_KEY = 'easyai:playground:media-runs:v1';
const MEDIA_RUNS_STORAGE_LIMIT = 50;
type MediaEstimateState = {
amount?: number;
currency?: string;
error?: string;
resolver?: string;
status: 'idle' | 'loading' | 'ready' | 'error';
};
const publicWorks = [
{ title: '雨夜霓虹街区', type: '图像生成', image: 'https://picsum.photos/seed/easyai-neon-city/720/960' },
{ title: '玻璃温室晨光', type: '图像生成', image: 'https://picsum.photos/seed/easyai-glasshouse/720/540' },
@@ -94,6 +102,7 @@ export function PlaygroundPage(props: {
const [mediaMessage, setMediaMessage] = useState('');
const [mediaUploadMessage, setMediaUploadMessage] = useState('');
const [mediaUploads, setMediaUploads] = useState<PlaygroundUpload[]>([]);
const [mediaEstimate, setMediaEstimate] = useState<MediaEstimateState>({ status: 'idle' });
const [mediaUploading, setMediaUploading] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const isMountedRef = useRef(false);
@@ -118,6 +127,13 @@ export function PlaygroundPage(props: {
: undefined,
[activeModelOption, mediaSettings.resolution, props.mode, videoMode],
);
const mediaEstimatePayload = useMemo(() => {
if (props.mode === 'chat' || !selectedModel) return null;
const normalizedSettings = mediaCapabilities
? normalizeMediaSettingsForCapabilities(mediaSettings, mediaCapabilities, props.mode)
: mediaSettings;
return buildMediaEstimatePayload(props.mode, selectedModel, prompt, normalizedSettings, mediaUploads, videoMode);
}, [mediaCapabilities, mediaSettings, mediaUploads, prompt, props.mode, selectedModel, videoMode]);
useEffect(() => {
setSelectedModel((current) => {
@@ -155,6 +171,34 @@ export function PlaygroundPage(props: {
setPrompt((current) => removeInvalidPlaygroundResourceTokens(current, mediaUploads));
}, [mediaUploadSignature, props.mode]);
useEffect(() => {
const credential = activeApiKeySecret || props.token;
if (props.mode === 'chat' || !credential || !mediaEstimatePayload) {
setMediaEstimate({ status: 'idle' });
return;
}
let cancelled = false;
setMediaEstimate((current) => ({ ...current, error: '', status: 'loading' }));
const timer = window.setTimeout(() => {
estimatePricing(credential, mediaEstimatePayload)
.then((estimate) => {
if (cancelled) return;
setMediaEstimate(mediaEstimateFromResponse(estimate));
})
.catch((err) => {
if (cancelled) return;
setMediaEstimate({
error: err instanceof Error ? err.message : '预计扣费计算失败',
status: 'error',
});
});
}, 260);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [activeApiKeySecret, mediaEstimatePayload, props.mode, props.token]);
useEffect(() => {
if (props.mode === 'image') {
setMediaUploads((current) => current.some((item) => item.kind !== 'image') ? current.filter((item) => item.kind === 'image') : current);
@@ -404,6 +448,7 @@ export function PlaygroundPage(props: {
selectedModel={selectedModel}
imageHasReference={effectiveImageHasReference}
mediaSettings={mediaSettings}
mediaEstimate={mediaEstimate}
mediaCapabilities={mediaCapabilities}
uploadAccept={mediaUploadAcceptValue}
uploadMessage={mediaUploadMessage}
@@ -631,6 +676,7 @@ function Composer(props: {
compact?: boolean;
imageHasReference?: boolean;
mediaCapabilities?: MediaModelCapabilities;
mediaEstimate?: MediaEstimateState;
mediaSettings?: MediaGenerationSettings;
mode: PlaygroundMode;
modelOptions: ModelOption[];
@@ -727,10 +773,17 @@ function Composer(props: {
onChange={props.onMediaSettingsChange}
/>
)}
<span className="composerEstimatedCharge" aria-label="预计扣费 1 / 张">
<Sparkles size={14} />
<span>1 / </span>
</span>
{props.mode !== 'chat' && props.mediaEstimate && (
<span
className="composerEstimatedCharge"
data-state={props.mediaEstimate.status}
title={mediaEstimateHint(props.mediaEstimate)}
aria-label={mediaEstimateAriaLabel(props.mediaEstimate)}
>
<Sparkles size={14} />
<span>{mediaEstimateText(props.mediaEstimate)}</span>
</span>
)}
<Button type="button" size="icon" className="composerMediaSendButton" aria-label="发送测试" onClick={props.onSubmit}>
<ArrowUp size={24} />
</Button>
@@ -739,6 +792,74 @@ function Composer(props: {
);
}
function buildMediaEstimatePayload(
mode: Exclude<PlaygroundMode, 'chat'>,
model: string,
prompt: string,
settings: MediaGenerationSettings,
uploads: PlaygroundUpload[],
videoMode: VideoCreateMode,
): Record<string, unknown> {
const requestPrompt = replacePlaygroundResourceTokens(prompt.trim(), uploads, mode);
if (mode === 'video') {
return {
kind: 'videos.generations',
model,
content: sharedVideoGenerationContentFromPromptAndUploads(requestPrompt, uploads, videoMode),
...mediaRequestPayload(settings, 'video'),
};
}
const uploadPayload = sharedMediaUploadRequestPayload(uploads, 'image');
return {
kind: uploads.some((item) => item.kind === 'image') ? 'images.edits' : 'images.generations',
model,
prompt: requestPrompt,
...mediaRequestPayload(settings, 'image'),
...uploadPayload,
};
}
function mediaEstimateFromResponse(response: GatewayPricingEstimate): MediaEstimateState {
return {
amount: numericFromUnknown(response.totalAmount) ?? estimateItemsTotal(response.items),
currency: response.currency || estimateItemsCurrency(response.items),
resolver: response.resolver,
status: 'ready',
};
}
function estimateItemsTotal(items: GatewayPricingEstimate['items']) {
const total = items.reduce((sum, item) => sum + (numericFromUnknown(item.amount) ?? 0), 0);
return Math.round(total * 1_000_000) / 1_000_000;
}
function estimateItemsCurrency(items: GatewayPricingEstimate['items']) {
return items.find((item) => stringFromUnknown(item.currency))?.currency || 'resource';
}
function mediaEstimateText(estimate: MediaEstimateState) {
if (estimate.amount === undefined) return '--';
return formatEstimateAmount(estimate.amount);
}
function mediaEstimateHint(estimate: MediaEstimateState) {
if (estimate.status === 'error' && estimate.error) return `预计扣费计算失败:${estimate.error}`;
return '扣费为预计,实际扣费以账单为准';
}
function mediaEstimateAriaLabel(estimate: MediaEstimateState) {
if (estimate.status === 'error') return estimate.error ? `预计扣费计算失败:${estimate.error}` : '预计扣费计算失败';
if (estimate.amount === undefined) return '预计扣费估算中';
return `预计扣费 ${formatEstimateAmount(estimate.amount)} ${estimate.currency || 'resource'}`;
}
function formatEstimateAmount(value: number) {
if (!Number.isFinite(value)) return '--';
const digits = Math.abs(value) > 0 && Math.abs(value) < 0.01 ? 6 : 2;
return value.toFixed(digits).replace(/\.?0+$/, '');
}
function mediaPromptPlaceholder(mode: PlaygroundMode) {
if (mode === 'image') return '输入画面描述,可用 @ 或 @资产 快速引用图片资源,例如:让 @图像 1 保持人物一致...';
if (mode === 'video') return '输入镜头、运动和风格,可用 @ 或 @资产 引用图片、视频或音频资源...';
@@ -78,8 +78,10 @@ type UserGroupForm = {
description: string;
source: string;
priority: string;
rechargeDiscountPolicyJson: string;
billingDiscountPolicyJson: string;
rechargeDiscountFactor: string;
rechargeDiscountPolicy: Record<string, unknown>;
billingDiscountFactor: string;
billingDiscountPolicy: Record<string, unknown>;
rateLimitPolicyJson: string;
quotaPolicyJson: string;
metadataJson: string;
@@ -516,8 +518,8 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
<Label><Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{userGroupStatuses.map(option)}</Select></Label>
<Label><Input size="sm" value={form.priority} inputMode="numeric" onChange={(event) => setForm({ ...form, priority: event.target.value })} /></Label>
<Label className="spanTwo"><Input size="sm" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
<JsonField label="充值折扣策略 JSON" value={form.rechargeDiscountPolicyJson} onChange={(value) => setForm({ ...form, rechargeDiscountPolicyJson: value })} />
<JsonField label="计费折扣策略 JSON" value={form.billingDiscountPolicyJson} onChange={(value) => setForm({ ...form, billingDiscountPolicyJson: value })} />
<Label><Input size="sm" value={form.rechargeDiscountFactor} inputMode="decimal" placeholder="1 = 不打折,0.95 = 95 折" onChange={(event) => setForm({ ...form, rechargeDiscountFactor: event.target.value })} /></Label>
<Label><Input size="sm" value={form.billingDiscountFactor} inputMode="decimal" placeholder="1 = 不打折,0.95 = 95 折" onChange={(event) => setForm({ ...form, billingDiscountFactor: event.target.value })} /></Label>
<JsonField label="限流策略 JSON" value={form.rateLimitPolicyJson} onChange={(value) => setForm({ ...form, rateLimitPolicyJson: value })} />
<JsonField label="额度策略 JSON" value={form.quotaPolicyJson} onChange={(value) => setForm({ ...form, quotaPolicyJson: value })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
@@ -769,8 +771,10 @@ function defaultUserGroupForm(): UserGroupForm {
description: '',
source: 'gateway',
priority: '100',
rechargeDiscountPolicyJson: '{}',
billingDiscountPolicyJson: '{}',
rechargeDiscountFactor: '1',
rechargeDiscountPolicy: {},
billingDiscountFactor: '1',
billingDiscountPolicy: {},
rateLimitPolicyJson: '{"rules":[]}',
quotaPolicyJson: '{}',
metadataJson: '{}',
@@ -785,8 +789,10 @@ function userGroupToForm(group: UserGroup): UserGroupForm {
description: group.description ?? '',
source: group.source,
priority: String(group.priority),
rechargeDiscountPolicyJson: stringifyJson(group.rechargeDiscountPolicy),
billingDiscountPolicyJson: stringifyJson(group.billingDiscountPolicy),
rechargeDiscountFactor: discountFactorText(group.rechargeDiscountPolicy),
rechargeDiscountPolicy: group.rechargeDiscountPolicy ?? {},
billingDiscountFactor: discountFactorText(group.billingDiscountPolicy),
billingDiscountPolicy: group.billingDiscountPolicy ?? {},
rateLimitPolicyJson: stringifyJson(group.rateLimitPolicy),
quotaPolicyJson: stringifyJson(group.quotaPolicy),
metadataJson: stringifyJson(group.metadata),
@@ -801,8 +807,8 @@ function formToUserGroupPayload(form: UserGroupForm): UserGroupUpsertRequest {
description: form.description.trim() || undefined,
source: form.source,
priority: Number(form.priority) || 100,
rechargeDiscountPolicy: parseJsonObject(form.rechargeDiscountPolicyJson, '充值折扣策略 JSON'),
billingDiscountPolicy: parseJsonObject(form.billingDiscountPolicyJson, '计费折扣策略 JSON'),
rechargeDiscountPolicy: discountPolicyPayload(form.rechargeDiscountPolicy, form.rechargeDiscountFactor, '充值折扣系数'),
billingDiscountPolicy: discountPolicyPayload(form.billingDiscountPolicy, form.billingDiscountFactor, '计费折扣系数'),
rateLimitPolicy: parseJsonObject(form.rateLimitPolicyJson, '限流策略 JSON'),
quotaPolicy: parseJsonObject(form.quotaPolicyJson, '额度策略 JSON'),
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
@@ -854,14 +860,57 @@ function newIdempotencyKey() {
}
function discountSummary(group: UserGroup) {
const billing = group.billingDiscountPolicy?.discountFactor ?? group.billingDiscountPolicy?.factor;
const recharge = group.rechargeDiscountPolicy?.discountFactor ?? group.rechargeDiscountPolicy?.factor;
const billing = discountFactorFromPolicy(group.billingDiscountPolicy);
const recharge = discountFactorFromPolicy(group.rechargeDiscountPolicy);
const parts = [];
if (billing) parts.push(`计费 ${billing}`);
if (recharge) parts.push(`充值 ${recharge}`);
if (billing) parts.push(`计费 ${trimNumber(billing)}`);
if (recharge) parts.push(`充值 ${trimNumber(recharge)}`);
return parts.join(' / ') || '未设置';
}
function discountFactorText(policy?: Record<string, unknown>) {
const value = discountFactorFromPolicy(policy);
return value ? trimNumber(value) : '1';
}
function discountFactorFromPolicy(policy?: Record<string, unknown>) {
return numberFromUnknown(policy?.discountFactor) ?? numberFromUnknown(policy?.factor);
}
function discountPolicyPayload(basePolicy: Record<string, unknown>, discountText: string, label: string) {
const policy = { ...basePolicy };
delete policy.discountFactor;
delete policy.factor;
const discount = optionalPositiveNumber(discountText, label);
if (discount && discount !== 1) {
policy.discountFactor = discount;
}
return Object.keys(policy).length ? policy : undefined;
}
function optionalPositiveNumber(value: string, label: string) {
const text = value.trim();
if (!text) return undefined;
const parsed = Number(text);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${label} 必须是大于 0 的数字`);
}
return parsed;
}
function numberFromUnknown(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return undefined;
}
function trimNumber(value: number) {
return value.toFixed(6).replace(/\.?0+$/, '');
}
function policyKeys(value?: Record<string, unknown>) {
if (!value) return [];
return Object.keys(value).slice(0, 3);
+9
View File
@@ -630,6 +630,15 @@
color: #526170;
}
.composerEstimatedCharge[data-state="loading"] {
color: #6f7c8a;
}
.composerEstimatedCharge[data-state="error"],
.composerEstimatedCharge[data-state="error"] svg {
color: #b45309;
}
.composerMediaSendButton.shButton {
width: 40px;
height: 40px;