Feat/pricing config enhancement (#781)

* feat(db): add pricing config fields to proxy_config table

- Add default_cost_multiplier field per app type
- Add pricing_model_source field (request/response)
- Add request_model field to proxy_request_logs table
- Implement schema migration v5

* feat(api): add pricing config commands and provider meta fields

- Add get/set commands for default cost multiplier
- Add get/set commands for pricing model source
- Extend ProviderMeta with cost_multiplier and pricing_model_source
- Register new commands in Tauri invoke handler

* fix(proxy): apply cost multiplier to total cost only

- Move multiplier calculation from per-item to total cost
- Add resolve_pricing_config for provider-level override
- Include request_model and cost_multiplier in usage logs
- Return new fields in get_request_logs API

* feat(ui): add pricing config UI and usage log enhancements

- Add pricing config section to provider advanced settings
- Refactor PricingConfigPanel to compact table layout
- Display all three apps (Claude/Codex/Gemini) in one view
- Add multiplier column and request model display to logs
- Add frontend API wrappers for pricing config

* feat(i18n): add pricing config translations

- Add zh/en/ja translations for pricing defaults config
- Add translations for multiplier, requestModel, responseModel
- Add provider pricing config translations

* fix(pricing): align backfill cost calculation with real-time logic

- Fix backfill to deduct cache_read_tokens from input (avoid double billing)
- Apply multiplier only to total cost, not to each item
- Add multiplier display in request detail panel with i18n support
- Use AppError::localized for backend error messages
- Fix init_proxy_config_rows to use per-app default values
- Fix silent failure in set_default_cost_multiplier/set_pricing_model_source
- Add clippy allow annotation for test mutex across await

* style: format code with cargo fmt and prettier

* fix(tests): correct error type assertions in proxy DAO tests

The tests expected AppError::InvalidInput but the DAO functions use
AppError::localized() which returns AppError::Localized variant.
Updated assertions to match the correct error type with key validation.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Dex Miller
2026-01-27 10:43:05 +08:00
committed by GitHub
parent c00f431d67
commit 785e1b5add
27 changed files with 2123 additions and 283 deletions
@@ -5,6 +5,7 @@ import {
ChevronRight,
FlaskConical,
Globe,
Coins,
Eye,
EyeOff,
X,
@@ -13,14 +14,31 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { ProviderTestConfig, ProviderProxyConfig } from "@/types";
export type PricingModelSourceOption = "inherit" | "request" | "response";
interface ProviderPricingConfig {
enabled: boolean;
costMultiplier?: string;
pricingModelSource: PricingModelSourceOption;
}
interface ProviderAdvancedConfigProps {
testConfig: ProviderTestConfig;
proxyConfig: ProviderProxyConfig;
pricingConfig: ProviderPricingConfig;
onTestConfigChange: (config: ProviderTestConfig) => void;
onProxyConfigChange: (config: ProviderProxyConfig) => void;
onPricingConfigChange: (config: ProviderPricingConfig) => void;
}
/** 从 ProviderProxyConfig 构建完整 URL */
@@ -71,14 +89,19 @@ function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
export function ProviderAdvancedConfig({
testConfig,
proxyConfig,
pricingConfig,
onTestConfigChange,
onProxyConfigChange,
onPricingConfigChange,
}: ProviderAdvancedConfigProps) {
const { t } = useTranslation();
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
proxyConfig.enabled,
);
const [isPricingConfigOpen, setIsPricingConfigOpen] = useState(
pricingConfig.enabled,
);
const [showPassword, setShowPassword] = useState(false);
// 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建)
@@ -97,6 +120,11 @@ export function ProviderAdvancedConfig({
setIsProxyConfigOpen(proxyConfig.enabled);
}, [proxyConfig.enabled]);
// 同步外部 pricingConfig.enabled 变化到展开状态
useEffect(() => {
setIsPricingConfigOpen(pricingConfig.enabled);
}, [pricingConfig.enabled]);
// 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据)
useEffect(() => {
if (!isUserTyping) {
@@ -450,6 +478,143 @@ export function ProviderAdvancedConfig({
</div>
</div>
</div>
{/* 计费配置 */}
<div className="rounded-lg border border-border/50 bg-muted/20">
<button
type="button"
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
onClick={() => setIsPricingConfigOpen(!isPricingConfigOpen)}
>
<div className="flex items-center gap-3">
<Coins className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{t("providerAdvanced.pricingConfig", {
defaultValue: "计费配置",
})}
</span>
</div>
<div className="flex items-center gap-3">
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Label
htmlFor="pricing-config-enabled"
className="text-sm text-muted-foreground"
>
{t("providerAdvanced.useCustomPricing", {
defaultValue: "使用单独配置",
})}
</Label>
<Switch
id="pricing-config-enabled"
checked={pricingConfig.enabled}
onCheckedChange={(checked) => {
onPricingConfigChange({ ...pricingConfig, enabled: checked });
if (checked) setIsPricingConfigOpen(true);
}}
/>
</div>
{isPricingConfigOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
<div
className={cn(
"overflow-hidden transition-all duration-200",
isPricingConfigOpen
? "max-h-[500px] opacity-100"
: "max-h-0 opacity-0",
)}
>
<div className="border-t border-border/50 p-4 space-y-4">
<p className="text-sm text-muted-foreground">
{t("providerAdvanced.pricingConfigDesc", {
defaultValue:
"为此供应商配置单独的计费参数,不启用时使用全局默认配置。",
})}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cost-multiplier">
{t("providerAdvanced.costMultiplier", {
defaultValue: "成本倍率",
})}
</Label>
<Input
id="cost-multiplier"
type="number"
step="0.01"
inputMode="decimal"
value={pricingConfig.costMultiplier || ""}
onChange={(e) =>
onPricingConfigChange({
...pricingConfig,
costMultiplier: e.target.value || undefined,
})
}
placeholder={t("providerAdvanced.costMultiplierPlaceholder", {
defaultValue: "留空使用全局默认(1",
})}
disabled={!pricingConfig.enabled}
/>
<p className="text-xs text-muted-foreground">
{t("providerAdvanced.costMultiplierHint", {
defaultValue: "实际成本 = 基础成本 × 倍率,支持小数如 1.5",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="pricing-model-source">
{t("providerAdvanced.pricingModelSourceLabel", {
defaultValue: "计费模式",
})}
</Label>
<Select
value={pricingConfig.pricingModelSource}
onValueChange={(value) =>
onPricingConfigChange({
...pricingConfig,
pricingModelSource: value as PricingModelSourceOption,
})
}
disabled={!pricingConfig.enabled}
>
<SelectTrigger id="pricing-model-source">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">
{t("providerAdvanced.pricingModelSourceInherit", {
defaultValue: "继承全局默认",
})}
</SelectItem>
<SelectItem value="request">
{t("providerAdvanced.pricingModelSourceRequest", {
defaultValue: "请求模型",
})}
</SelectItem>
<SelectItem value="response">
{t("providerAdvanced.pricingModelSourceResponse", {
defaultValue: "返回模型",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerAdvanced.pricingModelSourceHint", {
defaultValue: "选择按请求模型还是返回模型进行定价匹配",
})}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -46,7 +46,10 @@ import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
import { ProviderAdvancedConfig } from "./ProviderAdvancedConfig";
import {
ProviderAdvancedConfig,
type PricingModelSourceOption,
} from "./ProviderAdvancedConfig";
import {
useProviderCategory,
useApiKeyState,
@@ -121,6 +124,9 @@ interface ProviderFormProps {
showButtons?: boolean;
}
const normalizePricingSource = (value?: string): PricingModelSourceOption =>
value === "request" || value === "response" ? value : "inherit";
export function ProviderForm({
appId,
providerId,
@@ -168,6 +174,19 @@ export function ProviderForm({
const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>(
() => initialData?.meta?.proxyConfig ?? { enabled: false },
);
const [pricingConfig, setPricingConfig] = useState<{
enabled: boolean;
costMultiplier?: string;
pricingModelSource: PricingModelSourceOption;
}>(() => ({
enabled:
initialData?.meta?.costMultiplier !== undefined ||
initialData?.meta?.pricingModelSource !== undefined,
costMultiplier: initialData?.meta?.costMultiplier,
pricingModelSource: normalizePricingSource(
initialData?.meta?.pricingModelSource,
),
}));
// 使用 category hook
const { category } = useProviderCategory({
@@ -188,6 +207,15 @@ export function ProviderForm({
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
setPricingConfig({
enabled:
initialData?.meta?.costMultiplier !== undefined ||
initialData?.meta?.pricingModelSource !== undefined,
costMultiplier: initialData?.meta?.costMultiplier,
pricingModelSource: normalizePricingSource(
initialData?.meta?.pricingModelSource,
),
});
}, [appId, initialData]);
const defaultValues: ProviderFormData = useMemo(
@@ -940,6 +968,13 @@ export function ProviderForm({
// 添加高级配置
testConfig: testConfig.enabled ? testConfig : undefined,
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
costMultiplier: pricingConfig.enabled
? pricingConfig.costMultiplier
: undefined,
pricingModelSource:
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
? pricingConfig.pricingModelSource
: undefined,
};
onSubmit(payload);
@@ -1464,8 +1499,10 @@ export function ProviderForm({
<ProviderAdvancedConfig
testConfig={testConfig}
proxyConfig={proxyConfig}
pricingConfig={pricingConfig}
onTestConfigChange={setTestConfig}
onProxyConfigChange={setProxyConfig}
onPricingConfigChange={setPricingConfig}
/>
{showButtons && (
+371 -136
View File
@@ -1,6 +1,5 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
@@ -19,10 +18,31 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage";
import { PricingEditModal } from "./PricingEditModal";
import type { ModelPricing } from "@/types/usage";
import { Plus, Pencil, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { proxyApi } from "@/lib/api/proxy";
const PRICING_APPS = ["claude", "codex", "gemini"] as const;
type PricingApp = (typeof PRICING_APPS)[number];
type PricingModelSource = "request" | "response";
interface AppConfig {
multiplier: string;
source: PricingModelSource;
}
type AppConfigState = Record<PricingApp, AppConfig>;
export function PricingConfigPanel() {
const { t } = useTranslation();
@@ -31,13 +51,137 @@ export function PricingConfigPanel() {
const [editingModel, setEditingModel] = useState<ModelPricing | null>(null);
const [isAddingNew, setIsAddingNew] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
// 三个应用的配置状态
const [appConfigs, setAppConfigs] = useState<AppConfigState>({
claude: { multiplier: "1", source: "response" },
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
});
const [originalConfigs, setOriginalConfigs] = useState<AppConfigState | null>(
null,
);
const [isConfigLoading, setIsConfigLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// 检查是否有改动
const isDirty =
originalConfigs !== null &&
PRICING_APPS.some(
(app) =>
appConfigs[app].multiplier !== originalConfigs[app].multiplier ||
appConfigs[app].source !== originalConfigs[app].source,
);
// 加载所有应用的配置
useEffect(() => {
let isMounted = true;
const loadAllConfigs = async () => {
setIsConfigLoading(true);
try {
const results = await Promise.all(
PRICING_APPS.map(async (app) => {
const [multiplier, source] = await Promise.all([
proxyApi.getDefaultCostMultiplier(app),
proxyApi.getPricingModelSource(app),
]);
return {
app,
multiplier,
source: (source === "request"
? "request"
: "response") as PricingModelSource,
};
}),
);
if (!isMounted) return;
const newState: AppConfigState = {
claude: { multiplier: "1", source: "response" },
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
};
for (const result of results) {
newState[result.app] = {
multiplier: result.multiplier,
source: result.source,
};
}
setAppConfigs(newState);
setOriginalConfigs(newState);
} catch (error) {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
toast.error(
t("settings.globalProxy.pricingLoadFailed", { error: message }),
);
} finally {
if (isMounted) setIsConfigLoading(false);
}
};
loadAllConfigs();
return () => {
isMounted = false;
};
}, [t]);
// 保存所有配置
const handleSaveAll = async () => {
// 验证所有倍率
for (const app of PRICING_APPS) {
const trimmed = appConfigs[app].multiplier.trim();
if (!trimmed) {
toast.error(
`${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierRequired")}`,
);
return;
}
if (!/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
toast.error(
`${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierInvalid")}`,
);
return;
}
}
setIsSaving(true);
try {
await Promise.all(
PRICING_APPS.flatMap((app) => [
proxyApi.setDefaultCostMultiplier(
app,
appConfigs[app].multiplier.trim(),
),
proxyApi.setPricingModelSource(app, appConfigs[app].source),
]),
);
toast.success(t("settings.globalProxy.pricingSaved"));
setOriginalConfigs({ ...appConfigs });
} catch (error) {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
toast.error(
t("settings.globalProxy.pricingSaveFailed", { error: message }),
);
} finally {
setIsSaving(false);
}
};
const handleDelete = (modelId: string) => {
deleteMutation.mutate(modelId, {
onSuccess: () => {
setDeleteConfirm(null);
},
onSuccess: () => setDeleteConfirm(null),
});
};
@@ -55,151 +199,242 @@ export function PricingConfigPanel() {
if (isLoading) {
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<ChevronRight className="h-4 w-4" />
<CardTitle className="text-base">
{t("usage.modelPricing")}
</CardTitle>
</div>
</CardHeader>
</Card>
<div className="flex items-center justify-center p-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<CardTitle className="text-base">
{t("usage.modelPricing")}
</CardTitle>
</div>
</CardHeader>
{isExpanded && (
<CardContent>
<Alert variant="destructive">
<AlertDescription>
{t("usage.loadPricingError")}: {String(error)}
</AlertDescription>
</Alert>
</CardContent>
)}
</Card>
<Alert variant="destructive">
<AlertDescription>
{t("usage.loadPricingError")}: {String(error)}
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-medium text-muted-foreground">
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
</h4>
<Button
onClick={(e) => {
e.stopPropagation();
handleAddNew();
}}
size="sm"
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add")}
</Button>
</div>
<div className="space-y-6">
{/* 全局计费默认配置 - 紧凑表格布局 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">
{t("settings.globalProxy.pricingDefaultsTitle")}
</h4>
<p className="text-xs text-muted-foreground">
{t("settings.globalProxy.pricingDefaultsDescription")}
</p>
</div>
<Button
onClick={handleSaveAll}
disabled={isConfigLoading || isSaving || !isDirty}
size="sm"
>
{isSaving ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
{t("common.saving")}
</>
) : (
t("common.save")
)}
</Button>
</div>
<div className="space-y-4">
{!pricing || pricing.length === 0 ? (
<Alert>
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
</Alert>
{isConfigLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="rounded-md bg-card/60 shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("usage.model")}</TableHead>
<TableHead>{t("usage.displayName")}</TableHead>
<TableHead className="text-right">
{t("usage.inputCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.outputCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheReadCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheWriteCost")}
</TableHead>
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pricing.map((model) => (
<TableRow key={model.modelId}>
<TableCell className="font-mono text-sm">
{model.modelId}
</TableCell>
<TableCell>{model.displayName}</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.inputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.outputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheReadCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheCreationCostPerMillion}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setIsAddingNew(false);
setEditingModel(model);
}}
title={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteConfirm(model.modelId)}
title={t("common.delete")}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
<div className="rounded-md border border-border/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 bg-muted/30">
<th className="px-3 py-2 text-left font-medium text-muted-foreground w-24">
{t("settings.globalProxy.pricingAppLabel")}
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
{t("settings.globalProxy.defaultCostMultiplierLabel")}
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
{t("settings.globalProxy.pricingModelSourceLabel")}
</th>
</tr>
</thead>
<tbody>
{PRICING_APPS.map((app, idx) => (
<tr
key={app}
className={
idx < PRICING_APPS.length - 1
? "border-b border-border/30"
: ""
}
>
<td className="px-3 py-1.5 font-medium">
{t(`apps.${app}`)}
</td>
<td className="px-3 py-1.5">
<Input
type="number"
step="0.01"
inputMode="decimal"
value={appConfigs[app].multiplier}
onChange={(e) =>
setAppConfigs((prev) => ({
...prev,
[app]: { ...prev[app], multiplier: e.target.value },
}))
}
disabled={isSaving}
placeholder="1"
className="h-7 w-24"
/>
</td>
<td className="px-3 py-1.5">
<Select
value={appConfigs[app].source}
onValueChange={(value) =>
setAppConfigs((prev) => ({
...prev,
[app]: {
...prev[app],
source: value as PricingModelSource,
},
}))
}
disabled={isSaving}
>
<SelectTrigger className="h-7 w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="response">
{t(
"settings.globalProxy.pricingModelSourceResponse",
)}
</SelectItem>
<SelectItem value="request">
{t(
"settings.globalProxy.pricingModelSourceRequest",
)}
</SelectItem>
</SelectContent>
</Select>
</td>
</tr>
))}
</TableBody>
</Table>
</tbody>
</table>
</div>
)}
</div>
{/* 分隔线 */}
<div className="border-t border-border/50" />
{/* 模型定价配置 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-muted-foreground">
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
</h4>
<Button
onClick={(e) => {
e.stopPropagation();
handleAddNew();
}}
size="sm"
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add")}
</Button>
</div>
<div className="space-y-4">
{!pricing || pricing.length === 0 ? (
<Alert>
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
</Alert>
) : (
<div className="rounded-md bg-card/60 shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("usage.model")}</TableHead>
<TableHead>{t("usage.displayName")}</TableHead>
<TableHead className="text-right">
{t("usage.inputCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.outputCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheReadCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheWriteCost")}
</TableHead>
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pricing.map((model) => (
<TableRow key={model.modelId}>
<TableCell className="font-mono text-sm">
{model.modelId}
</TableCell>
<TableCell>{model.displayName}</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.inputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.outputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheReadCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheCreationCostPerMillion}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setIsAddingNew(false);
setEditingModel(model);
}}
title={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteConfirm(model.modelId)}
title={t("common.delete")}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</div>
{editingModel && (
<PricingEditModal
open={!!editingModel}
+31 -1
View File
@@ -184,6 +184,9 @@ export function RequestDetailPanel({
<div>
<dt className="text-muted-foreground">
{t("usage.inputCost", "输入成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt>
<dd className="font-mono">
${parseFloat(request.inputCostUsd).toFixed(6)}
@@ -192,6 +195,9 @@ export function RequestDetailPanel({
<div>
<dt className="text-muted-foreground">
{t("usage.outputCost", "输出成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt>
<dd className="font-mono">
${parseFloat(request.outputCostUsd).toFixed(6)}
@@ -200,6 +206,9 @@ export function RequestDetailPanel({
<div>
<dt className="text-muted-foreground">
{t("usage.cacheReadCost", "缓存读取成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt>
<dd className="font-mono">
${parseFloat(request.cacheReadCostUsd).toFixed(6)}
@@ -208,14 +217,35 @@ export function RequestDetailPanel({
<div>
<dt className="text-muted-foreground">
{t("usage.cacheCreationCost", "缓存写入成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt>
<dd className="font-mono">
${parseFloat(request.cacheCreationCostUsd).toFixed(6)}
</dd>
</div>
<div className="col-span-2 border-t pt-3">
{/* 显示成本倍率(如果不等于1) */}
{request.costMultiplier &&
parseFloat(request.costMultiplier) !== 1 && (
<div className="col-span-2 border-t pt-3">
<dt className="text-muted-foreground">
{t("usage.costMultiplier", "成本倍率")}
</dt>
<dd className="font-mono">×{request.costMultiplier}</dd>
</div>
)}
<div
className={`col-span-2 ${request.costMultiplier && parseFloat(request.costMultiplier) !== 1 ? "" : "border-t"} pt-3`}
>
<dt className="text-muted-foreground">
{t("usage.totalCost", "总成本")}
{request.costMultiplier &&
parseFloat(request.costMultiplier) !== 1 && (
<span className="ml-1 text-xs">
({t("usage.withMultiplier", "含倍率")})
</span>
)}
</dt>
<dd className="text-lg font-semibold text-primary">
${parseFloat(request.totalCostUsd).toFixed(6)}
+33 -7
View File
@@ -250,7 +250,7 @@ export function RequestLogTable() {
<TableHead className="whitespace-nowrap">
{t("usage.provider")}
</TableHead>
<TableHead className="min-w-[280px] whitespace-nowrap">
<TableHead className="min-w-[200px] whitespace-nowrap">
{t("usage.billingModel")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
@@ -265,6 +265,9 @@ export function RequestLogTable() {
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheCreationTokens")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("usage.multiplier")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("usage.totalCost")}
</TableHead>
@@ -280,7 +283,7 @@ export function RequestLogTable() {
{logs.length === 0 ? (
<TableRow>
<TableCell
colSpan={10}
colSpan={11}
className="text-center text-muted-foreground"
>
{t("usage.noData")}
@@ -297,11 +300,25 @@ export function RequestLogTable() {
<TableCell>
{log.providerName || t("usage.unknownProvider")}
</TableCell>
<TableCell
className="font-mono text-sm max-w-[280px] truncate"
title={log.model}
>
{log.model}
<TableCell className="font-mono text-xs max-w-[200px]">
<div
className="truncate"
title={
log.requestModel && log.requestModel !== log.model
? `${t("usage.requestModel")}: ${log.requestModel}\n${t("usage.responseModel")}: ${log.model}`
: log.model
}
>
{log.model}
</div>
{log.requestModel && log.requestModel !== log.model && (
<div
className="truncate text-muted-foreground text-[10px]"
title={log.requestModel}
>
{log.requestModel}
</div>
)}
</TableCell>
<TableCell className="text-right">
{log.inputTokens.toLocaleString()}
@@ -315,6 +332,15 @@ export function RequestLogTable() {
<TableCell className="text-right">
{log.cacheCreationTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{parseFloat(log.costMultiplier) !== 1 ? (
<span className="text-orange-600">
×{log.costMultiplier}
</span>
) : (
<span className="text-muted-foreground">×1</span>
)}
</TableCell>
<TableCell className="text-right">
${parseFloat(log.totalCostUsd).toFixed(6)}
</TableCell>
+43 -3
View File
@@ -376,7 +376,21 @@
"saved": "Proxy settings saved",
"saveFailed": "Save failed: {{error}}",
"testSuccess": "Connected! Latency {{latency}}ms",
"testFailed": "Connection failed: {{error}}"
"testFailed": "Connection failed: {{error}}",
"pricingDefaultsTitle": "Pricing Defaults",
"pricingDefaultsDescription": "Set the default multiplier and pricing model source per app.",
"pricingAppLabel": "App",
"defaultCostMultiplierLabel": "Default Multiplier",
"defaultCostMultiplierHint": "Multiplier for cost calculation, decimals supported.",
"pricingModelSourceLabel": "Pricing Model Source",
"pricingModelSourceRequest": "Request model",
"pricingModelSourceResponse": "Response model",
"pricingSave": "Save Pricing Defaults",
"pricingSaved": "Pricing defaults saved",
"pricingSaveFailed": "Failed to save pricing defaults: {{error}}",
"pricingLoadFailed": "Failed to load pricing defaults: {{error}}",
"defaultCostMultiplierRequired": "Default multiplier is required",
"defaultCostMultiplierInvalid": "Invalid multiplier format"
}
},
"apps": {
@@ -512,7 +526,18 @@
"useCustomProxy": "Use separate proxy",
"proxyConfigDesc": "Configure separate network proxy for this provider. Uses system proxy or global settings when disabled.",
"proxyUsername": "Username (optional)",
"proxyPassword": "Password (optional)"
"proxyPassword": "Password (optional)",
"pricingConfig": "Pricing Config",
"useCustomPricing": "Use separate config",
"pricingConfigDesc": "Configure separate pricing parameters for this provider. Uses global defaults when disabled.",
"costMultiplier": "Cost Multiplier",
"costMultiplierPlaceholder": "Leave empty to use global default (1)",
"costMultiplierHint": "Actual cost = Base cost × Multiplier, supports decimals like 1.5",
"pricingModelSourceLabel": "Pricing Mode",
"pricingModelSourceInherit": "Inherit global default",
"pricingModelSourceRequest": "Request model",
"pricingModelSourceResponse": "Response model",
"pricingModelSourceHint": "Choose whether to match pricing by request model or response model"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
@@ -615,6 +640,9 @@
"cacheCreationTokens": "Cache Creation",
"timingInfo": "Duration/TTFT",
"status": "Status",
"multiplier": "Multiplier",
"requestModel": "Request Model",
"responseModel": "Response Model",
"noData": "No data",
"unknownProvider": "Unknown Provider",
"stream": "Stream",
@@ -657,7 +685,19 @@
"input": "Input",
"output": "Output",
"cacheWrite": "Creation",
"cacheRead": "Hit"
"cacheRead": "Hit",
"baseCost": "Base",
"costMultiplier": "Cost Multiplier",
"withMultiplier": "with multiplier",
"requestDetail": "Request Detail",
"requestNotFound": "Request not found",
"basicInfo": "Basic Info",
"tokenUsage": "Token Usage",
"cacheCreationCost": "Cache Creation Cost",
"costBreakdown": "Cost Breakdown",
"performance": "Performance",
"latency": "Latency",
"errorMessage": "Error Message"
},
"usageScript": {
"title": "Configure Usage Query",
+43 -3
View File
@@ -376,7 +376,21 @@
"saved": "プロキシ設定を保存しました",
"saveFailed": "保存に失敗しました: {{error}}",
"testSuccess": "接続成功!遅延 {{latency}}ms",
"testFailed": "接続に失敗しました: {{error}}"
"testFailed": "接続に失敗しました: {{error}}",
"pricingDefaultsTitle": "課金のデフォルト設定",
"pricingDefaultsDescription": "アプリごとのデフォルト倍率と課金モードを設定します。",
"pricingAppLabel": "アプリ",
"defaultCostMultiplierLabel": "デフォルト倍率",
"defaultCostMultiplierHint": "コスト計算用の倍率(小数対応)。",
"pricingModelSourceLabel": "課金モード",
"pricingModelSourceRequest": "リクエストモデル",
"pricingModelSourceResponse": "レスポンスモデル",
"pricingSave": "課金設定を保存",
"pricingSaved": "課金設定を保存しました",
"pricingSaveFailed": "課金設定の保存に失敗しました: {{error}}",
"pricingLoadFailed": "課金設定の読み込みに失敗しました: {{error}}",
"defaultCostMultiplierRequired": "デフォルト倍率は必須です",
"defaultCostMultiplierInvalid": "デフォルト倍率の形式が正しくありません"
}
},
"apps": {
@@ -512,7 +526,18 @@
"useCustomProxy": "個別プロキシを使用",
"proxyConfigDesc": "このプロバイダーに個別のネットワークプロキシを設定します。無効の場合はシステムプロキシまたはグローバル設定を使用します。",
"proxyUsername": "ユーザー名(任意)",
"proxyPassword": "パスワード(任意)"
"proxyPassword": "パスワード(任意)",
"pricingConfig": "課金設定",
"useCustomPricing": "個別設定を使用",
"pricingConfigDesc": "このプロバイダーに個別の課金パラメータを設定します。無効の場合はグローバル設定を使用します。",
"costMultiplier": "コスト倍率",
"costMultiplierPlaceholder": "空白の場合はグローバル設定を使用(1)",
"costMultiplierHint": "実際のコスト = 基本コスト × 倍率、1.5 などの小数をサポート",
"pricingModelSourceLabel": "課金モード",
"pricingModelSourceInherit": "グローバル設定を継承",
"pricingModelSourceRequest": "リクエストモデル",
"pricingModelSourceResponse": "レスポンスモデル",
"pricingModelSourceHint": "リクエストモデルまたはレスポンスモデルで価格を照合するかを選択"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
@@ -615,6 +640,9 @@
"cacheCreationTokens": "キャッシュ作成",
"timingInfo": "応答時間/TTFT",
"status": "ステータス",
"multiplier": "倍率",
"requestModel": "リクエストモデル",
"responseModel": "レスポンスモデル",
"noData": "データなし",
"unknownProvider": "不明なプロバイダー",
"stream": "ストリーム",
@@ -657,7 +685,19 @@
"input": "Input",
"output": "Output",
"cacheWrite": "作成",
"cacheRead": "ヒット"
"cacheRead": "ヒット",
"baseCost": "基本",
"costMultiplier": "コスト倍率",
"withMultiplier": "倍率込み",
"requestDetail": "リクエスト詳細",
"requestNotFound": "リクエストが見つかりません",
"basicInfo": "基本情報",
"tokenUsage": "Token 使用量",
"cacheCreationCost": "キャッシュ作成コスト",
"costBreakdown": "コスト明細",
"performance": "パフォーマンス",
"latency": "レイテンシー",
"errorMessage": "エラーメッセージ"
},
"usageScript": {
"title": "利用状況を設定",
+43 -3
View File
@@ -376,7 +376,21 @@
"saved": "代理设置已保存",
"saveFailed": "保存失败:{{error}}",
"testSuccess": "连接成功!延迟 {{latency}}ms",
"testFailed": "连接失败:{{error}}"
"testFailed": "连接失败:{{error}}",
"pricingDefaultsTitle": "计费默认配置",
"pricingDefaultsDescription": "设置各应用的默认倍率与计费模式来源。",
"pricingAppLabel": "应用",
"defaultCostMultiplierLabel": "默认倍率",
"defaultCostMultiplierHint": "用于成本计算的倍率,支持小数。",
"pricingModelSourceLabel": "计费模式",
"pricingModelSourceRequest": "请求模型",
"pricingModelSourceResponse": "返回模型",
"pricingSave": "保存计费配置",
"pricingSaved": "计费配置已保存",
"pricingSaveFailed": "保存计费配置失败:{{error}}",
"pricingLoadFailed": "加载计费配置失败:{{error}}",
"defaultCostMultiplierRequired": "默认倍率不能为空",
"defaultCostMultiplierInvalid": "默认倍率格式不正确"
}
},
"apps": {
@@ -512,7 +526,18 @@
"useCustomProxy": "使用单独代理",
"proxyConfigDesc": "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。",
"proxyUsername": "用户名(可选)",
"proxyPassword": "密码(可选)"
"proxyPassword": "密码(可选)",
"pricingConfig": "计费配置",
"useCustomPricing": "使用单独配置",
"pricingConfigDesc": "为此供应商配置单独的计费参数,不启用时使用全局默认配置。",
"costMultiplier": "成本倍率",
"costMultiplierPlaceholder": "留空使用全局默认(1",
"costMultiplierHint": "实际成本 = 基础成本 × 倍率,支持小数如 1.5",
"pricingModelSourceLabel": "计费模式",
"pricingModelSourceInherit": "继承全局默认",
"pricingModelSourceRequest": "请求模型",
"pricingModelSourceResponse": "返回模型",
"pricingModelSourceHint": "选择按请求模型还是返回模型进行定价匹配"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
@@ -615,6 +640,9 @@
"cacheCreationTokens": "缓存创建",
"timingInfo": "用时/首字",
"status": "状态",
"multiplier": "倍率",
"requestModel": "请求模型",
"responseModel": "返回模型",
"noData": "暂无数据",
"unknownProvider": "未知供应商",
"stream": "流",
@@ -657,7 +685,19 @@
"input": "Input",
"output": "Output",
"cacheWrite": "创建",
"cacheRead": "命中"
"cacheRead": "命中",
"baseCost": "基础",
"costMultiplier": "成本倍率",
"withMultiplier": "含倍率",
"requestDetail": "请求详情",
"requestNotFound": "请求未找到",
"basicInfo": "基本信息",
"tokenUsage": "Token 使用量",
"cacheCreationCost": "缓存写入成本",
"costBreakdown": "成本明细",
"performance": "性能信息",
"latency": "延迟",
"errorMessage": "错误信息"
},
"usageScript": {
"title": "配置用量查询",
+25
View File
@@ -92,4 +92,29 @@ export const proxyApi = {
async updateProxyConfigForApp(config: AppProxyConfig): Promise<void> {
return invoke("update_proxy_config_for_app", { config });
},
// ========== 计费默认配置 API ==========
// 获取默认成本倍率
async getDefaultCostMultiplier(appType: string): Promise<string> {
return invoke("get_default_cost_multiplier", { appType });
},
// 设置默认成本倍率
async setDefaultCostMultiplier(
appType: string,
value: string,
): Promise<void> {
return invoke("set_default_cost_multiplier", { appType, value });
},
// 获取计费模式来源
async getPricingModelSource(appType: string): Promise<string> {
return invoke("get_pricing_model_source", { appType });
},
// 设置计费模式来源
async setPricingModelSource(appType: string, value: string): Promise<void> {
return invoke("set_pricing_model_source", { appType, value });
},
};
+4
View File
@@ -135,6 +135,10 @@ export interface ProviderMeta {
testConfig?: ProviderTestConfig;
// 供应商单独的代理配置
proxyConfig?: ProviderProxyConfig;
// 供应商成本倍率
costMultiplier?: string;
// 供应商计费模式来源
pricingModelSource?: string;
}
// Skill 同步方式
+2
View File
@@ -13,6 +13,8 @@ export interface RequestLog {
providerName?: string;
appType: string;
model: string;
requestModel?: string;
costMultiplier: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;