@jeffusion/bungee-core 5.20.0 → 5.21.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/config-worker/private-transport.d.ts +2 -0
- package/dist/data-admission/errors.d.ts +6 -1
- package/dist/gateway/contracts.d.ts +15 -4
- package/dist/gateway/forward-plugin.d.ts +2 -0
- package/dist/gateway/plugin.js +12 -2
- package/dist/gateway/request-plugin.d.ts +6 -0
- package/dist/gateway/responses-websocket-session.d.ts +18 -0
- package/dist/gateway/runtime.d.ts +1 -0
- package/dist/gateway/websocket-contracts.d.ts +1 -0
- package/dist/hooks/plugin-hooks.d.ts +2 -3
- package/dist/logger/access-log-writer.d.ts +2 -2
- package/dist/main.js +1199 -176
- package/dist/plugins/ai-transformer/index.js +1 -1
- package/dist/plugins/ai-transformer/index.js.map +4 -4
- package/dist/plugins/codex-router/control.js +6 -2
- package/dist/plugins/codex-router/control.js.map +4 -4
- package/dist/plugins/codex-router/index.js +498 -165
- package/dist/plugins/codex-router/index.js.map +13 -11
- package/dist/plugins/codex-router/manifest.json +124 -8
- package/dist/plugins/key-access/index.js +12 -2
- package/dist/plugins/key-access/index.js.map +3 -3
- package/dist/plugins/key-access/ingress.js +12 -2
- package/dist/plugins/key-access/ingress.js.map +3 -3
- package/dist/plugins/openai-messages-to-chat/index.js +6 -4
- package/dist/plugins/openai-messages-to-chat/index.js.map +4 -4
- package/dist/plugins/token-metering/index.js +1 -1
- package/dist/plugins/token-metering/index.js.map +4 -4
- package/dist/plugins/token-stats/manifest.json +16 -6
- package/dist/plugins/token-stats/ui/ClientModelPicker.svelte +13 -61
- package/dist/plugins/token-stats/ui/PriceModelPicker.svelte +18 -74
- package/dist/plugins/token-stats/ui/TokenStatsSettings.svelte +3 -2
- package/dist/plugins/token-stats/ui/model-search.ts +2 -45
- package/dist/plugins/token-stats/ui/pricing-catalog.ts +2 -1
- package/dist/scoped-plugin-registry.d.ts +7 -0
- package/dist/websocket/index.d.ts +1 -1
- package/dist/websocket/transport.d.ts +40 -18
- package/package.json +2 -2
- package/dist/plugins/chatgpt-oauth/ui/account-model.test.js +0 -69
- package/dist/plugins/local-accounts/ui/login-errors.test.ts +0 -22
- package/dist/plugins/token-stats/ui/client-model-search.test.ts +0 -58
- package/dist/plugins/token-stats/ui/pricing-catalog.test.ts +0 -114
|
@@ -169,6 +169,9 @@
|
|
|
169
169
|
"configSchema": [],
|
|
170
170
|
"translations": {
|
|
171
171
|
"en": {
|
|
172
|
+
"settings.modelsLoaded": "Loaded",
|
|
173
|
+
"settings.modelsComplete": "All loaded",
|
|
174
|
+
"settings.modelsLoadMore": "Load more",
|
|
172
175
|
"metadata.name": "Token Statistics",
|
|
173
176
|
"plugin.description": "View AI token usage by model and over time",
|
|
174
177
|
"widgets.chart.title": "Token Usage",
|
|
@@ -226,6 +229,8 @@
|
|
|
226
229
|
"settings.mappingProvider": "Pricing provider",
|
|
227
230
|
"settings.mappingTarget": "Catalog model",
|
|
228
231
|
"settings.mappingSearch": "Search model ID or name",
|
|
232
|
+
"settings.modelSearchRetry": "Retry search",
|
|
233
|
+
"settings.mappingUseCustom": "Use custom ID:",
|
|
229
234
|
"settings.mappingNoModels": "No matching pricing models",
|
|
230
235
|
"settings.mappingRemove": "Remove",
|
|
231
236
|
"settings.mappingAdd": "Add alias",
|
|
@@ -277,13 +282,16 @@
|
|
|
277
282
|
"settings.stale": "Using last available catalog",
|
|
278
283
|
"settings.failed": "Unavailable",
|
|
279
284
|
"settings.catalogFailure": "Catalog refresh failed. The last available catalog is retained. Check Models.dev Catalog settings.",
|
|
280
|
-
"settings.clientModelsLoading": "
|
|
281
|
-
"settings.clientModelsError": "Unable to search client models. You can still
|
|
282
|
-
"settings.clientModelsEmpty": "No matching client models. You can
|
|
285
|
+
"settings.clientModelsLoading": "Loading…",
|
|
286
|
+
"settings.clientModelsError": "Unable to search client models. You can still confirm a custom ID.",
|
|
287
|
+
"settings.clientModelsEmpty": "No matching client models. You can confirm a custom ID.",
|
|
283
288
|
"settings.previousPage": "Previous page",
|
|
284
289
|
"settings.nextPage": "Next page"
|
|
285
290
|
},
|
|
286
291
|
"zh-CN": {
|
|
292
|
+
"settings.modelsLoaded": "已加载",
|
|
293
|
+
"settings.modelsComplete": "已全部加载",
|
|
294
|
+
"settings.modelsLoadMore": "加载更多",
|
|
287
295
|
"metadata.name": "Token 统计",
|
|
288
296
|
"plugin.description": "按模型与时间查看 AI Token 用量",
|
|
289
297
|
"widgets.chart.title": "Token 使用量",
|
|
@@ -341,6 +349,8 @@
|
|
|
341
349
|
"settings.mappingProvider": "价格提供商",
|
|
342
350
|
"settings.mappingTarget": "价格表模型",
|
|
343
351
|
"settings.mappingSearch": "搜索模型 ID 或名称",
|
|
352
|
+
"settings.modelSearchRetry": "重试搜索",
|
|
353
|
+
"settings.mappingUseCustom": "使用自定义标识:",
|
|
344
354
|
"settings.mappingNoModels": "没有匹配的价格模型",
|
|
345
355
|
"settings.mappingRemove": "移除",
|
|
346
356
|
"settings.mappingAdd": "添加映射",
|
|
@@ -392,9 +402,9 @@
|
|
|
392
402
|
"settings.stale": "使用上一次有效目录",
|
|
393
403
|
"settings.failed": "不可用",
|
|
394
404
|
"settings.catalogFailure": "目录刷新失败,保留上一次有效目录。请查看 models.dev 目录设置。",
|
|
395
|
-
"settings.clientModelsLoading": "
|
|
396
|
-
"settings.clientModelsError": "
|
|
397
|
-
"settings.clientModelsEmpty": "
|
|
405
|
+
"settings.clientModelsLoading": "加载中…",
|
|
406
|
+
"settings.clientModelsError": "客户端模型搜索失败,仍可确认自定义标识。",
|
|
407
|
+
"settings.clientModelsEmpty": "没有匹配的客户端模型,仍可确认自定义标识。",
|
|
398
408
|
"settings.previousPage": "上一页",
|
|
399
409
|
"settings.nextPage": "下一页"
|
|
400
410
|
}
|
|
@@ -1,76 +1,28 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
3
|
import { requestPluginControl } from '@bungee/plugin-sdk';
|
|
4
|
+
import { BSelect } from '$components/industrial';
|
|
4
5
|
import { createClientModelSearch, type ClientModelSearchState } from './client-model-search';
|
|
5
6
|
|
|
6
|
-
let { id, value, disabled = false, label, placeholder, loadingLabel, errorLabel,
|
|
7
|
+
let { id, value, disabled = false, label, placeholder, searchLabel, loadingLabel, errorLabel,
|
|
8
|
+
emptyLabel, retryLabel, customLabel, loadedLabel, completeLabel, loadMoreLabel, onchange }: {
|
|
7
9
|
id: string; value: string; disabled?: boolean; label: string; placeholder: string;
|
|
8
|
-
|
|
10
|
+
searchLabel: string; loadingLabel: string; errorLabel: string; emptyLabel: string;
|
|
11
|
+
retryLabel: string; customLabel: string; loadedLabel: string; completeLabel: string; loadMoreLabel: string;
|
|
9
12
|
onchange: (value: string) => void;
|
|
10
13
|
} = $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
14
|
let results = $state<ClientModelSearchState>({ models: [], total: 0, page: 1, pageSize: 50, loading: false, error: false });
|
|
19
15
|
const search = createClientModelSearch(
|
|
20
16
|
(path, signal) => requestPluginControl('token-stats', path, 'GET', undefined, signal),
|
|
21
|
-
state => { results = state;
|
|
17
|
+
state => { results = state; },
|
|
22
18
|
);
|
|
23
|
-
const
|
|
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
|
-
}
|
|
19
|
+
const options = $derived(results.models.map(model => ({ value: model, label: model })));
|
|
41
20
|
onMount(() => () => search.destroy());
|
|
42
21
|
</script>
|
|
43
22
|
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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>
|
|
23
|
+
<BSelect {id} {value} {options} {disabled} ariaLabel={label} {placeholder} creatable maxLength={256}
|
|
24
|
+
rootTestId="client-model-picker" searchTestId="client-model-input"
|
|
25
|
+
searchLabels={{ search: searchLabel, loading: loadingLabel, error: errorLabel, empty: emptyLabel,
|
|
26
|
+
retry: retryLabel, custom: customLabel, loaded: loadedLabel, complete: completeLabel, loadMore: loadMoreLabel }}
|
|
27
|
+
remoteSearch={{ result: results, search: (keyword, page, debounce) => search.search(keyword, page, debounce), cancel: () => search.cancel() }}
|
|
28
|
+
onchange={next => onchange(String(next))} />
|
|
@@ -1,87 +1,31 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
3
|
import { requestPluginControl } from '@bungee/plugin-sdk';
|
|
4
|
-
import
|
|
5
|
-
import * as Command from '$components/ui/command';
|
|
6
|
-
import { Button } from '$components/ui/button';
|
|
7
|
-
import ChevronDown from 'lucide-svelte/icons/chevron-down';
|
|
4
|
+
import { BSelect } from '$components/industrial';
|
|
8
5
|
import { createPricingModelSearch, type PricingModelSearchState } from './pricing-catalog';
|
|
9
6
|
|
|
10
7
|
let { provider, catalogVersion, value, disabled = false, label, placeholder, searchLabel, emptyLabel,
|
|
11
|
-
loadingLabel, errorLabel, retryLabel,
|
|
12
|
-
|
|
13
|
-
catalogVersion: number | null;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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;
|
|
8
|
+
loadingLabel, errorLabel, retryLabel, loadedLabel, completeLabel, loadMoreLabel, onchange,
|
|
9
|
+
catalogPlugin = 'token-stats', catalogPath = '/pricing/models' }: {
|
|
10
|
+
provider: string; catalogVersion: number | null; value: string; disabled?: boolean;
|
|
11
|
+
label: string; placeholder: string; searchLabel: string; emptyLabel: string;
|
|
12
|
+
loadingLabel: string; errorLabel: string; retryLabel: string;
|
|
13
|
+
loadedLabel: string; completeLabel: string; loadMoreLabel: string; onchange: (model: string) => void;
|
|
14
|
+
catalogPlugin?: string; catalogPath?: string;
|
|
26
15
|
} = $props();
|
|
27
|
-
let open = $state(false);
|
|
28
|
-
let width = $state(0);
|
|
29
|
-
let search = $state('');
|
|
30
16
|
let results = $state<PricingModelSearchState>({ models: [], total: 0, page: 1, pageSize: 50, loading: false, error: false });
|
|
31
17
|
const modelSearch = createPricingModelSearch(
|
|
32
|
-
(path, signal) => requestPluginControl(
|
|
33
|
-
state => { results = state; },
|
|
18
|
+
(path, signal) => requestPluginControl(catalogPlugin, path, 'GET', undefined, signal),
|
|
19
|
+
state => { results = state; }, 250, catalogPath,
|
|
34
20
|
);
|
|
35
|
-
const
|
|
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); }
|
|
21
|
+
const options = $derived(results.models.map(model => ({ value: model.model, label: model.model })));
|
|
43
22
|
onMount(() => () => modelSearch.destroy());
|
|
44
23
|
</script>
|
|
45
24
|
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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>
|
|
25
|
+
<BSelect {value} {options} disabled={disabled || !provider} ariaLabel={label} {placeholder}
|
|
26
|
+
rootTestId="price-model-picker"
|
|
27
|
+
searchLabels={{ search: searchLabel, loading: loadingLabel, error: errorLabel, empty: emptyLabel,
|
|
28
|
+
retry: retryLabel, loaded: loadedLabel, complete: completeLabel, loadMore: loadMoreLabel }}
|
|
29
|
+
remoteSearch={{ result: results, resetKey: JSON.stringify([provider, catalogVersion]),
|
|
30
|
+
search: (search, page, debounce) => modelSearch.search({ provider, search }, page, debounce), cancel: () => modelSearch.cancel() }}
|
|
31
|
+
onchange={next => onchange(String(next))} />
|
|
@@ -146,7 +146,8 @@
|
|
|
146
146
|
<Label for={`price-alias-${mappingIds[index]}`}>{t('mappingSource')}</Label>
|
|
147
147
|
<ClientModelPicker id={`price-alias-${mappingIds[index]}`} value={mapping.source} label={t('mappingSource')}
|
|
148
148
|
placeholder={t('mappingSourcePlaceholder')} loadingLabel={t('clientModelsLoading')} errorLabel={t('clientModelsError')}
|
|
149
|
-
emptyLabel={t('clientModelsEmpty')}
|
|
149
|
+
emptyLabel={t('clientModelsEmpty')} loadedLabel={t('modelsLoaded')} completeLabel={t('modelsComplete')} loadMoreLabel={t('modelsLoadMore')}
|
|
150
|
+
searchLabel={t('mappingSearch')} retryLabel={t('modelSearchRetry')} customLabel={t('mappingUseCustom')}
|
|
150
151
|
disabled={mappingsBusy} onchange={(value) => changeMapping(index, 'source', value)} />
|
|
151
152
|
</div>
|
|
152
153
|
<div class="min-w-0 space-y-2">
|
|
@@ -159,7 +160,7 @@
|
|
|
159
160
|
<PriceModelPicker value={mapping.model} provider={mapping.provider} {catalogVersion}
|
|
160
161
|
disabled={mappingsBusy || !mapping.provider || !status.modelCount} label={t('mappingTarget')} placeholder={t('mappingTarget')}
|
|
161
162
|
searchLabel={t('mappingSearch')} emptyLabel={t('mappingNoModels')} loadingLabel={t('clientModelsLoading')}
|
|
162
|
-
errorLabel={t('modelsError')} retryLabel={t('
|
|
163
|
+
errorLabel={t('modelsError')} retryLabel={t('modelSearchRetry')} loadedLabel={t('modelsLoaded')} completeLabel={t('modelsComplete')} loadMoreLabel={t('modelsLoadMore')}
|
|
163
164
|
onchange={(value) => changeMapping(index, 'model', value)} />
|
|
164
165
|
</div>
|
|
165
166
|
<Button variant="ghost" disabled={mappingsBusy} onclick={() => removeMapping(index)}>{t('mappingRemove')}</Button>
|
|
@@ -1,45 +1,2 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
total: number;
|
|
4
|
-
page: number;
|
|
5
|
-
pageSize: number;
|
|
6
|
-
}
|
|
7
|
-
export interface ModelSearchState<T> extends ModelPage<T> {
|
|
8
|
-
loading: boolean;
|
|
9
|
-
error: boolean;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** Cancels on every query change, even during debounce, and ignores late responses. */
|
|
13
|
-
export function createModelSearch<T, Query>(
|
|
14
|
-
load: (path: string, signal: AbortSignal) => Promise<ModelPage<T>>,
|
|
15
|
-
publish: (state: ModelSearchState<T>) => void,
|
|
16
|
-
pathFor: (query: Query, page: number) => string,
|
|
17
|
-
delayMs = 250,
|
|
18
|
-
) {
|
|
19
|
-
let generation = 0;
|
|
20
|
-
let controller: AbortController | undefined;
|
|
21
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
22
|
-
let destroyed = false;
|
|
23
|
-
function cancel() { generation++; clearTimeout(timer); controller?.abort(); }
|
|
24
|
-
function search(query: Query, page = 1, debounce = true) {
|
|
25
|
-
if (destroyed) return;
|
|
26
|
-
cancel();
|
|
27
|
-
const version = generation;
|
|
28
|
-
publish({ models: [], total: 0, page, pageSize: 50, loading: true, error: false });
|
|
29
|
-
async function run() {
|
|
30
|
-
const request = new AbortController();
|
|
31
|
-
controller = request;
|
|
32
|
-
try {
|
|
33
|
-
const result = await load(pathFor(query, page), request.signal);
|
|
34
|
-
if (!destroyed && version === generation) publish({ ...result, loading: false, error: false });
|
|
35
|
-
} catch {
|
|
36
|
-
if (!destroyed && version === generation && !request.signal.aborted) {
|
|
37
|
-
publish({ models: [], total: 0, page, pageSize: 50, loading: false, error: true });
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
if (debounce) timer = setTimeout(() => void run(), delayMs);
|
|
42
|
-
else void run();
|
|
43
|
-
}
|
|
44
|
-
return { search, cancel, destroy() { destroyed = true; cancel(); } };
|
|
45
|
-
}
|
|
1
|
+
export { createModelSearch } from '../../../packages/ui/src/components/industrial/paginated-search';
|
|
2
|
+
export type { ModelPage, ModelSearchState } from '../../../packages/ui/src/components/industrial/paginated-search';
|
|
@@ -9,9 +9,10 @@ export function createPricingModelSearch(
|
|
|
9
9
|
load: (path: string, signal: AbortSignal) => Promise<PricingModelPage>,
|
|
10
10
|
publish: (state: PricingModelSearchState) => void,
|
|
11
11
|
delayMs = 250,
|
|
12
|
+
catalogPath = '/pricing/models',
|
|
12
13
|
) {
|
|
13
14
|
return createModelSearch(load, publish, (query: { provider: string; search: string }, page) => {
|
|
14
15
|
const params = new URLSearchParams({ provider: query.provider, search: query.search, page: String(page), pageSize: '50' });
|
|
15
|
-
return
|
|
16
|
+
return `${catalogPath}?${params}`;
|
|
16
17
|
}, delayMs);
|
|
17
18
|
}
|
|
@@ -436,6 +436,13 @@ export declare class ScopedPluginRegistry {
|
|
|
436
436
|
/** Captured by the host once for the complete logical request and all attempts. */
|
|
437
437
|
getGatewayHooks(): PluginHooks;
|
|
438
438
|
getGlobalAdmissionHandlers(): readonly PluginHandler[];
|
|
439
|
+
/** Entry plugins retained across dispatch/session work even when the final route differs. */
|
|
440
|
+
getRoutePluginOwners(routeId: string, dispatchOnly?: boolean): Array<{
|
|
441
|
+
pluginName: string;
|
|
442
|
+
scopeKey: string;
|
|
443
|
+
}>;
|
|
444
|
+
/** Dispatch and deferred response callbacks share the retained entry owner's RPC authority. */
|
|
445
|
+
dispatchRequest(routeId: string, input: import('./gateway/contracts').DispatchRequestInput, callee: unknown): Promise<import('./gateway/contracts').DispatchRequestDecision | undefined>;
|
|
439
446
|
getDeclaredDispatchTargets(routeId: string): import('./gateway/contracts').GatewayDispatchTarget[];
|
|
440
447
|
/**
|
|
441
448
|
* 从配置初始化所有插件实例
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { createWebSocketMessageView } from './message';
|
|
2
2
|
export { isWebSocketUpgradeRequest, validateWebSocketRequest } from './headers';
|
|
3
|
-
export { createWebSocketBridge, WebSocketBridge, WEBSOCKET_BRIDGE_DEFAULTS, type WebSocketBridgeOptions, type WebSocketBridgeData, type WebSocketUpgradeOptions, type WebSocketBridgeStats, } from './transport';
|
|
3
|
+
export { createWebSocketBridge, WebSocketBridge, WEBSOCKET_BRIDGE_DEFAULTS, type WebSocketBridgeOptions, type WebSocketBridgeData, type WebSocketUpgradeOptions, type WebSocketBridgeStats, type WebSocketResponsesRequestOptions, type ManagedWebSocketSession, type ManagedWebSocketSessionOptions, } from './transport';
|
|
@@ -29,6 +29,28 @@ export interface WebSocketUpgradeOptions {
|
|
|
29
29
|
/** Runs once after both native close callbacks, for committed sessions only. */
|
|
30
30
|
readonly onClose?: (code: number, reason: string, metrics: WebSocketSessionMetrics) => unknown;
|
|
31
31
|
}
|
|
32
|
+
/** Application-owned messages, independent of the HTTP handshake lifetime. */
|
|
33
|
+
export interface ManagedWebSocketSession {
|
|
34
|
+
readonly signal: AbortSignal;
|
|
35
|
+
/** Resolves after native drain when Bun has queued the frame. Never resends it. */
|
|
36
|
+
send(message: string): Promise<void>;
|
|
37
|
+
close(code: number, reason: string): void;
|
|
38
|
+
}
|
|
39
|
+
export interface ManagedWebSocketSessionOptions {
|
|
40
|
+
readonly headers?: Headers;
|
|
41
|
+
/** Handshake cancellation only; the session owns its established lifetime. */
|
|
42
|
+
readonly signal?: AbortSignal;
|
|
43
|
+
readonly onOpen?: (session: ManagedWebSocketSession) => void | Promise<void>;
|
|
44
|
+
/** Invoked synchronously; returned jobs retain admission until they settle. */
|
|
45
|
+
readonly onMessage: (session: ManagedWebSocketSession, message: WebSocketMessageView) => void | Promise<void>;
|
|
46
|
+
readonly onClose?: (code: number, reason: string, metrics: WebSocketSessionMetrics) => unknown;
|
|
47
|
+
}
|
|
48
|
+
export interface WebSocketResponsesRequestOptions {
|
|
49
|
+
readonly url: URL;
|
|
50
|
+
readonly headers: Headers;
|
|
51
|
+
readonly body: Record<string, unknown>;
|
|
52
|
+
readonly signal: AbortSignal;
|
|
53
|
+
}
|
|
32
54
|
export interface WebSocketBridgeStats {
|
|
33
55
|
readonly accepting: boolean;
|
|
34
56
|
readonly connections: number;
|
|
@@ -38,6 +60,19 @@ export interface WebSocketBridgeStats {
|
|
|
38
60
|
readonly downstreamBackpressureEvents: number;
|
|
39
61
|
readonly downstreamDrainEvents: number;
|
|
40
62
|
}
|
|
63
|
+
interface TransportSession {
|
|
64
|
+
readonly token: object;
|
|
65
|
+
readonly bufferedBytes: number;
|
|
66
|
+
readonly processLimit: number;
|
|
67
|
+
refreshBuffered(): void;
|
|
68
|
+
cancelHandshake(status: number, reason: string): void;
|
|
69
|
+
downstreamOpen(socket: Bun.ServerWebSocket<WebSocketBridgeData>): void;
|
|
70
|
+
fromClient(message: Message): void;
|
|
71
|
+
downstreamDrain(): void;
|
|
72
|
+
downstreamClose(code: number, reason: string): void;
|
|
73
|
+
close(code: number, reason: string): void;
|
|
74
|
+
terminate(): void;
|
|
75
|
+
}
|
|
41
76
|
export declare class WebSocketBridge {
|
|
42
77
|
#private;
|
|
43
78
|
readonly limits: Readonly<Limits>;
|
|
@@ -46,6 +81,10 @@ export declare class WebSocketBridge {
|
|
|
46
81
|
get stats(): WebSocketBridgeStats;
|
|
47
82
|
/** Call only for upgrade candidates; ordinary HTTP returns undefined without taking ownership. */
|
|
48
83
|
upgrade(request: Request, server: Bun.Server<any>, options: WebSocketUpgradeOptions): Promise<Response | undefined>;
|
|
84
|
+
/** Uses the same native handler and admission budgets without an upstream socket. */
|
|
85
|
+
upgradeSession(request: Request, server: Bun.Server<any>, options: ManagedWebSocketSessionOptions): Promise<Response | undefined>;
|
|
86
|
+
/** One native Responses generation, exposed to the normal HTTP body pipeline as SSE. */
|
|
87
|
+
requestResponses(options: WebSocketResponsesRequestOptions): Promise<Response>;
|
|
49
88
|
stopAccepting(): void;
|
|
50
89
|
/** 1012 close, bounded grace, then terminate, and require actual native close confirmation. */
|
|
51
90
|
stop(): Promise<void>;
|
|
@@ -53,25 +92,8 @@ export declare class WebSocketBridge {
|
|
|
53
92
|
get accepting(): boolean;
|
|
54
93
|
/** @internal */ endHandshake(): void;
|
|
55
94
|
/** @internal */ backpressure(): void;
|
|
56
|
-
/** @internal */ confirmGone(session:
|
|
95
|
+
/** @internal */ confirmGone(session: TransportSession): void;
|
|
57
96
|
}
|
|
58
97
|
type Message = string | Buffer;
|
|
59
|
-
declare class BridgeSession {
|
|
60
|
-
#private;
|
|
61
|
-
readonly token: Readonly<{}>;
|
|
62
|
-
readonly response: Promise<Response | undefined>;
|
|
63
|
-
constructor(bridge: WebSocketBridge, request: Request, server: Bun.Server<any>, options: WebSocketUpgradeOptions, url: URL);
|
|
64
|
-
get bufferedBytes(): number;
|
|
65
|
-
get processLimit(): number;
|
|
66
|
-
start(): void;
|
|
67
|
-
cancelHandshake(status: number, reason: string): void;
|
|
68
|
-
downstreamOpen(socket: Bun.ServerWebSocket<WebSocketBridgeData>): void;
|
|
69
|
-
fromClient(message: Message): void;
|
|
70
|
-
downstreamDrain(): void;
|
|
71
|
-
refreshBuffered(): void;
|
|
72
|
-
close(code: number, reason: string): void;
|
|
73
|
-
terminate(): void;
|
|
74
|
-
downstreamClose(code: number, reason: string): void;
|
|
75
|
-
}
|
|
76
98
|
export declare function createWebSocketBridge(options?: WebSocketBridgeOptions): WebSocketBridge;
|
|
77
99
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeffusion/bungee-core",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.21.0",
|
|
4
4
|
"description": "Bungee reverse proxy core engine",
|
|
5
5
|
"main": "./dist/main.js",
|
|
6
6
|
"types": "./dist/main.d.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@bungee/ws-client": "npm:ws@8.22.0",
|
|
16
16
|
"@types/ws": "^8.18.1",
|
|
17
|
-
"@jeffusion/bungee-types": "^5.
|
|
17
|
+
"@jeffusion/bungee-types": "^5.21.0",
|
|
18
18
|
"@types/lodash-es": "^4.17.12",
|
|
19
19
|
"acorn": "^8.15.0",
|
|
20
20
|
"canonicalize": "3.0.0",
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from 'bun:test';
|
|
3
|
-
import { accountSummary, accountUsage, errorCode, resetOutcome } from './account-model.js';
|
|
4
|
-
|
|
5
|
-
const usage = { usage: { state: 'fresh', value: { availableCount: 1 } }, resetCredits: { state: 'fresh', value: {
|
|
6
|
-
availableCount: 1,
|
|
7
|
-
credits: [{ id: 'safe-credit', status: 'available', resetType: 'daily', grantedAt: 1700000000000,
|
|
8
|
-
title: 'Daily reset', description: 'A bounded description', expiresAt: 1700003600000 }],
|
|
9
|
-
} } };
|
|
10
|
-
|
|
11
|
-
test('old accounts default automation off; pending automatic reset is bounded and redacted', () => {
|
|
12
|
-
const base = { id: 'simulation-account', label: 'Simulation', status: 'active', available: true };
|
|
13
|
-
assert.equal(accountSummary(base).autoResetCredits, false);
|
|
14
|
-
const pending = { creditId: 'simulation-credit', redeemRequestId: '123e4567-e89b-42d3-a456-426614174000', expiresAt: 1900000000000, completed: false };
|
|
15
|
-
const parsed = accountSummary({ ...base, autoResetCredits: true, pendingAutoReset: { ...pending, accessToken: 'never-expose' } });
|
|
16
|
-
assert.equal(parsed.autoResetCredits, true);
|
|
17
|
-
assert.deepEqual(parsed.pendingAutoReset, { creditId: pending.creditId, redeemRequestId: pending.redeemRequestId, expiresAt: pending.expiresAt });
|
|
18
|
-
for (const autoResetCredits of ['true', 1, null]) assert.throws(() => accountSummary({ ...base, autoResetCredits }), /invalid_response/);
|
|
19
|
-
for (const override of [{ creditId: '' }, { redeemRequestId: 'bad' }, { expiresAt: NaN }, { completed: true }]) {
|
|
20
|
-
assert.throws(() => accountSummary({ ...base, pendingAutoReset: { ...pending, ...override } }), /invalid_response/);
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test('projects bounded credit metadata without exposing unsafe fields', () => {
|
|
25
|
-
const result = accountUsage(usage);
|
|
26
|
-
assert.deepEqual(result.resetCredits.value?.credits[0], {
|
|
27
|
-
creditId: 'safe-credit', status: 'available', resetType: 'daily', grantedAt: 1700000000000,
|
|
28
|
-
title: 'Daily reset', description: 'A bounded description', expiresAt: 1700003600000,
|
|
29
|
-
});
|
|
30
|
-
for (const status of ['redeeming', 'redeemed', 'unknown']) assert.equal(accountUsage({
|
|
31
|
-
...usage, resetCredits: { ...usage.resetCredits, value: { ...usage.resetCredits.value, credits: [{ id: 'x', status }] } },
|
|
32
|
-
}).resetCredits.value?.credits[0].status, status);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('rejects invalid credit statuses, types and control characters', () => {
|
|
36
|
-
for (const status of ['consumed', 'available\n', 1, null]) assert.throws(() => accountUsage({
|
|
37
|
-
...usage, resetCredits: { ...usage.resetCredits, value: { ...usage.resetCredits.value, credits: [{ id: 'x', status }] } },
|
|
38
|
-
}), /invalid_response/);
|
|
39
|
-
for (const field of ['title', 'description', 'resetType']) assert.throws(() => accountUsage({
|
|
40
|
-
...usage, resetCredits: { ...usage.resetCredits, value: { ...usage.resetCredits.value, credits: [{ id: 'x', status: 'available', [field]: 'bad\u0000text' }] } },
|
|
41
|
-
}), /invalid_response/);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('keeps usage availableCount authoritative and rejects invalid counts', () => {
|
|
45
|
-
assert.equal(accountUsage(usage).usage.value?.availableCount, 1);
|
|
46
|
-
for (const availableCount of [-1, 1.5, '1', null]) assert.throws(() => accountUsage({
|
|
47
|
-
...usage, usage: { state: 'fresh', value: { availableCount } },
|
|
48
|
-
}), /invalid_response/);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('treats a null secondary window as absent, not malformed', () => {
|
|
52
|
-
const parsed = accountUsage({ usage: { state: 'fresh', value: { primary: { usedPercent: 25 }, secondary: null } }, resetCredits: { state: 'fresh', value: { availableCount: 1, credits: [] } } });
|
|
53
|
-
assert.equal(parsed.usage.value?.primary?.usedPercent, 25);
|
|
54
|
-
assert.equal(parsed.usage.value?.secondary, undefined);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('accepts only definite outcomes and non-negative integer windowsReset', () => {
|
|
58
|
-
for (const outcome of ['reset', 'already_redeemed', 'nothing_to_reset', 'no_credit']) assert.equal(resetOutcome({ outcome, windowsReset: 0 }).outcome, outcome);
|
|
59
|
-
assert.throws(() => resetOutcome({ outcome: 'reset' }), /invalid_response/);
|
|
60
|
-
for (const value of ['nothing', 'reset_outcome_unknown', 'unexpected', 1]) assert.throws(() => resetOutcome({ outcome: value }), /invalid_response|reset_outcome_unknown/);
|
|
61
|
-
for (const windowsReset of [-1, 1.5, '1', null]) assert.throws(() => resetOutcome({ outcome: 'reset', windowsReset }), /invalid_response/);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('keeps unknown reset errors on the explicit API error path', () => {
|
|
65
|
-
assert.equal(errorCode({ body: { error: 'reset_outcome_unknown' } }), 'reset_outcome_unknown');
|
|
66
|
-
assert.equal(errorCode({ body: { error: 'reset_in_progress' } }), 'reset_in_progress');
|
|
67
|
-
assert.equal(errorCode({ body: { error: 'reset' } }), 'unknown');
|
|
68
|
-
for (const code of ['credits_unavailable', 'credit_unavailable', 'upstream_unavailable', 'request_cancelled']) assert.equal(errorCode({ body: { error: code } }), code);
|
|
69
|
-
});
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { expect, test } from 'bun:test';
|
|
2
|
-
import { loginFailure, requireLoginSuccess } from './login-errors';
|
|
3
|
-
import manifest from '../manifest.json';
|
|
4
|
-
|
|
5
|
-
test('only explicit provider success can proceed to context completion', () => {
|
|
6
|
-
expect(() => requireLoginSuccess({ success: true })).not.toThrow();
|
|
7
|
-
for (const result of [null, undefined, true, {}, { success: 'true' }, { success: false }]) {
|
|
8
|
-
expect(() => requireLoginSuccess(result)).toThrow('login_failed');
|
|
9
|
-
}
|
|
10
|
-
expect(() => requireLoginSuccess({ success: false, error: 'invalid_credentials' })).toThrow('invalid_credentials');
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
test('credential errors are plugin-owned and translated in both locales', () => {
|
|
14
|
-
for (const code of ['invalid_credentials', 'invalid_input', 'forbidden', 'unauthorized', 'invalid_origin', 'invalid_csrf', 'login_limited', 'session_limit', 'management_provider_unavailable', 'provider_unavailable', 'control_recovering']) {
|
|
15
|
-
const failure = loginFailure(new Error(code));
|
|
16
|
-
expect(failure).toEqual({ key: `login.errors.${code}`, detail: code });
|
|
17
|
-
for (const messages of Object.values(manifest.translations)) expect(messages[failure.key as keyof typeof messages]).toBeTruthy();
|
|
18
|
-
}
|
|
19
|
-
expect(loginFailure(new Error('unrecognized')).key).toBe('login.errors.unknown');
|
|
20
|
-
const incomplete = new Error('management_login_initialization_failed'); incomplete.name = 'ManagementAuthenticationError';
|
|
21
|
-
expect(loginFailure(incomplete).key).toBe('login.errors.session_unavailable');
|
|
22
|
-
});
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { createClientModelSearch, type ClientModelPage, type ClientModelSearchState } from './client-model-search';
|
|
3
|
-
|
|
4
|
-
const page = (models: string[], current = 1): ClientModelPage => ({ models, page: current, pageSize: 50, total: 101 });
|
|
5
|
-
const deferred = () => { let resolve!: (page: ClientModelPage) => void; const promise = new Promise<ClientModelPage>(done => resolve = done); return { resolve, promise }; };
|
|
6
|
-
const tick = () => new Promise(resolve => setTimeout(resolve, 10));
|
|
7
|
-
|
|
8
|
-
describe('client model search lifecycle', () => {
|
|
9
|
-
test('debounces typing and preserves exact search case and query characters', async () => {
|
|
10
|
-
const paths: string[] = [];
|
|
11
|
-
const states: ClientModelSearchState[] = [];
|
|
12
|
-
const search = createClientModelSearch(async path => { paths.push(path); return page(['DeepSeek/V4 & 中文']); }, state => states.push(state), 5);
|
|
13
|
-
search.search('first'); search.search('DeepSeek/V4 & 中文');
|
|
14
|
-
expect(paths).toHaveLength(0);
|
|
15
|
-
await tick();
|
|
16
|
-
expect(paths).toEqual(['/models?search=DeepSeek%2FV4+%26+%E4%B8%AD%E6%96%87&page=1&pageSize=50']);
|
|
17
|
-
expect(states.at(-1)?.models).toEqual(['DeepSeek/V4 & 中文']);
|
|
18
|
-
search.destroy();
|
|
19
|
-
});
|
|
20
|
-
test('aborts old requests during debounce and rejects late responses', async () => {
|
|
21
|
-
const pending = [deferred(), deferred()];
|
|
22
|
-
const signals: AbortSignal[] = [];
|
|
23
|
-
const states: ClientModelSearchState[] = [];
|
|
24
|
-
const search = createClientModelSearch((_path, signal) => { signals.push(signal); return pending[signals.length - 1].promise; }, state => states.push(state), 5);
|
|
25
|
-
search.search('old', 1, false);
|
|
26
|
-
search.search('new');
|
|
27
|
-
expect(signals[0].aborted).toBe(true);
|
|
28
|
-
pending[0].resolve(page(['OLD'])); await tick();
|
|
29
|
-
expect(states.at(-1)?.models).toEqual([]);
|
|
30
|
-
pending[1].resolve(page(['New'])); await tick();
|
|
31
|
-
expect(states.at(-1)?.models).toEqual(['New']);
|
|
32
|
-
search.destroy();
|
|
33
|
-
});
|
|
34
|
-
test('requests later pages and aborts on component destruction without publishing', async () => {
|
|
35
|
-
const pending = deferred();
|
|
36
|
-
let signal!: AbortSignal;
|
|
37
|
-
let path = '';
|
|
38
|
-
const states: ClientModelSearchState[] = [];
|
|
39
|
-
const search = createClientModelSearch((requested, incoming) => { path = requested; signal = incoming; return pending.promise; }, state => states.push(state));
|
|
40
|
-
search.search('MixedCase', 3, false);
|
|
41
|
-
expect(path).toBe('/models?search=MixedCase&page=3&pageSize=50');
|
|
42
|
-
search.destroy(); expect(signal.aborted).toBe(true);
|
|
43
|
-
pending.resolve(page(['Late'], 3)); await tick();
|
|
44
|
-
expect(states).toHaveLength(1);
|
|
45
|
-
search.search('ignored', 1, false); expect(states).toHaveLength(1);
|
|
46
|
-
});
|
|
47
|
-
test('reports search failure and accepts another query after failure', async () => {
|
|
48
|
-
const states: ClientModelSearchState[] = [];
|
|
49
|
-
let fail = true;
|
|
50
|
-
const search = createClientModelSearch(async () => { if (fail) throw new Error('offline'); return page(['ExactCASE']); }, state => states.push(state));
|
|
51
|
-
search.search('', 1, false); await tick();
|
|
52
|
-
expect(states.at(-1)?.error).toBe(true);
|
|
53
|
-
fail = false; search.search('ExactCASE', 1, false); await tick();
|
|
54
|
-
expect(states.at(-1)?.error).toBe(false);
|
|
55
|
-
expect(states.at(-1)?.models).toEqual(['ExactCASE']);
|
|
56
|
-
search.destroy();
|
|
57
|
-
});
|
|
58
|
-
});
|