feat(usage): update model pricing from external catalogs
This commit is contained in:
1 parent
8806fe57eb
commit
2432220677
12 files changed
+979
-189
No files matched your search
Generated
+34
@@ -92,6 +92,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
@@ -526,6 +538,23 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -4567,12 +4596,17 @@ version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.13.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
||||
@@ -18,7 +18,7 @@ getrandom = "0.3"
|
||||
json5 = "0.4"
|
||||
notify = "7"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "socks", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["gzip", "json", "rustls-tls", "socks", "stream"] }
|
||||
rusqlite = { version = "0.32.1", features = ["bundled"] }
|
||||
semver = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-09-07",
|
||||
"updatedAt": "2026-09-24",
|
||||
"models": {
|
||||
"gpt-6-astra": { "inputPer1M": 10.0, "outputPer1M": 50.0, "cacheReadPer1M": 1.0, "cacheCreationPer1M": 12.5 },
|
||||
"gpt-6-sol": { "inputPer1M": 2.0, "outputPer1M": 10.0, "cacheReadPer1M": 0.2, "cacheCreationPer1M": 2.5 },
|
||||
"gpt-6-luna": { "inputPer1M": 0.1, "outputPer1M": 0.5, "cacheReadPer1M": 0.01, "cacheCreationPer1M": 0.125 },
|
||||
"gpt-5.6": { "inputPer1M": 4.0, "outputPer1M": 20.0, "cacheReadPer1M": 0.4, "cacheCreationPer1M": 5.0 },
|
||||
"gpt-5.6-sol": { "inputPer1M": 4.0, "outputPer1M": 20.0, "cacheReadPer1M": 0.4, "cacheCreationPer1M": 5.0 },
|
||||
"gpt-5.6-cyber": { "inputPer1M": 12.5, "outputPer1M": 75.0, "cacheReadPer1M": 1.25, "cacheCreationPer1M": 15.625 },
|
||||
"gpt-5.6-terra": { "inputPer1M": 2.0, "outputPer1M": 12.0, "cacheReadPer1M": 0.2, "cacheCreationPer1M": 2.5 },
|
||||
"gpt-5.6-luna": { "inputPer1M": 0.2, "outputPer1M": 1.2, "cacheReadPer1M": 0.02, "cacheCreationPer1M": 0.25 },
|
||||
"gpt-5.5": { "inputPer1M": 5.0, "outputPer1M": 30.0, "cacheReadPer1M": 0.5 },
|
||||
@@ -41,6 +44,7 @@
|
||||
"claude-mythos-5-1": { "inputPer1M": 10.0, "outputPer1M": 50.0, "cacheReadPer1M": 0.25, "cacheCreationPer1M": 12.5 },
|
||||
"claude-fable-5": { "inputPer1M": 10.0, "outputPer1M": 50.0, "cacheReadPer1M": 1.0, "cacheCreationPer1M": 12.5 },
|
||||
"claude-mythos-5": { "inputPer1M": 10.0, "outputPer1M": 50.0, "cacheReadPer1M": 1.0, "cacheCreationPer1M": 12.5 },
|
||||
"claude-opus-5-5": { "inputPer1M": 4.0, "outputPer1M": 20.0, "cacheReadPer1M": 0.2, "cacheCreationPer1M": 5.0 },
|
||||
"claude-opus-5": { "inputPer1M": 5.0, "outputPer1M": 25.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 },
|
||||
"claude-opus-4-8": { "inputPer1M": 5.0, "outputPer1M": 25.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 },
|
||||
"claude-opus-4-7": { "inputPer1M": 5.0, "outputPer1M": 25.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 },
|
||||
@@ -67,8 +71,10 @@
|
||||
"deepseek-v4-pro": { "inputPer1M": 1.32, "outputPer1M": 3.96, "cacheReadPer1M": 0.044, "cacheCreationPer1M": 0.0 },
|
||||
"deepseek-v4-flash": { "inputPer1M": 0.44, "outputPer1M": 1.32, "cacheReadPer1M": 0.014, "cacheCreationPer1M": 0.0 },
|
||||
"deepseek-v4-flash-vision-exp": { "inputPer1M": 0.44, "outputPer1M": 1.32, "cacheReadPer1M": 0.014, "cacheCreationPer1M": 0.0 },
|
||||
"deepseek-flash": { "inputPer1M": 0.3, "outputPer1M": 1.2, "cacheReadPer1M": 0.006, "cacheCreationPer1M": 0.0 },
|
||||
"deepseek-chat": { "inputPer1M": 0.28, "outputPer1M": 0.42, "cacheReadPer1M": 0.028 },
|
||||
"deepseek-reasoner": { "inputPer1M": 0.28, "outputPer1M": 0.42, "cacheReadPer1M": 0.028 },
|
||||
"grok-4.7": { "inputPer1M": 2.0, "outputPer1M": 6.0, "cacheReadPer1M": 0.5 },
|
||||
"grok-4.6": { "inputPer1M": 2.0, "outputPer1M": 6.0, "cacheReadPer1M": 0.5 },
|
||||
"grok-build-0.1": { "inputPer1M": 1.0, "outputPer1M": 2.0, "cacheReadPer1M": 0.2 },
|
||||
"grok-code-fast-1": { "inputPer1M": 1.0, "outputPer1M": 2.0, "cacheReadPer1M": 0.2 },
|
||||
|
||||
@@ -2724,7 +2724,8 @@ fn main() {
|
||||
usage::shrink_usage_database,
|
||||
usage::save_usage_model_price,
|
||||
usage::delete_usage_model_price,
|
||||
usage::sync_usage_model_prices,
|
||||
usage::preview_usage_model_prices,
|
||||
usage::apply_usage_model_prices,
|
||||
start_core_process,
|
||||
stop_core_process,
|
||||
restart_core_process,
|
||||
|
||||
+689
-116
File diff suppressed because it is too large.
Load diff
+14
-5
@@ -518,20 +518,29 @@ export const jaOverrides = {
|
||||
'usage.pricing.coverage': '{priced} / {total} 件を計算済み · 利用可能なモデル料金 {saved} 件',
|
||||
'usage.pricing.search': 'モデルを検索',
|
||||
'usage.pricing.add': '手動で追加',
|
||||
'usage.pricing.sync': '料金を同期',
|
||||
'usage.pricing.syncing': '同期中',
|
||||
'usage.pricing.syncResult': '料金を更新しました:{imported} 件を追加、手動料金 {skipped} 件を保持、{unmatched} 件は未一致',
|
||||
'usage.pricing.sync': '料金を更新',
|
||||
'usage.pricing.syncing': '読み込み中',
|
||||
'usage.pricing.syncResult': '{imported} 件のモデル料金を保存しました',
|
||||
'usage.pricing.syncSource': '料金ソース',
|
||||
'usage.pricing.syncPreview': '料金同期のプレビュー',
|
||||
'usage.pricing.syncNote': '選択した基本料金のみ保存します。時間帯料金、コンテキスト階層、動的エイリアスは確認してください。手動料金は最初は選択されません。',
|
||||
'usage.pricing.syncCounts': '一致 {matched} 件、未一致 {unmatched} 件',
|
||||
'usage.pricing.selectAll': 'すべて選択',
|
||||
'usage.pricing.selectNone': '選択解除',
|
||||
'usage.pricing.syncNoMatches': '一致するモデル料金がありません',
|
||||
'usage.pricing.syncUnmatched': '未一致の {count} 件を表示',
|
||||
'usage.pricing.syncInvalid': '0 以上の有効な料金を入力してください。入力・出力料金は必須です',
|
||||
'usage.pricing.applySelected': '選択した {count} 件を保存',
|
||||
'usage.pricing.syncFallback': 'リモート料金を読み込めなかったため、内蔵料金を引き続き使用します',
|
||||
'usage.pricing.model': 'モデル',
|
||||
'usage.pricing.calls': 'リクエスト数',
|
||||
'usage.pricing.cost': '推定コスト',
|
||||
'usage.pricing.prompt': '入力料金 ($/1M)',
|
||||
'usage.pricing.completion': '出力料金 ($/1M)',
|
||||
'usage.pricing.cache': '互換キャッシュ料金 ($/1M)',
|
||||
'usage.pricing.cacheRead': 'キャッシュ読み取り料金 ($/1M)',
|
||||
'usage.pricing.cacheCreation': 'キャッシュ作成料金 ($/1M)',
|
||||
'usage.pricing.actions': '操作',
|
||||
'usage.pricing.optional': '任意。空欄の場合は代替料金を使用',
|
||||
'usage.pricing.optional': '任意。空欄の場合は 0 で計算',
|
||||
'usage.pricing.modelRequired': 'モデル名を入力してください',
|
||||
'usage.pricing.saving': '保存中',
|
||||
'usage.pricing.saved': 'モデル料金を保存し、概要のコストを更新しました',
|
||||
|
||||
+14
-5
@@ -505,20 +505,29 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.pricing.coverage': '{priced} / {total} requests priced · {saved} model prices available',
|
||||
'usage.pricing.search': 'Search model',
|
||||
'usage.pricing.add': 'Add Manually',
|
||||
'usage.pricing.sync': 'Sync Prices',
|
||||
'usage.pricing.syncing': 'Syncing',
|
||||
'usage.pricing.syncResult': 'Prices updated: {imported} imported, {skipped} manual prices kept, {unmatched} unmatched',
|
||||
'usage.pricing.sync': 'Update Prices',
|
||||
'usage.pricing.syncing': 'Loading',
|
||||
'usage.pricing.syncResult': 'Saved prices for {imported} models',
|
||||
'usage.pricing.syncSource': 'Price source',
|
||||
'usage.pricing.syncPreview': 'Price sync preview',
|
||||
'usage.pricing.syncNote': 'Only selected base prices are saved. Check time-based prices, context tiers, and dynamic aliases yourself. Manual prices are initially unselected.',
|
||||
'usage.pricing.syncCounts': '{matched} matched, {unmatched} unmatched',
|
||||
'usage.pricing.selectAll': 'Select all',
|
||||
'usage.pricing.selectNone': 'Select none',
|
||||
'usage.pricing.syncNoMatches': 'No matching model prices',
|
||||
'usage.pricing.syncUnmatched': 'Show {count} unmatched models',
|
||||
'usage.pricing.syncInvalid': 'Enter valid nonnegative prices; input and output prices are required',
|
||||
'usage.pricing.applySelected': 'Save {count} selected',
|
||||
'usage.pricing.syncFallback': 'Remote prices could not be read; built-in prices remain active',
|
||||
'usage.pricing.model': 'Model',
|
||||
'usage.pricing.calls': 'Requests',
|
||||
'usage.pricing.cost': 'Estimated Cost',
|
||||
'usage.pricing.prompt': 'Input ($/1M)',
|
||||
'usage.pricing.completion': 'Output ($/1M)',
|
||||
'usage.pricing.cache': 'Legacy Cache ($/1M)',
|
||||
'usage.pricing.cacheRead': 'Cache Read ($/1M)',
|
||||
'usage.pricing.cacheCreation': 'Cache Creation ($/1M)',
|
||||
'usage.pricing.actions': 'Actions',
|
||||
'usage.pricing.optional': 'Optional; blank uses fallback',
|
||||
'usage.pricing.optional': 'Optional; blank is billed at 0',
|
||||
'usage.pricing.modelRequired': 'Enter a model name',
|
||||
'usage.pricing.saving': 'Saving',
|
||||
'usage.pricing.saved': 'Model price saved; overview cost updated',
|
||||
|
||||
@@ -504,20 +504,29 @@ export const zhCN = {
|
||||
'usage.pricing.coverage': '已计价 {priced} / {total} 次请求 · 可用 {saved} 个模型价格',
|
||||
'usage.pricing.search': '搜索模型',
|
||||
'usage.pricing.add': '手动添加',
|
||||
'usage.pricing.sync': '同步价格',
|
||||
'usage.pricing.syncing': '同步中',
|
||||
'usage.pricing.syncResult': '价格更新完成:导入 {imported},保留手动价格 {skipped},未匹配 {unmatched}',
|
||||
'usage.pricing.sync': '更新价格',
|
||||
'usage.pricing.syncing': '读取中',
|
||||
'usage.pricing.syncResult': '已保存 {imported} 个模型价格',
|
||||
'usage.pricing.syncSource': '价格来源',
|
||||
'usage.pricing.syncPreview': '价格同步预览',
|
||||
'usage.pricing.syncNote': '仅保存勾选的基础价格。时段价、上下文阶梯价和动态别名需自行核对;手动价格默认不勾选。',
|
||||
'usage.pricing.syncCounts': '匹配 {matched} 个,未匹配 {unmatched} 个',
|
||||
'usage.pricing.selectAll': '全选',
|
||||
'usage.pricing.selectNone': '取消全选',
|
||||
'usage.pricing.syncNoMatches': '没有可同步的模型价格',
|
||||
'usage.pricing.syncUnmatched': '查看 {count} 个未匹配模型',
|
||||
'usage.pricing.syncInvalid': '请输入有效的非负价格;输入价和输出价不能为空',
|
||||
'usage.pricing.applySelected': '保存所选 {count} 项',
|
||||
'usage.pricing.syncFallback': '远程价格读取失败,已继续使用软件内置价格',
|
||||
'usage.pricing.model': '模型',
|
||||
'usage.pricing.calls': '请求数',
|
||||
'usage.pricing.cost': '预估成本',
|
||||
'usage.pricing.prompt': '输入价 ($/1M)',
|
||||
'usage.pricing.completion': '输出价 ($/1M)',
|
||||
'usage.pricing.cache': '兼容缓存价 ($/1M)',
|
||||
'usage.pricing.cacheRead': '缓存读取价 ($/1M)',
|
||||
'usage.pricing.cacheCreation': '缓存创建价 ($/1M)',
|
||||
'usage.pricing.actions': '操作',
|
||||
'usage.pricing.optional': '可选,留空使用回退价',
|
||||
'usage.pricing.optional': '可选,留空按 0 计费',
|
||||
'usage.pricing.modelRequired': '请输入模型名称',
|
||||
'usage.pricing.saving': '保存中',
|
||||
'usage.pricing.saved': '模型价格已保存,总览成本已更新',
|
||||
|
||||
+137
-50
@@ -135,7 +135,6 @@ type ModelPrice = {
|
||||
model: string;
|
||||
prompt: number;
|
||||
completion: number;
|
||||
cache: number;
|
||||
cacheRead: number;
|
||||
cacheCreation: number;
|
||||
promptConfigured: boolean;
|
||||
@@ -183,9 +182,22 @@ type UsageStorageSettings = {
|
||||
|
||||
type ModelPriceSyncResult = {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
type ModelPriceSyncPreview = {
|
||||
source: string;
|
||||
sourceUrl: string;
|
||||
matches: ModelPrice[];
|
||||
unmatched: string[];
|
||||
usedBuiltin: boolean;
|
||||
};
|
||||
|
||||
type SyncPriceDraft = {
|
||||
selected: boolean;
|
||||
price: ModelPrice;
|
||||
prompt: string;
|
||||
completion: string;
|
||||
cacheRead: string;
|
||||
cacheCreation: string;
|
||||
};
|
||||
|
||||
type UsageQuery = {
|
||||
@@ -2271,7 +2283,6 @@ type PriceDraft = {
|
||||
model: string;
|
||||
prompt: string;
|
||||
completion: string;
|
||||
cache: string;
|
||||
cacheRead: string;
|
||||
cacheCreation: string;
|
||||
};
|
||||
@@ -2280,7 +2291,6 @@ const emptyPriceDraft = (): PriceDraft => ({
|
||||
model: '',
|
||||
prompt: '',
|
||||
completion: '',
|
||||
cache: '',
|
||||
cacheRead: '',
|
||||
cacheCreation: '',
|
||||
});
|
||||
@@ -2288,9 +2298,8 @@ const priceDraftFor = (model = '', price?: ModelPrice | null): PriceDraft => ({
|
||||
model,
|
||||
prompt: price ? String(price.prompt) : '',
|
||||
completion: price ? String(price.completion) : '',
|
||||
cache: price ? String(price.cache) : '',
|
||||
cacheRead: price && (price.cacheReadConfigured || price.cacheRead > 0) ? String(price.cacheRead) : '',
|
||||
cacheCreation: price && (price.cacheCreationConfigured || price.cacheCreation > 0) ? String(price.cacheCreation) : '',
|
||||
cacheRead: price ? String(price.cacheRead) : '',
|
||||
cacheCreation: price ? String(price.cacheCreation) : '',
|
||||
});
|
||||
|
||||
const parsePrice = (value: string) => {
|
||||
@@ -2315,6 +2324,15 @@ function PricingView({
|
||||
const [draft, setDraft] = useState<PriceDraft | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [applyingSync, setApplyingSync] = useState(false);
|
||||
const [syncSource, setSyncSource] = useState<'models-dev' | 'litellm'>(() => {
|
||||
try { return localStorage.getItem('cpa-gui.pricing-sync-source.v1') === 'litellm' ? 'litellm' : 'models-dev'; }
|
||||
catch { return 'models-dev'; }
|
||||
});
|
||||
const [syncPreview, setSyncPreview] = useState<ModelPriceSyncPreview | null>(null);
|
||||
const [syncDrafts, setSyncDrafts] = useState<SyncPriceDraft[]>([]);
|
||||
const [syncError, setSyncError] = useState('');
|
||||
const syncDialog = useRef<HTMLDialogElement>(null);
|
||||
const { notice, revision, showNotice, clearNotice } = useAppNotice();
|
||||
|
||||
|
||||
@@ -2336,7 +2354,6 @@ function PricingView({
|
||||
model: draft.model.trim(),
|
||||
prompt: parsePrice(draft.prompt),
|
||||
completion: parsePrice(draft.completion),
|
||||
cache: draft.cache.trim() ? parsePrice(draft.cache) : parsePrice(draft.prompt),
|
||||
cacheRead: parsePrice(draft.cacheRead),
|
||||
cacheCreation: parsePrice(draft.cacheCreation),
|
||||
promptConfigured: draft.prompt.trim() !== '',
|
||||
@@ -2370,20 +2387,23 @@ function PricingView({
|
||||
}
|
||||
};
|
||||
|
||||
const syncPrices = async () => {
|
||||
const previewPrices = async () => {
|
||||
setSyncing(true);
|
||||
setSyncError('');
|
||||
clearNotice();
|
||||
try {
|
||||
const result = await invoke<ModelPriceSyncResult>('sync_usage_model_prices', { query });
|
||||
showNotice({
|
||||
key: 'usage.pricing.syncResult',
|
||||
variables: {
|
||||
imported: result.imported,
|
||||
skipped: result.skipped,
|
||||
unmatched: result.unmatched.length,
|
||||
},
|
||||
});
|
||||
await onChanged();
|
||||
const preview = await invoke<ModelPriceSyncPreview>('preview_usage_model_prices', { query, source: syncSource });
|
||||
const existing = new Map(pricing.rows.map((row) => [row.model.toLowerCase(), row.price]));
|
||||
setSyncPreview(preview);
|
||||
setSyncDrafts(preview.matches.map((price) => ({
|
||||
selected: existing.get(price.model.toLowerCase())?.source !== 'manual',
|
||||
price,
|
||||
prompt: String(price.prompt),
|
||||
completion: String(price.completion),
|
||||
cacheRead: String(price.cacheRead),
|
||||
cacheCreation: String(price.cacheCreation),
|
||||
})));
|
||||
syncDialog.current?.showModal();
|
||||
} catch (syncError) {
|
||||
showNotice(String(syncError), 'error');
|
||||
} finally {
|
||||
@@ -2391,6 +2411,49 @@ function PricingView({
|
||||
}
|
||||
};
|
||||
|
||||
const applySyncPrices = async () => {
|
||||
const selected = syncDrafts.filter((item) => item.selected);
|
||||
if (!selected.length) return;
|
||||
const valid = (value: string) => value.trim() !== '' && Number.isFinite(Number(value)) && Number(value) >= 0;
|
||||
if (selected.some((item) => !valid(item.prompt) || !valid(item.completion)
|
||||
|| (item.cacheRead.trim() !== '' && !valid(item.cacheRead))
|
||||
|| (item.cacheCreation.trim() !== '' && !valid(item.cacheCreation)))) {
|
||||
setSyncError(t('usage.pricing.syncInvalid'));
|
||||
return;
|
||||
}
|
||||
const prices = selected.map((item): ModelPrice => ({
|
||||
...item.price,
|
||||
prompt: Number(item.prompt),
|
||||
completion: Number(item.completion),
|
||||
cacheRead: item.cacheRead.trim() === '' ? 0 : Number(item.cacheRead),
|
||||
cacheCreation: item.cacheCreation.trim() === '' ? 0 : Number(item.cacheCreation),
|
||||
cacheReadConfigured: item.cacheRead.trim() !== '',
|
||||
cacheCreationConfigured: item.cacheCreation.trim() !== '',
|
||||
}));
|
||||
setApplyingSync(true);
|
||||
setSyncError('');
|
||||
clearNotice();
|
||||
try {
|
||||
const result = await invoke<ModelPriceSyncResult>('apply_usage_model_prices', { prices });
|
||||
syncDialog.current?.close();
|
||||
showNotice({ key: 'usage.pricing.syncResult', variables: { imported: result.imported } });
|
||||
await onChanged();
|
||||
} catch (syncError) {
|
||||
setSyncError(String(syncError));
|
||||
} finally {
|
||||
setApplyingSync(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSyncDraft = (index: number, update: Partial<SyncPriceDraft>) => {
|
||||
setSyncDrafts((current) => current.map((item, currentIndex) => currentIndex === index ? { ...item, ...update } : item));
|
||||
};
|
||||
|
||||
const changeSyncSource = (source: 'models-dev' | 'litellm') => {
|
||||
setSyncSource(source);
|
||||
try { localStorage.setItem('cpa-gui.pricing-sync-source.v1', source); } catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel usage-pricing-panel">
|
||||
{confirmationDialog}
|
||||
@@ -2415,8 +2478,11 @@ function PricingView({
|
||||
<button type="button" className="secondary-button" onClick={() => setDraft(emptyPriceDraft())}>
|
||||
{t('usage.pricing.add')}
|
||||
</button>
|
||||
<button type="button" className="primary-button" disabled={syncing} onClick={() => void syncPrices()}>
|
||||
<RefreshCw size={14} className={syncing ? 'spin' : ''} />
|
||||
<select value={syncSource} onChange={(event) => changeSyncSource(event.currentTarget.value as 'models-dev' | 'litellm')} aria-label={t('usage.pricing.syncSource')}>
|
||||
<option value="models-dev">Models.dev</option>
|
||||
<option value="litellm">LiteLLM</option>
|
||||
</select>
|
||||
<button type="button" className="primary-button" disabled={syncing} onClick={() => void previewPrices()}>
|
||||
{syncing ? t('usage.pricing.syncing') : t('usage.pricing.sync')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -2424,6 +2490,53 @@ function PricingView({
|
||||
|
||||
<FloatingNotice key={revision} notice={notice} onDismiss={clearNotice} />
|
||||
|
||||
<dialog ref={syncDialog} className="usage-price-sync-dialog" onCancel={(event) => { if (applyingSync) event.preventDefault(); }} onClose={() => setSyncPreview(null)}>
|
||||
<div className="usage-price-sync-header">
|
||||
<div>
|
||||
<h2>{t('usage.pricing.syncPreview')}</h2>
|
||||
<a href={syncPreview?.sourceUrl} target="_blank" rel="noreferrer">{syncPreview?.source}</a>
|
||||
</div>
|
||||
<button type="button" className="icon-button" aria-label={t('common.close')} disabled={applyingSync} onClick={() => syncDialog.current?.close()}><X size={16} /></button>
|
||||
</div>
|
||||
<p className="usage-price-sync-note">{t('usage.pricing.syncNote')}</p>
|
||||
<div className="usage-price-sync-controls">
|
||||
<span>{t('usage.pricing.syncCounts', { matched: syncDrafts.length, unmatched: syncPreview?.unmatched.length ?? 0 })}</span>
|
||||
<button type="button" className="secondary-button" onClick={() => setSyncDrafts((current) => current.map((item) => ({ ...item, selected: true })))}>{t('usage.pricing.selectAll')}</button>
|
||||
<button type="button" className="secondary-button" onClick={() => setSyncDrafts((current) => current.map((item) => ({ ...item, selected: false })))}>{t('usage.pricing.selectNone')}</button>
|
||||
</div>
|
||||
<div className="usage-price-sync-list">
|
||||
{syncDrafts.map((item, index) => (
|
||||
<div className="usage-price-sync-row" key={item.price.model}>
|
||||
<label className="usage-price-sync-identity">
|
||||
<input type="checkbox" checked={item.selected} disabled={applyingSync} onChange={(event) => updateSyncDraft(index, { selected: event.currentTarget.checked })} />
|
||||
<span><strong>{item.price.model}</strong><small>{item.price.sourceModelId}</small></span>
|
||||
</label>
|
||||
{(['prompt', 'completion', 'cacheRead', 'cacheCreation'] as const).map((field) => (
|
||||
<label key={field}>
|
||||
<span>{t(`usage.pricing.${field}`)}</span>
|
||||
<input type="number" min="0" step="any" value={item[field]} disabled={applyingSync}
|
||||
onChange={(event) => updateSyncDraft(index, { [field]: event.currentTarget.value })} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{syncDrafts.length === 0 && <p>{t('usage.pricing.syncNoMatches')}</p>}
|
||||
</div>
|
||||
{(syncPreview?.unmatched.length ?? 0) > 0 && (
|
||||
<details className="usage-price-sync-unmatched">
|
||||
<summary>{t('usage.pricing.syncUnmatched', { count: syncPreview?.unmatched.length ?? 0 })}</summary>
|
||||
<p>{syncPreview?.unmatched.join(', ')}</p>
|
||||
</details>
|
||||
)}
|
||||
{syncError && <p className="usage-price-sync-error" role="alert">{syncError}</p>}
|
||||
<div className="usage-price-sync-footer">
|
||||
<button type="button" className="secondary-button" disabled={applyingSync} onClick={() => syncDialog.current?.close()}>{t('common.cancel')}</button>
|
||||
<button type="button" className="primary-button" disabled={applyingSync || !syncDrafts.some((item) => item.selected)} onClick={() => void applySyncPrices()}>
|
||||
{applyingSync ? t('usage.pricing.saving') : t('usage.pricing.applySelected', { count: syncDrafts.filter((item) => item.selected).length })}
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{draft ? (
|
||||
<div className="usage-price-editor">
|
||||
<label>
|
||||
@@ -2454,16 +2567,6 @@ function PricingView({
|
||||
onChange={(event) => setDraft({ ...draft, completion: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('usage.pricing.cache')}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
value={draft.cache}
|
||||
onChange={(event) => setDraft({ ...draft, cache: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('usage.pricing.cacheRead')}</span>
|
||||
<input
|
||||
@@ -2527,24 +2630,8 @@ function PricingView({
|
||||
</td>
|
||||
<td>{row.price ? priceUnit(row.price.prompt) : '—'}</td>
|
||||
<td>{row.price ? priceUnit(row.price.completion) : '—'}</td>
|
||||
<td>
|
||||
{row.price
|
||||
? priceUnit(
|
||||
row.price.cacheReadConfigured || row.price.cacheRead > 0
|
||||
? row.price.cacheRead
|
||||
: row.price.cache
|
||||
)
|
||||
: '—'}
|
||||
</td>
|
||||
<td>
|
||||
{row.price
|
||||
? priceUnit(
|
||||
row.price.cacheCreationConfigured || row.price.cacheCreation > 0
|
||||
? row.price.cacheCreation
|
||||
: row.price.prompt
|
||||
)
|
||||
: '—'}
|
||||
</td>
|
||||
<td>{row.price ? priceUnit(row.price.cacheRead) : '—'}</td>
|
||||
<td>{row.price ? priceUnit(row.price.cacheCreation) : '—'}</td>
|
||||
<td>
|
||||
<div className="usage-price-row-actions">
|
||||
<button
|
||||
|
||||
+53
-1
@@ -9922,7 +9922,8 @@ body.table-col-resizing * {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-pricing-actions input {
|
||||
.usage-pricing-actions input,
|
||||
.usage-pricing-actions select {
|
||||
min-width: 180px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
@@ -9938,6 +9939,57 @@ body.table-col-resizing * {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.usage-price-sync-dialog {
|
||||
width: min(1100px, calc(100vw - 32px));
|
||||
max-height: min(760px, calc(100vh - 32px));
|
||||
padding: 20px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 10px;
|
||||
background: var(--theme-fff);
|
||||
color: var(--theme-2d2a26);
|
||||
}
|
||||
|
||||
.usage-price-sync-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.usage-price-sync-header,
|
||||
.usage-price-sync-controls,
|
||||
.usage-price-sync-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.usage-price-sync-header h2 { margin: 0 0 4px; }
|
||||
.usage-price-sync-note { color: var(--theme-7a746c); }
|
||||
.usage-price-sync-controls { justify-content: flex-start; margin: 14px 0; flex-wrap: wrap; }
|
||||
.usage-price-sync-controls span { margin-right: auto; }
|
||||
.usage-price-sync-list { max-height: 430px; overflow: auto; }
|
||||
.usage-price-sync-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1.5fr) repeat(4, minmax(115px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid var(--theme-eeeae2);
|
||||
}
|
||||
.usage-price-sync-row label:not(.usage-price-sync-identity) { display: grid; gap: 4px; font-size: var(--font-size-caption); }
|
||||
.usage-price-sync-row input[type="number"] { width: 100%; min-width: 0; height: 34px; padding: 0 8px; border: 1px solid var(--theme-d5d2cb); border-radius: 6px; }
|
||||
.usage-price-sync-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.usage-price-sync-identity span { display: grid; min-width: 0; overflow-wrap: anywhere; }
|
||||
.usage-price-sync-identity small { color: var(--theme-8a847c); }
|
||||
.usage-price-sync-unmatched { margin-top: 12px; }
|
||||
.usage-price-sync-unmatched p { max-height: 90px; overflow: auto; overflow-wrap: anywhere; }
|
||||
.usage-price-sync-error { color: var(--theme-error, #b42318); }
|
||||
.usage-price-sync-footer { justify-content: flex-end; margin-top: 14px; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.usage-price-sync-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.usage-price-sync-identity { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
.usage-price-editor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
|
||||
+8
-2
@@ -23,10 +23,16 @@ mockIPC(async (cmd) => {
|
||||
rows: [{ model: 'test-model', requests: 1, totalTokens: 100, estimatedCost: 0, price: null }],
|
||||
totalCost: 0, totalRequests: 1, pricedRequests: 0, savedPrices: 0,
|
||||
};
|
||||
if (cmd === 'sync_usage_model_prices') {
|
||||
if (cmd === 'preview_usage_model_prices') {
|
||||
if (fixture.feedbackFixture.failSync) throw new Error('price sync failed');
|
||||
return { imported: 77, skipped: 0, unmatched: ['test-model'], usedBuiltin: false };
|
||||
return {
|
||||
source: 'Models.dev', sourceUrl: 'https://models.dev/api.json', unmatched: [],
|
||||
matches: [{ model: 'test-model', prompt: 2, completion: 6, cache: 0, cacheRead: 0, cacheCreation: 0,
|
||||
promptConfigured: true, completionConfigured: true, cacheReadConfigured: false, cacheCreationConfigured: false,
|
||||
source: 'models.dev', sourceModelId: 'openai/test-model', updatedAtMs: 0 }],
|
||||
};
|
||||
}
|
||||
if (cmd === 'apply_usage_model_prices') return { imported: 1 };
|
||||
if (cmd === 'save_usage_model_price' || cmd === 'delete_usage_model_price') return null;
|
||||
throw new Error('Unexpected fixture command: ' + cmd);
|
||||
}, { shouldMockEvents: true });
|
||||
|
||||
@@ -117,8 +117,12 @@ const base = process.env.FEEDBACK_TEST_BASE_URL || 'http://127.0.0.1:1421';
|
||||
const table = page.locator('.usage-pricing-table'); await table.waitFor();
|
||||
const priceBefore = await table.boundingBox();
|
||||
const sync = page.locator('.usage-pricing-actions .primary-button');
|
||||
await sync.click(); await waitNotices(1);
|
||||
assert.ok((await notice.innerText()).includes('价格更新完成:导入 77,保留手动价格 0,未匹配 1'));
|
||||
await sync.click();
|
||||
const priceDialog = page.locator('.usage-price-sync-dialog');
|
||||
await priceDialog.waitFor({ state: 'visible' });
|
||||
assert.equal(await notice.count(), 0, 'Preview does not save prices');
|
||||
await priceDialog.getByRole('button', { name: '保存所选 1 项' }).click(); await waitNotices(1);
|
||||
assert.ok((await notice.innerText()).includes('已保存 1 个模型价格'));
|
||||
assert.deepEqual(await table.boundingBox(), priceBefore, 'Real pricing success never shifts table');
|
||||
if (process.env.FEEDBACK_SCREENSHOT) await page.screenshot({ path: process.env.FEEDBACK_SCREENSHOT });
|
||||
await notice.getByRole('button').click(); await waitNotices(0);
|
||||
|
||||
Reference in new issue
Block a user