feat: add gateway billing estimate and rate limit details
This commit is contained in:
+3
-2
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user