@jeffusion/bungee-core 5.9.0 → 5.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +31348 -17337
- package/dist/plugins/ai-transformer/index.js.map +2 -2
- package/dist/plugins/anthropic-request-sanitizer/index.js.map +2 -2
- package/dist/plugins/anthropic-tool-name-transformer/index.js.map +2 -2
- package/dist/plugins/chatgpt-oauth/control.js +513 -1
- package/dist/plugins/chatgpt-oauth/control.js.map +12 -4
- package/dist/plugins/chatgpt-oauth/index.js +511 -4
- package/dist/plugins/chatgpt-oauth/index.js.map +13 -5
- package/dist/plugins/chatgpt-oauth/manifest.json +19 -0
- package/dist/plugins/deepseek-reasoning-fix/index.js.map +2 -2
- package/dist/plugins/key-access/control.js.map +1 -1
- package/dist/plugins/key-access/index.js.map +3 -3
- package/dist/plugins/key-access/ingress.js.map +1 -1
- package/dist/plugins/key-rate-limit/index.js.map +2 -2
- package/dist/plugins/local-accounts/control.js +68 -18
- package/dist/plugins/local-accounts/control.js.map +4 -4
- package/dist/plugins/local-accounts/index.js.map +2 -2
- package/dist/plugins/local-accounts/manifest.json +48 -2
- package/dist/plugins/local-accounts/ui/Settings.svelte +73 -1
- package/dist/plugins/model-mapping/control.js +72 -17741
- package/dist/plugins/model-mapping/control.js.map +6 -58
- package/dist/plugins/model-mapping/index.js +32 -17433
- package/dist/plugins/model-mapping/index.js.map +6 -58
- package/dist/plugins/model-mapping/manifest.json +30 -14
- package/dist/plugins/models-dev/control.js +1042 -0
- package/dist/plugins/models-dev/control.js.map +16 -0
- package/dist/plugins/models-dev/index.js +15418 -0
- package/dist/plugins/models-dev/index.js.map +134 -0
- package/dist/plugins/models-dev/manifest.json +199 -0
- package/dist/plugins/models-dev/ui/ModelsDevSettings.svelte +120 -0
- package/dist/plugins/openai-messages-to-chat/index.js.map +2 -2
- package/dist/plugins/signature-repair/index.js.map +2 -2
- package/dist/plugins/token-budget/control.js +524 -9
- package/dist/plugins/token-budget/control.js.map +12 -4
- package/dist/plugins/token-budget/index.js +519 -12
- package/dist/plugins/token-budget/index.js.map +13 -6
- package/dist/plugins/token-budget/manifest.json +16 -2
- package/dist/plugins/token-metering/index.js +93 -2
- package/dist/plugins/token-metering/index.js.map +12 -6
- package/dist/plugins/token-stats/control.js +174 -312
- package/dist/plugins/token-stats/control.js.map +7 -7
- package/dist/plugins/token-stats/index.js +283 -486
- package/dist/plugins/token-stats/index.js.map +16 -12
- package/dist/plugins/token-stats/manifest.json +99 -12
- package/dist/plugins/token-stats/ui/ClientModelPicker.svelte +76 -0
- package/dist/plugins/token-stats/ui/PriceModelPicker.svelte +87 -0
- package/dist/plugins/token-stats/ui/TokenStatsSettings.svelte +136 -80
- package/dist/plugins/token-stats/ui/client-model-search.test.ts +58 -0
- package/dist/plugins/token-stats/ui/client-model-search.ts +16 -0
- package/dist/plugins/token-stats/ui/model-search.ts +45 -0
- package/dist/plugins/token-stats/ui/pricing-catalog.test.ts +114 -0
- package/dist/plugins/token-stats/ui/pricing-catalog.ts +17 -0
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "token-stats",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"schemaVersion": 2,
|
|
5
5
|
"artifactKind": "runtime-plugin",
|
|
6
6
|
"main": "index.js",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
]
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"token-metering": "^1.0.0"
|
|
46
|
+
"token-metering": "^1.0.0",
|
|
47
|
+
"models-dev": "^1.0.0"
|
|
47
48
|
},
|
|
48
49
|
"services": {
|
|
49
50
|
"provides": [
|
|
@@ -59,6 +60,20 @@
|
|
|
59
60
|
"version": 1,
|
|
60
61
|
"process": "worker",
|
|
61
62
|
"plugin": "token-metering"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "models-dev.catalog.v1",
|
|
66
|
+
"version": 1,
|
|
67
|
+
"process": "worker",
|
|
68
|
+
"plugin": "models-dev",
|
|
69
|
+
"kind": "local"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "models-dev.catalog.v1",
|
|
73
|
+
"version": 1,
|
|
74
|
+
"process": "control",
|
|
75
|
+
"plugin": "models-dev",
|
|
76
|
+
"kind": "local"
|
|
62
77
|
}
|
|
63
78
|
]
|
|
64
79
|
},
|
|
@@ -98,22 +113,40 @@
|
|
|
98
113
|
"capability": "config.read"
|
|
99
114
|
},
|
|
100
115
|
{
|
|
101
|
-
"path": "/pricing/
|
|
116
|
+
"path": "/pricing/models",
|
|
117
|
+
"methods": [
|
|
118
|
+
"GET"
|
|
119
|
+
],
|
|
120
|
+
"handler": "getPricingModels",
|
|
121
|
+
"execution": "control",
|
|
122
|
+
"capability": "config.read"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"path": "/pricing/mappings",
|
|
126
|
+
"methods": [
|
|
127
|
+
"GET"
|
|
128
|
+
],
|
|
129
|
+
"handler": "getPricingMappings",
|
|
130
|
+
"execution": "control",
|
|
131
|
+
"capability": "config.read"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"path": "/pricing/mappings",
|
|
102
135
|
"methods": [
|
|
103
136
|
"PUT"
|
|
104
137
|
],
|
|
105
|
-
"handler": "
|
|
138
|
+
"handler": "configurePricingMappings",
|
|
106
139
|
"execution": "control",
|
|
107
140
|
"capability": "config.write"
|
|
108
141
|
},
|
|
109
142
|
{
|
|
110
|
-
"path": "/
|
|
143
|
+
"path": "/models",
|
|
111
144
|
"methods": [
|
|
112
|
-
"
|
|
145
|
+
"GET"
|
|
113
146
|
],
|
|
114
|
-
"handler": "
|
|
147
|
+
"handler": "getClientModels",
|
|
115
148
|
"execution": "control",
|
|
116
|
-
"capability": "
|
|
149
|
+
"capability": "logs.read"
|
|
117
150
|
}
|
|
118
151
|
],
|
|
119
152
|
"navigation": [
|
|
@@ -158,7 +191,7 @@
|
|
|
158
191
|
"ui.loadFailed": "Unable to load token usage",
|
|
159
192
|
"ui.noData": "No token usage in this range",
|
|
160
193
|
"settings.title": "Price settings",
|
|
161
|
-
"settings.description": "
|
|
194
|
+
"settings.description": "Configure model price aliases for subsequent cost estimates. Manage catalog downloads in Models.dev Catalog settings.",
|
|
162
195
|
"settings.autoRefresh": "Automatic refresh",
|
|
163
196
|
"settings.interval": "Refresh interval (minutes)",
|
|
164
197
|
"settings.timeout": "Download timeout (seconds)",
|
|
@@ -186,6 +219,23 @@
|
|
|
186
219
|
"settings.networkError": "Network or source error",
|
|
187
220
|
"settings.invalid_catalogError": "No valid model prices returned",
|
|
188
221
|
"settings.storageError": "Unable to save the price cache",
|
|
222
|
+
"settings.mappingsTitle": "Model price aliases",
|
|
223
|
+
"settings.mappingsDescription": "Map an exact, case-sensitive client model name to a provider's catalog price. Explicit aliases take priority and only affect subsequent cost estimates, not requests, recorded model names or historical costs. Up to 100 aliases.",
|
|
224
|
+
"settings.mappingsNoCatalog": "No catalog models available. Refresh the catalog in Models.dev Catalog settings.",
|
|
225
|
+
"settings.modelsError": "Unable to load pricing models.",
|
|
226
|
+
"settings.mappingSource": "Client model alias",
|
|
227
|
+
"settings.mappingSourcePlaceholder": "e.g. deepseek-ai/deepseek-v4-flash",
|
|
228
|
+
"settings.mappingProvider": "Pricing provider",
|
|
229
|
+
"settings.mappingTarget": "Catalog model",
|
|
230
|
+
"settings.mappingSearch": "Search model ID or name",
|
|
231
|
+
"settings.mappingNoModels": "No matching pricing models",
|
|
232
|
+
"settings.mappingRemove": "Remove",
|
|
233
|
+
"settings.mappingAdd": "Add alias",
|
|
234
|
+
"settings.mappingRetry": "Reload aliases",
|
|
235
|
+
"settings.mappingsSave": "Save aliases",
|
|
236
|
+
"settings.mappingsSaved": "Aliases saved",
|
|
237
|
+
"settings.mappingsInvalid": "Enter a unique alias and select an available provider/model for every row. Aliases are case-sensitive; blank fields and leading/trailing whitespace are not allowed.",
|
|
238
|
+
"settings.mappingUnavailable": "Target is no longer in the price catalog. Its cost is unknown; select another model or remove this alias.",
|
|
189
239
|
"widgets.overview.title": "Token Overview",
|
|
190
240
|
"widgets.time.title": "Token Trend",
|
|
191
241
|
"page.title": "Token Statistics",
|
|
@@ -221,7 +271,17 @@
|
|
|
221
271
|
"page.key.all": "All",
|
|
222
272
|
"page.key.unattributed": "Unattributed / anonymous",
|
|
223
273
|
"page.key.missing": "Unavailable key",
|
|
224
|
-
"page.key.loadFailed": "Unable to load key names; the selected ID filter remains active."
|
|
274
|
+
"page.key.loadFailed": "Unable to load key names; the selected ID filter remains active.",
|
|
275
|
+
"settings.catalogState": "Status",
|
|
276
|
+
"settings.catalogVersion": "Version",
|
|
277
|
+
"settings.stale": "Using last available catalog",
|
|
278
|
+
"settings.failed": "Unavailable",
|
|
279
|
+
"settings.catalogFailure": "Catalog refresh failed. The last available catalog is retained. Check Models.dev Catalog settings.",
|
|
280
|
+
"settings.clientModelsLoading": "Searching recorded client models…",
|
|
281
|
+
"settings.clientModelsError": "Unable to search client models. You can still enter an alias directly.",
|
|
282
|
+
"settings.clientModelsEmpty": "No matching client models. You can enter an alias directly.",
|
|
283
|
+
"settings.previousPage": "Previous page",
|
|
284
|
+
"settings.nextPage": "Next page"
|
|
225
285
|
},
|
|
226
286
|
"zh-CN": {
|
|
227
287
|
"metadata.name": "Token 统计",
|
|
@@ -246,7 +306,7 @@
|
|
|
246
306
|
"ui.loadFailed": "Token 用量加载失败",
|
|
247
307
|
"ui.noData": "所选范围暂无 Token 用量",
|
|
248
308
|
"settings.title": "价格设置",
|
|
249
|
-
"settings.description": "
|
|
309
|
+
"settings.description": "配置用于后续费用估算的模型价格映射。目录下载请在 models.dev 目录设置中管理。",
|
|
250
310
|
"settings.autoRefresh": "自动刷新",
|
|
251
311
|
"settings.interval": "刷新间隔(分钟)",
|
|
252
312
|
"settings.timeout": "下载超时(秒)",
|
|
@@ -274,6 +334,23 @@
|
|
|
274
334
|
"settings.networkError": "网络或数据源错误",
|
|
275
335
|
"settings.invalid_catalogError": "数据源没有有效的模型价格",
|
|
276
336
|
"settings.storageError": "价格缓存保存失败",
|
|
337
|
+
"settings.mappingsTitle": "模型价格映射",
|
|
338
|
+
"settings.mappingsDescription": "将客户端模型别名精确映射到指定提供商的目录价格。区分大小写,映射优先于默认匹配,仅用于后续费用估算,不修改请求、统计模型名或历史金额。最多 100 条。",
|
|
339
|
+
"settings.mappingsNoCatalog": "暂无目录模型,请在 models.dev 目录设置中刷新。",
|
|
340
|
+
"settings.modelsError": "价格模型列表加载失败。",
|
|
341
|
+
"settings.mappingSource": "客户端模型别名",
|
|
342
|
+
"settings.mappingSourcePlaceholder": "例如 deepseek-ai/deepseek-v4-flash",
|
|
343
|
+
"settings.mappingProvider": "价格提供商",
|
|
344
|
+
"settings.mappingTarget": "价格表模型",
|
|
345
|
+
"settings.mappingSearch": "搜索模型 ID 或名称",
|
|
346
|
+
"settings.mappingNoModels": "没有匹配的价格模型",
|
|
347
|
+
"settings.mappingRemove": "移除",
|
|
348
|
+
"settings.mappingAdd": "添加映射",
|
|
349
|
+
"settings.mappingRetry": "重新加载映射",
|
|
350
|
+
"settings.mappingsSave": "保存映射",
|
|
351
|
+
"settings.mappingsSaved": "映射已保存",
|
|
352
|
+
"settings.mappingsInvalid": "每行需填写唯一别名并选择目录中可用的提供商及模型。别名区分大小写,不允许空值或首尾空格。",
|
|
353
|
+
"settings.mappingUnavailable": "目标模型已不在价格目录中,费用将显示未知;请选择其他模型或移除此映射。",
|
|
277
354
|
"widgets.overview.title": "Token 概览",
|
|
278
355
|
"widgets.time.title": "Token 趋势",
|
|
279
356
|
"page.title": "Token 统计",
|
|
@@ -309,7 +386,17 @@
|
|
|
309
386
|
"page.key.all": "全部",
|
|
310
387
|
"page.key.unattributed": "未归属 / 匿名",
|
|
311
388
|
"page.key.missing": "未找到的 Key",
|
|
312
|
-
"page.key.loadFailed": "Key 名称加载失败,仍按所选 ID 筛选。"
|
|
389
|
+
"page.key.loadFailed": "Key 名称加载失败,仍按所选 ID 筛选。",
|
|
390
|
+
"settings.catalogState": "状态",
|
|
391
|
+
"settings.catalogVersion": "版本",
|
|
392
|
+
"settings.stale": "使用上一次有效目录",
|
|
393
|
+
"settings.failed": "不可用",
|
|
394
|
+
"settings.catalogFailure": "目录刷新失败,保留上一次有效目录。请查看 models.dev 目录设置。",
|
|
395
|
+
"settings.clientModelsLoading": "正在搜索已记录的客户端模型…",
|
|
396
|
+
"settings.clientModelsError": "客户端模型搜索失败,仍可直接输入别名。",
|
|
397
|
+
"settings.clientModelsEmpty": "没有匹配的客户端模型,仍可直接输入别名。",
|
|
398
|
+
"settings.previousPage": "上一页",
|
|
399
|
+
"settings.nextPage": "下一页"
|
|
313
400
|
}
|
|
314
401
|
}
|
|
315
402
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { requestPluginControl } from '@bungee/plugin-sdk';
|
|
4
|
+
import { createClientModelSearch, type ClientModelSearchState } from './client-model-search';
|
|
5
|
+
|
|
6
|
+
let { id, value, disabled = false, label, placeholder, loadingLabel, errorLabel, emptyLabel, previousLabel, nextLabel, onchange }: {
|
|
7
|
+
id: string; value: string; disabled?: boolean; label: string; placeholder: string;
|
|
8
|
+
loadingLabel: string; errorLabel: string; emptyLabel: string; previousLabel: string; nextLabel: string;
|
|
9
|
+
onchange: (value: string) => void;
|
|
10
|
+
} = $props();
|
|
11
|
+
const listId = $props.id();
|
|
12
|
+
let open = $state(false);
|
|
13
|
+
let keyword = $state('');
|
|
14
|
+
let restoringFocus = false;
|
|
15
|
+
let highlighted = $state(-1);
|
|
16
|
+
let container = $state<HTMLDivElement>();
|
|
17
|
+
let input = $state<HTMLInputElement>();
|
|
18
|
+
let results = $state<ClientModelSearchState>({ models: [], total: 0, page: 1, pageSize: 50, loading: false, error: false });
|
|
19
|
+
const search = createClientModelSearch(
|
|
20
|
+
(path, signal) => requestPluginControl('token-stats', path, 'GET', undefined, signal),
|
|
21
|
+
state => { results = state; highlighted = -1; },
|
|
22
|
+
);
|
|
23
|
+
const pages = $derived(Math.max(1, Math.ceil(results.total / results.pageSize)));
|
|
24
|
+
function changePage(page: number) {
|
|
25
|
+
// Keep the combobox focus while loading replaces the candidate list.
|
|
26
|
+
restoringFocus = true; input?.focus(); restoringFocus = false;
|
|
27
|
+
search.search(keyword, page, false);
|
|
28
|
+
}
|
|
29
|
+
function select(model: string) { onchange(model); open = false; search.cancel(); restoringFocus = true; input?.focus(); restoringFocus = false; }
|
|
30
|
+
function keydown(event: KeyboardEvent) {
|
|
31
|
+
if (event.key === 'Escape') { event.preventDefault(); open = false; search.cancel(); }
|
|
32
|
+
else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
if (!open) { open = true; keyword = value; search.search(keyword); }
|
|
35
|
+
highlighted = Math.max(-1, Math.min(results.models.length - 1, highlighted + (event.key === 'ArrowDown' ? 1 : -1)));
|
|
36
|
+
if (highlighted >= 0) document.getElementById(`${listId}-${highlighted}`)?.scrollIntoView({ block: 'nearest' });
|
|
37
|
+
} else if (event.key === 'Enter' && open) {
|
|
38
|
+
event.preventDefault(); select(highlighted >= 0 ? results.models[highlighted] : value);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
onMount(() => () => search.destroy());
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<div class="relative min-w-0" bind:this={container} data-testid="client-model-picker"
|
|
45
|
+
onfocusout={(event) => { if (!container?.contains(event.relatedTarget as Node | null)) { open = false; search.cancel(); } }}>
|
|
46
|
+
<input {id} bind:this={input} type="text" {value} {disabled} {placeholder} maxlength={256}
|
|
47
|
+
class="nx-input w-full" data-testid="client-model-input" aria-label={label} autocomplete="off"
|
|
48
|
+
role="combobox" aria-expanded={open} aria-autocomplete="list" aria-controls={open ? listId : undefined}
|
|
49
|
+
aria-activedescendant={open && highlighted >= 0 ? `${listId}-${highlighted}` : undefined}
|
|
50
|
+
onfocus={() => { if (restoringFocus) return; keyword = value; open = true; search.search(keyword); }}
|
|
51
|
+
oninput={(event) => { keyword = event.currentTarget.value; onchange(keyword); open = true; search.search(keyword); }}
|
|
52
|
+
onkeydown={keydown} />
|
|
53
|
+
{#if open && !disabled}
|
|
54
|
+
<div class="absolute z-[200] left-0 right-0 mt-1 border border-carbon-600 bg-carbon-900 shadow-industrial">
|
|
55
|
+
<div id={listId} role="listbox" aria-label={label} class="max-h-52 overflow-y-auto">
|
|
56
|
+
{#each results.models as model, index (model)}
|
|
57
|
+
<button type="button" role="option" id={`${listId}-${index}`} aria-selected={model === value}
|
|
58
|
+
tabindex="-1" class="block w-full break-all px-3 py-2 text-left font-mono text-xs {highlighted === index ? 'bg-carbon-700 text-zinc-100' : 'text-zinc-200'}"
|
|
59
|
+
onpointermove={() => highlighted = index} onmousedown={(event) => event.preventDefault()} onclick={() => select(model)}>{model}</button>
|
|
60
|
+
{/each}
|
|
61
|
+
</div>
|
|
62
|
+
{#if results.loading}<p role="status" class="px-3 py-2 text-xs text-zinc-400">{loadingLabel}</p>
|
|
63
|
+
{:else if results.error}<p role="alert" data-testid="client-model-search-error" class="px-3 py-2 text-xs text-red-400">{errorLabel}</p>
|
|
64
|
+
{:else if !results.models.length}<p role="status" class="px-3 py-2 text-xs text-zinc-400">{emptyLabel}</p>{/if}
|
|
65
|
+
{#if !results.loading && pages > 1}
|
|
66
|
+
<div class="flex items-center justify-between border-t border-carbon-600 p-2">
|
|
67
|
+
<button type="button" class="nx-pager-btn" aria-label={previousLabel} disabled={results.page <= 1}
|
|
68
|
+
onmousedown={(event) => event.preventDefault()} onclick={() => changePage(results.page - 1)}>«</button>
|
|
69
|
+
<span class="text-xs text-zinc-400">{results.page} / {pages}</span>
|
|
70
|
+
<button type="button" class="nx-pager-btn" data-testid="client-model-next-page" aria-label={nextLabel} disabled={results.page >= pages}
|
|
71
|
+
onmousedown={(event) => event.preventDefault()} onclick={() => changePage(results.page + 1)}>»</button>
|
|
72
|
+
</div>
|
|
73
|
+
{/if}
|
|
74
|
+
</div>
|
|
75
|
+
{/if}
|
|
76
|
+
</div>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { requestPluginControl } from '@bungee/plugin-sdk';
|
|
4
|
+
import * as Popover from '$components/ui/popover';
|
|
5
|
+
import * as Command from '$components/ui/command';
|
|
6
|
+
import { Button } from '$components/ui/button';
|
|
7
|
+
import ChevronDown from 'lucide-svelte/icons/chevron-down';
|
|
8
|
+
import { createPricingModelSearch, type PricingModelSearchState } from './pricing-catalog';
|
|
9
|
+
|
|
10
|
+
let { provider, catalogVersion, value, disabled = false, label, placeholder, searchLabel, emptyLabel,
|
|
11
|
+
loadingLabel, errorLabel, retryLabel, previousLabel, nextLabel, onchange }: {
|
|
12
|
+
provider: string;
|
|
13
|
+
catalogVersion: number | null;
|
|
14
|
+
value: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
label: string;
|
|
17
|
+
placeholder: string;
|
|
18
|
+
searchLabel: string;
|
|
19
|
+
emptyLabel: string;
|
|
20
|
+
loadingLabel: string;
|
|
21
|
+
errorLabel: string;
|
|
22
|
+
retryLabel: string;
|
|
23
|
+
previousLabel: string;
|
|
24
|
+
nextLabel: string;
|
|
25
|
+
onchange: (model: string) => void;
|
|
26
|
+
} = $props();
|
|
27
|
+
let open = $state(false);
|
|
28
|
+
let width = $state(0);
|
|
29
|
+
let search = $state('');
|
|
30
|
+
let results = $state<PricingModelSearchState>({ models: [], total: 0, page: 1, pageSize: 50, loading: false, error: false });
|
|
31
|
+
const modelSearch = createPricingModelSearch(
|
|
32
|
+
(path, signal) => requestPluginControl('token-stats', path, 'GET', undefined, signal),
|
|
33
|
+
state => { results = state; },
|
|
34
|
+
);
|
|
35
|
+
const pages = $derived(Math.max(1, Math.ceil(results.total / results.pageSize)));
|
|
36
|
+
$effect(() => {
|
|
37
|
+
// A new catalog version invalidates an open picker's current results too.
|
|
38
|
+
void catalogVersion;
|
|
39
|
+
if (!open || disabled || !provider) { modelSearch.cancel(); return; }
|
|
40
|
+
modelSearch.search({ provider, search }, 1, search.length > 0);
|
|
41
|
+
});
|
|
42
|
+
function changePage(page: number) { modelSearch.search({ provider, search }, page, false); }
|
|
43
|
+
onMount(() => () => modelSearch.destroy());
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<div class="min-w-0 w-full" bind:clientWidth={width}>
|
|
47
|
+
<Popover.Root bind:open>
|
|
48
|
+
<Popover.Trigger asChild let:builder>
|
|
49
|
+
<Button builders={[builder]} variant="outline" {disabled} aria-label={label} aria-haspopup="listbox"
|
|
50
|
+
onclick={() => { search = ''; }}
|
|
51
|
+
class="h-[34px] w-full min-w-0 justify-between border-carbon-500 bg-carbon-900 px-2 font-mono text-[11px] font-normal normal-case tracking-normal focus-visible:border-nexus-500">
|
|
52
|
+
<span class="truncate text-zinc-200" title={value}>{value || placeholder}</span>
|
|
53
|
+
<ChevronDown class="h-4 w-4 shrink-0 opacity-50" />
|
|
54
|
+
</Button>
|
|
55
|
+
</Popover.Trigger>
|
|
56
|
+
<Popover.Content style={`width: ${width}px`} class="max-w-[calc(100vw-2rem)] border-carbon-600 p-0" align="start">
|
|
57
|
+
<!-- cmdk-sv's imperative sorting moves Svelte-owned nodes; keep filtering declarative. -->
|
|
58
|
+
<Command.Root shouldFilter={false}>
|
|
59
|
+
<Command.Input bind:value={search} placeholder={searchLabel} aria-label={searchLabel} />
|
|
60
|
+
<Command.List>
|
|
61
|
+
{#if results.loading}<p role="status" class="px-3 py-2 text-xs text-zinc-400">{loadingLabel}</p>
|
|
62
|
+
{:else if results.error}
|
|
63
|
+
<p role="alert" class="px-3 py-2 text-xs text-red-400">{errorLabel}</p>
|
|
64
|
+
<Button variant="ghost" onclick={() => changePage(results.page)}>{retryLabel}</Button>
|
|
65
|
+
{:else}
|
|
66
|
+
<Command.Empty>{emptyLabel}</Command.Empty>
|
|
67
|
+
{#each results.models as option (option.model)}
|
|
68
|
+
<Command.Item value={option.model} onSelect={() => { onchange(option.model); open = false; }}
|
|
69
|
+
class="min-w-0 break-all text-zinc-200">
|
|
70
|
+
{option.model}
|
|
71
|
+
</Command.Item>
|
|
72
|
+
{/each}
|
|
73
|
+
{/if}
|
|
74
|
+
</Command.List>
|
|
75
|
+
</Command.Root>
|
|
76
|
+
{#if !results.loading && !results.error && pages > 1}
|
|
77
|
+
<div class="flex items-center justify-between border-t border-carbon-600 p-2">
|
|
78
|
+
<button type="button" class="nx-pager-btn" aria-label={previousLabel} disabled={results.page <= 1}
|
|
79
|
+
onmousedown={(event) => event.preventDefault()} onclick={() => changePage(results.page - 1)}>«</button>
|
|
80
|
+
<span class="text-xs text-zinc-400">{results.page} / {pages}</span>
|
|
81
|
+
<button type="button" class="nx-pager-btn" data-testid="price-model-next-page" aria-label={nextLabel} disabled={results.page >= pages}
|
|
82
|
+
onmousedown={(event) => event.preventDefault()} onclick={() => changePage(results.page + 1)}>»</button>
|
|
83
|
+
</div>
|
|
84
|
+
{/if}
|
|
85
|
+
</Popover.Content>
|
|
86
|
+
</Popover.Root>
|
|
87
|
+
</div>
|
|
@@ -2,39 +2,111 @@
|
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
3
|
import { isLoading } from 'svelte-i18n';
|
|
4
4
|
import { getPluginText } from '$utils/plugin-i18n';
|
|
5
|
-
import { PanelCard,
|
|
5
|
+
import { PanelCard, LoadingIndicator, BSelect } from '$components/industrial';
|
|
6
6
|
import { Button } from '$components/ui/button';
|
|
7
|
-
import { Input } from '$components/ui/input';
|
|
8
7
|
import { Label } from '$components/ui/label';
|
|
9
8
|
import { requestPluginControl, _ } from '@bungee/plugin-sdk';
|
|
10
|
-
import type {
|
|
9
|
+
import type { ModelsDevCatalogStatus, ModelsDevProviderSummary } from '../../models-dev/contract';
|
|
10
|
+
import ClientModelPicker from './ClientModelPicker.svelte';
|
|
11
|
+
import { MAX_PRICE_MODEL_MAPPINGS, parsePriceModelMappings, type PriceModelMapping } from '../server/model-mappings';
|
|
12
|
+
import PriceModelPicker from './PriceModelPicker.svelte';
|
|
11
13
|
|
|
12
|
-
let status:
|
|
13
|
-
let autoRefresh = $state(true);
|
|
14
|
-
let intervalMinutes = $state(60);
|
|
15
|
-
let timeoutSeconds = $state(15);
|
|
14
|
+
let status: ModelsDevCatalogStatus | null = $state(null);
|
|
16
15
|
let loading = $state(true);
|
|
17
|
-
let busy = $state(false);
|
|
18
16
|
let error = $state('');
|
|
19
|
-
let
|
|
17
|
+
let mappings = $state<PriceModelMapping[]>([]);
|
|
18
|
+
let mappingsBusy = $state(false);
|
|
19
|
+
let mappingsLoaded = $state(false);
|
|
20
|
+
let mappingsSaved = $state(false);
|
|
21
|
+
let mappingsError = $state('');
|
|
22
|
+
let providersError = $state(false);
|
|
23
|
+
let catalogVersion: number | null = $state(null);
|
|
24
|
+
let providers = $state<ModelsDevProviderSummary[]>([]);
|
|
25
|
+
let providersVersion: number | null | undefined;
|
|
26
|
+
let providersPendingVersion: number | null | undefined;
|
|
27
|
+
let providersGeneration = 0;
|
|
28
|
+
let mappingIds = $state<number[]>([]);
|
|
29
|
+
let nextMappingId = 0;
|
|
30
|
+
const providerOptions = $derived(providers.map(provider => ({
|
|
31
|
+
value: provider.provider, label: `${provider.name} · ${provider.provider}`,
|
|
32
|
+
})));
|
|
20
33
|
let alive = false;
|
|
21
34
|
let polling = false;
|
|
22
35
|
const controller = new AbortController();
|
|
23
36
|
const t = (key: string) => $isLoading ? '' : getPluginText(`settings.${key}`, 'token-stats', (id, options) => $_(id, options));
|
|
24
37
|
const date = (value: number | null) => value === null ? t('never') : new Date(value).toLocaleString();
|
|
25
38
|
|
|
26
|
-
async function
|
|
39
|
+
async function loadProviders(version: number | null) {
|
|
40
|
+
if (providersPendingVersion === version) return;
|
|
41
|
+
providersPendingVersion = version;
|
|
42
|
+
const generation = ++providersGeneration;
|
|
43
|
+
try {
|
|
44
|
+
const providerResult = await requestPluginControl<{ providers: ModelsDevProviderSummary[] }>('models-dev', '/catalog/providers', 'GET', undefined, controller.signal);
|
|
45
|
+
if (alive && generation === providersGeneration && catalogVersion === version) {
|
|
46
|
+
providers = providerResult.providers;
|
|
47
|
+
providersVersion = version;
|
|
48
|
+
providersError = false;
|
|
49
|
+
}
|
|
50
|
+
} catch { if (alive && generation === providersGeneration) providersError = true; }
|
|
51
|
+
finally { if (generation === providersGeneration) providersPendingVersion = undefined; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function loadMappings() {
|
|
55
|
+
try {
|
|
56
|
+
const result = await requestPluginControl<{ mappings: PriceModelMapping[] }>('token-stats', '/pricing/mappings', 'GET', undefined, controller.signal);
|
|
57
|
+
if (alive) {
|
|
58
|
+
mappings = result.mappings;
|
|
59
|
+
mappingIds = mappings.map(() => nextMappingId++);
|
|
60
|
+
mappingsLoaded = true;
|
|
61
|
+
mappingsError = '';
|
|
62
|
+
}
|
|
63
|
+
} catch { if (alive) mappingsError = 'error'; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function changeMapping(index: number, key: keyof PriceModelMapping, value: string) {
|
|
67
|
+
mappings = mappings.map((mapping, i) => i === index ? { ...mapping, [key]: value, ...(key === 'provider' ? { model: '' } : {}) } : mapping);
|
|
68
|
+
mappingsSaved = false;
|
|
69
|
+
mappingsError = '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function addMapping() {
|
|
73
|
+
mappings = [...mappings, { source: '', provider: '', model: '' }];
|
|
74
|
+
mappingIds = [...mappingIds, nextMappingId++];
|
|
75
|
+
mappingsSaved = false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function removeMapping(index: number) {
|
|
79
|
+
mappings = mappings.filter((_, i) => i !== index);
|
|
80
|
+
mappingIds = mappingIds.filter((_, i) => i !== index);
|
|
81
|
+
mappingsSaved = false;
|
|
82
|
+
mappingsError = '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function saveMappings() {
|
|
86
|
+
if (!mappingsLoaded || mappingsBusy) return;
|
|
87
|
+
mappingsSaved = false;
|
|
88
|
+
let parsed: PriceModelMapping[];
|
|
89
|
+
try {
|
|
90
|
+
parsed = parsePriceModelMappings(mappings);
|
|
91
|
+
} catch { mappingsError = 'mappingsInvalid'; return; }
|
|
92
|
+
mappingsBusy = true;
|
|
93
|
+
mappingsError = '';
|
|
94
|
+
try {
|
|
95
|
+
const result = await requestPluginControl<{ mappings: PriceModelMapping[] }>('token-stats', '/pricing/mappings', 'PUT', parsed, controller.signal);
|
|
96
|
+
if (alive) { mappings = result.mappings; mappingsSaved = true; }
|
|
97
|
+
} catch (error) { if (alive) mappingsError = error instanceof Error && error.message === 'invalid_input' ? 'mappingsInvalid' : 'error'; }
|
|
98
|
+
finally { if (alive) mappingsBusy = false; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function load() {
|
|
27
102
|
if (polling) return;
|
|
28
103
|
polling = true;
|
|
29
104
|
try {
|
|
30
|
-
const result = await requestPluginControl<
|
|
105
|
+
const result = await requestPluginControl<ModelsDevCatalogStatus>('token-stats', '/pricing', 'GET', undefined, controller.signal);
|
|
31
106
|
if (!alive) return;
|
|
32
107
|
status = result;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
intervalMinutes = result.settings.intervalMinutes;
|
|
36
|
-
timeoutSeconds = result.settings.timeoutSeconds;
|
|
37
|
-
}
|
|
108
|
+
catalogVersion = result.version;
|
|
109
|
+
if (providersVersion !== result.version || providersError) void loadProviders(result.version);
|
|
38
110
|
error = '';
|
|
39
111
|
} catch {
|
|
40
112
|
if (alive) error = 'error';
|
|
@@ -44,38 +116,11 @@
|
|
|
44
116
|
}
|
|
45
117
|
}
|
|
46
118
|
|
|
47
|
-
async function save() {
|
|
48
|
-
saved = false;
|
|
49
|
-
const interval = Number(intervalMinutes);
|
|
50
|
-
const timeout = Number(timeoutSeconds);
|
|
51
|
-
if (!Number.isInteger(interval) || interval < 1 || interval > 1440
|
|
52
|
-
|| !Number.isInteger(timeout) || timeout < 5 || timeout > 60) {
|
|
53
|
-
error = 'invalid'; return;
|
|
54
|
-
}
|
|
55
|
-
busy = true;
|
|
56
|
-
error = '';
|
|
57
|
-
try {
|
|
58
|
-
const result = await requestPluginControl<PriceStatus>('token-stats', '/pricing/settings', 'PUT',
|
|
59
|
-
{ autoRefresh, intervalMinutes: interval, timeoutSeconds: timeout }, controller.signal);
|
|
60
|
-
if (alive) { status = result; saved = true; }
|
|
61
|
-
} catch { if (alive) error = 'error'; }
|
|
62
|
-
finally { if (alive) busy = false; }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function refresh() {
|
|
66
|
-
busy = true;
|
|
67
|
-
error = '';
|
|
68
|
-
try {
|
|
69
|
-
const result = await requestPluginControl<PriceStatus>('token-stats', '/pricing/refresh', 'POST', undefined, controller.signal);
|
|
70
|
-
if (alive) status = result;
|
|
71
|
-
} catch { if (alive) error = 'error'; }
|
|
72
|
-
finally { if (alive) busy = false; }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
119
|
onMount(() => {
|
|
76
120
|
alive = true;
|
|
77
|
-
void load(
|
|
78
|
-
|
|
121
|
+
void load();
|
|
122
|
+
void loadMappings();
|
|
123
|
+
const timer = setInterval(() => { void load(); }, 3000);
|
|
79
124
|
return () => { alive = false; controller.abort(); clearInterval(timer); };
|
|
80
125
|
});
|
|
81
126
|
</script>
|
|
@@ -86,45 +131,56 @@
|
|
|
86
131
|
{#if loading}
|
|
87
132
|
<LoadingIndicator />
|
|
88
133
|
{:else if status}
|
|
89
|
-
<PanelCard title={t('
|
|
90
|
-
<
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
134
|
+
<PanelCard title={t('mappingsTitle')}>
|
|
135
|
+
<div class="space-y-5">
|
|
136
|
+
<p class="text-sm text-zinc-400">{t('mappingsDescription')}</p>
|
|
137
|
+
{#if providersError}<p role="alert" class="text-sm text-red-400">{t('modelsError')}</p>{/if}
|
|
138
|
+
{#if mappingsError}<p role="alert" class="text-sm text-red-400">{t(mappingsError)}</p>{/if}
|
|
139
|
+
{#if !mappingsLoaded}
|
|
140
|
+
<Button variant="outline" onclick={() => void loadMappings()}>{t('mappingRetry')}</Button>
|
|
141
|
+
{/if}
|
|
142
|
+
{#if !status.modelCount}<p class="text-sm text-amber-400">{t('mappingsNoCatalog')}</p>{/if}
|
|
143
|
+
{#each mappings as mapping, index (mappingIds[index])}
|
|
144
|
+
<div class="grid min-w-0 gap-3 border-b border-carbon-600 pb-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,1.2fr)_auto] sm:items-end" data-testid="price-model-mapping">
|
|
145
|
+
<div class="min-w-0 space-y-2">
|
|
146
|
+
<Label for={`price-alias-${mappingIds[index]}`}>{t('mappingSource')}</Label>
|
|
147
|
+
<ClientModelPicker id={`price-alias-${mappingIds[index]}`} value={mapping.source} label={t('mappingSource')}
|
|
148
|
+
placeholder={t('mappingSourcePlaceholder')} loadingLabel={t('clientModelsLoading')} errorLabel={t('clientModelsError')}
|
|
149
|
+
emptyLabel={t('clientModelsEmpty')} previousLabel={t('previousPage')} nextLabel={t('nextPage')}
|
|
150
|
+
disabled={mappingsBusy} onchange={(value) => changeMapping(index, 'source', value)} />
|
|
151
|
+
</div>
|
|
152
|
+
<div class="min-w-0 space-y-2">
|
|
153
|
+
<span class="nx-field-label block">{t('mappingProvider')}</span>
|
|
154
|
+
<BSelect value={mapping.provider} options={providerOptions} ariaLabel={t('mappingProvider')} placeholder={t('mappingProvider')}
|
|
155
|
+
disabled={mappingsBusy || !providers.length} onchange={(value) => changeMapping(index, 'provider', String(value))} />
|
|
156
|
+
</div>
|
|
157
|
+
<div class="min-w-0 space-y-2">
|
|
158
|
+
<span class="nx-field-label block">{t('mappingTarget')}</span>
|
|
159
|
+
<PriceModelPicker value={mapping.model} provider={mapping.provider} {catalogVersion}
|
|
160
|
+
disabled={mappingsBusy || !mapping.provider || !status.modelCount} label={t('mappingTarget')} placeholder={t('mappingTarget')}
|
|
161
|
+
searchLabel={t('mappingSearch')} emptyLabel={t('mappingNoModels')} loadingLabel={t('clientModelsLoading')}
|
|
162
|
+
errorLabel={t('modelsError')} retryLabel={t('mappingRetry')} previousLabel={t('previousPage')} nextLabel={t('nextPage')}
|
|
163
|
+
onchange={(value) => changeMapping(index, 'model', value)} />
|
|
164
|
+
</div>
|
|
165
|
+
<Button variant="ghost" disabled={mappingsBusy} onclick={() => removeMapping(index)}>{t('mappingRemove')}</Button>
|
|
103
166
|
</div>
|
|
167
|
+
{/each}
|
|
168
|
+
<div class="flex flex-wrap items-center gap-4">
|
|
169
|
+
<Button variant="outline" disabled={!mappingsLoaded || mappingsBusy || mappings.length >= MAX_PRICE_MODEL_MAPPINGS} onclick={addMapping}>{t('mappingAdd')}</Button>
|
|
170
|
+
<Button data-testid="price-mappings-save" disabled={!mappingsLoaded || mappingsBusy} aria-busy={mappingsBusy} onclick={() => void saveMappings()}>{t(mappingsBusy ? 'saving' : 'mappingsSave')}</Button>
|
|
171
|
+
{#if mappingsSaved}<span role="status" class="text-sm text-emerald-400">{t('mappingsSaved')}</span>{/if}
|
|
104
172
|
</div>
|
|
105
|
-
|
|
106
|
-
<Button type="submit" disabled={busy} aria-busy={busy}>{t(busy ? 'saving' : 'save')}</Button>
|
|
107
|
-
{#if saved}<span role="status" class="text-sm text-emerald-400">{t('saved')}</span>{/if}
|
|
108
|
-
</div>
|
|
109
|
-
</form>
|
|
173
|
+
</div>
|
|
110
174
|
</PanelCard>
|
|
111
175
|
<PanelCard title={t('catalog')}>
|
|
112
|
-
<
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
</
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
<div class="space-y-2"><dt class="text-zinc-400">{t('source')}</dt><dd class="break-all text-zinc-200"><a href={status.source} target="_blank" rel="noopener noreferrer" class="hover:text-nexus-400 underline underline-offset-4">models.dev</a></dd></div>
|
|
119
|
-
<div class="space-y-2"><dt class="text-zinc-400">{t('modelCount')}</dt><dd class="font-display text-xl text-nexus-500">{status.modelCount}</dd></div>
|
|
120
|
-
<div class="space-y-2"><dt class="text-zinc-400">{t('providerCount')}</dt><dd class="font-display text-xl text-zinc-100">{status.providerCount}</dd></div>
|
|
121
|
-
<div class="space-y-2"><dt class="text-zinc-400">{t('lastAttempt')}</dt><dd class="font-mono text-zinc-200">{date(status.lastAttemptAt)}</dd></div>
|
|
122
|
-
<div class="space-y-2"><dt class="text-zinc-400">{t('lastSuccess')}</dt><dd data-testid="price-last-success" class="font-mono text-zinc-200">{date(status.lastSuccessAt)}</dd></div>
|
|
123
|
-
<div class="space-y-2"><dt class="text-zinc-400">{t('nextRefresh')}</dt><dd class="font-mono text-zinc-200">{status.nextRefreshAt === null ? t(status.settings.autoRefresh ? 'refreshing' : 'disabled') : date(status.nextRefreshAt)}</dd></div>
|
|
176
|
+
<dl class="grid gap-5 text-sm sm:grid-cols-2 lg:grid-cols-3" data-testid="pricing-catalog-status">
|
|
177
|
+
<div class="space-y-2"><dt class="text-zinc-400">{t('catalogState')}</dt><dd data-testid="pricing-catalog-state">{t(status.state)}</dd></div>
|
|
178
|
+
<div class="space-y-2"><dt class="text-zinc-400">{t('catalogVersion')}</dt><dd data-testid="pricing-catalog-version">{status.version ?? t('never')}</dd></div>
|
|
179
|
+
<div class="space-y-2"><dt class="text-zinc-400">{t('lastSuccess')}</dt><dd data-testid="price-last-success" class="font-mono text-zinc-200">{date(status.fetchedAt)}</dd></div>
|
|
180
|
+
<div class="space-y-2"><dt class="text-zinc-400">{t('modelCount')}</dt><dd>{status.modelCount}</dd></div>
|
|
181
|
+
<div class="space-y-2"><dt class="text-zinc-400">{t('providerCount')}</dt><dd>{status.providerCount}</dd></div>
|
|
124
182
|
</dl>
|
|
125
|
-
{#if status.
|
|
126
|
-
<p role="status" class="mt-5 border-l-2 border-amber-400 pl-3 text-sm text-amber-400">{t(`${status.lastError}Error`)} · {t('failure')}</p>
|
|
127
|
-
{/if}
|
|
183
|
+
{#if status.error}<p role="status" data-testid="pricing-catalog-error" class="mt-5 border-l-2 border-amber-400 pl-3 text-sm text-amber-400">{t('catalogFailure')}</p>{/if}
|
|
128
184
|
<p class="mt-5 border-t border-carbon-600 pt-4 text-xs leading-relaxed text-zinc-400">{t('history')}</p>
|
|
129
185
|
</PanelCard>
|
|
130
186
|
{/if}
|