@jeffusion/bungee-core 5.19.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.
Files changed (41) hide show
  1. package/dist/config-worker/private-transport.d.ts +2 -0
  2. package/dist/data-admission/errors.d.ts +6 -1
  3. package/dist/gateway/contracts.d.ts +16 -3
  4. package/dist/gateway/forward-plugin.d.ts +2 -0
  5. package/dist/gateway/plugin.js +12 -2
  6. package/dist/gateway/request-plugin.d.ts +6 -0
  7. package/dist/gateway/responses-websocket-session.d.ts +18 -0
  8. package/dist/gateway/runtime.d.ts +1 -0
  9. package/dist/gateway/websocket-contracts.d.ts +1 -0
  10. package/dist/hooks/plugin-hooks.d.ts +2 -0
  11. package/dist/logger/access-log-writer.d.ts +2 -2
  12. package/dist/main.js +1234 -191
  13. package/dist/plugins/ai-transformer/index.js +11 -1
  14. package/dist/plugins/ai-transformer/index.js.map +5 -3
  15. package/dist/plugins/codex-router/control.js +677 -2
  16. package/dist/plugins/codex-router/control.js.map +19 -4
  17. package/dist/plugins/codex-router/index.js +4123 -33
  18. package/dist/plugins/codex-router/index.js.map +25 -7
  19. package/dist/plugins/codex-router/manifest.json +139 -9
  20. package/dist/plugins/key-access/index.js +12 -2
  21. package/dist/plugins/key-access/index.js.map +3 -3
  22. package/dist/plugins/key-access/ingress.js +12 -2
  23. package/dist/plugins/key-access/ingress.js.map +3 -3
  24. package/dist/plugins/openai-messages-to-chat/index.js +693 -543
  25. package/dist/plugins/openai-messages-to-chat/index.js.map +8 -4
  26. package/dist/plugins/token-metering/index.js +652 -71
  27. package/dist/plugins/token-metering/index.js.map +5 -3
  28. package/dist/plugins/token-stats/manifest.json +16 -6
  29. package/dist/plugins/token-stats/ui/ClientModelPicker.svelte +13 -61
  30. package/dist/plugins/token-stats/ui/PriceModelPicker.svelte +18 -74
  31. package/dist/plugins/token-stats/ui/TokenStatsSettings.svelte +3 -2
  32. package/dist/plugins/token-stats/ui/model-search.ts +2 -45
  33. package/dist/plugins/token-stats/ui/pricing-catalog.ts +2 -1
  34. package/dist/scoped-plugin-registry.d.ts +11 -2
  35. package/dist/websocket/index.d.ts +1 -1
  36. package/dist/websocket/transport.d.ts +40 -18
  37. package/package.json +2 -2
  38. package/dist/plugins/chatgpt-oauth/ui/account-model.test.js +0 -69
  39. package/dist/plugins/local-accounts/ui/login-errors.test.ts +0 -22
  40. package/dist/plugins/token-stats/ui/client-model-search.test.ts +0 -58
  41. 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": "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.",
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, emptyLabel, previousLabel, nextLabel, onchange }: {
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
- loadingLabel: string; errorLabel: string; emptyLabel: string; previousLabel: string; nextLabel: string;
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; highlighted = -1; },
17
+ state => { results = state; },
22
18
  );
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
- }
19
+ const options = $derived(results.models.map(model => ({ value: model, label: model })));
41
20
  onMount(() => () => search.destroy());
42
21
  </script>
43
22
 
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>
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 * 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';
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, 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;
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('token-stats', path, 'GET', undefined, signal),
33
- state => { results = state; },
18
+ (path, signal) => requestPluginControl(catalogPlugin, path, 'GET', undefined, signal),
19
+ state => { results = state; }, 250, catalogPath,
34
20
  );
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); }
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
- <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>
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')} previousLabel={t('previousPage')} nextLabel={t('nextPage')}
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('mappingRetry')} previousLabel={t('previousPage')} nextLabel={t('nextPage')}
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 interface ModelPage<T> {
2
- models: T[];
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 `/pricing/models?${params}`;
16
+ return `${catalogPath}?${params}`;
16
17
  }, delayMs);
17
18
  }
@@ -144,6 +144,7 @@ export interface InboundChain {
144
144
  onError: (ctx: any) => Promise<void>;
145
145
  }
146
146
  export interface PhaseAwareHooks {
147
+ dispatchAdapter?: PrecompiledHooks;
147
148
  routePhase: PrecompiledHooks;
148
149
  servicePhase: PrecompiledHooks | null;
149
150
  upstreamPhase: PrecompiledHooks;
@@ -185,6 +186,7 @@ export declare class ScopedPluginRegistry {
185
186
  * same canonical owner/lifecycle/lease registry as every other consumer.
186
187
  */
187
188
  readonly serviceHost: PluginServiceHost;
189
+ private dispatchConfig?;
188
190
  private readonly rpcContexts;
189
191
  private readonly requestLeases;
190
192
  /** Host request entry: async credential work reuses the original owner lease. */
@@ -391,7 +393,7 @@ export declare class ScopedPluginRegistry {
391
393
  * }
392
394
  * ```
393
395
  */
394
- getPrecompiledHooks(routeId: string, upstreamId?: string, serviceName?: string): PhaseAwareHooks;
396
+ getPrecompiledHooks(routeId: string, upstreamId?: string, serviceName?: string, adapter?: import('./plugin.types').Plugin): PhaseAwareHooks;
395
397
  /** Retain every instance actually executed by handshake phases; observer precedence is different. */
396
398
  getWebSocketHandshakeOwners(routeId: string, upstreamId: string, serviceName?: string): Array<{
397
399
  pluginName: string;
@@ -425,7 +427,7 @@ export declare class ScopedPluginRegistry {
425
427
  buildRoutePhaseHooks(routeId: string): PrecompiledHooks;
426
428
  buildServicePhaseHooks(routeId: string, serviceName: string): PrecompiledHooks | null;
427
429
  buildUpstreamPhaseHooks(routeId: string, upstreamId: string): PrecompiledHooks;
428
- buildInboundChain(routeId: string, upstreamId: string, serviceName?: string): InboundChain;
430
+ buildInboundChain(routeId: string, upstreamId: string, serviceName?: string, adapter?: PrecompiledHooks): InboundChain;
429
431
  /**
430
432
  * 清除指定 scope 的预编译缓存
431
433
  */
@@ -434,6 +436,13 @@ export declare class ScopedPluginRegistry {
434
436
  /** Captured by the host once for the complete logical request and all attempts. */
435
437
  getGatewayHooks(): PluginHooks;
436
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>;
437
446
  getDeclaredDispatchTargets(routeId: string): import('./gateway/contracts').GatewayDispatchTarget[];
438
447
  /**
439
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: BridgeSession): void;
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.19.0",
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.19.0",
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
- });