@seanhogg/builderforce-memory 2026.9.29 → 2026.9.31

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 (118) hide show
  1. package/dist/bridges/AnthropicBridge.d.ts.map +1 -1
  2. package/dist/bridges/AnthropicBridge.js +24 -65
  3. package/dist/bridges/AnthropicBridge.js.map +1 -1
  4. package/dist/bridges/FetchBridge.d.ts +12 -5
  5. package/dist/bridges/FetchBridge.d.ts.map +1 -1
  6. package/dist/bridges/FetchBridge.js +6 -4
  7. package/dist/bridges/FetchBridge.js.map +1 -1
  8. package/dist/bridges/OpenAIBridge.d.ts +17 -4
  9. package/dist/bridges/OpenAIBridge.d.ts.map +1 -1
  10. package/dist/bridges/OpenAIBridge.js +71 -94
  11. package/dist/bridges/OpenAIBridge.js.map +1 -1
  12. package/dist/bridges/TransformerBridge.d.ts +6 -0
  13. package/dist/bridges/TransformerBridge.d.ts.map +1 -1
  14. package/dist/bridges/VertexAIBridge.d.ts.map +1 -1
  15. package/dist/bridges/VertexAIBridge.js +3 -34
  16. package/dist/bridges/VertexAIBridge.js.map +1 -1
  17. package/dist/distillation/DistillationEngine.d.ts +71 -54
  18. package/dist/distillation/DistillationEngine.d.ts.map +1 -1
  19. package/dist/distillation/DistillationEngine.js +112 -95
  20. package/dist/distillation/DistillationEngine.js.map +1 -1
  21. package/dist/distillation/index.d.ts +7 -1
  22. package/dist/distillation/index.d.ts.map +1 -1
  23. package/dist/distillation/index.js +5 -0
  24. package/dist/distillation/index.js.map +1 -1
  25. package/dist/distillation/ports.d.ts +43 -0
  26. package/dist/distillation/ports.d.ts.map +1 -0
  27. package/dist/distillation/ports.js +29 -0
  28. package/dist/distillation/ports.js.map +1 -0
  29. package/dist/eval/EvalHarness.d.ts +2 -1
  30. package/dist/eval/EvalHarness.d.ts.map +1 -1
  31. package/dist/eval/EvalHarness.js +13 -1
  32. package/dist/eval/EvalHarness.js.map +1 -1
  33. package/dist/eval/baseline.d.ts +33 -0
  34. package/dist/eval/baseline.d.ts.map +1 -0
  35. package/dist/eval/baseline.js +41 -0
  36. package/dist/eval/baseline.js.map +1 -0
  37. package/dist/eval/index.d.ts +1 -0
  38. package/dist/eval/index.d.ts.map +1 -1
  39. package/dist/eval/index.js +1 -0
  40. package/dist/eval/index.js.map +1 -1
  41. package/dist/eval/types.d.ts +7 -0
  42. package/dist/eval/types.d.ts.map +1 -1
  43. package/dist/index.d.ts +2 -2
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/rag/EnterpriseRetriever.d.ts.map +1 -1
  48. package/dist/rag/EnterpriseRetriever.js +2 -1
  49. package/dist/rag/EnterpriseRetriever.js.map +1 -1
  50. package/dist/retrieval/HybridRetriever.d.ts.map +1 -1
  51. package/dist/retrieval/HybridRetriever.js +2 -1
  52. package/dist/retrieval/HybridRetriever.js.map +1 -1
  53. package/dist/retrieval/decay.d.ts +24 -0
  54. package/dist/retrieval/decay.d.ts.map +1 -0
  55. package/dist/retrieval/decay.js +29 -0
  56. package/dist/retrieval/decay.js.map +1 -0
  57. package/dist/retrieval/fusion.d.ts +13 -19
  58. package/dist/retrieval/fusion.d.ts.map +1 -1
  59. package/dist/retrieval/fusion.js +17 -44
  60. package/dist/retrieval/fusion.js.map +1 -1
  61. package/dist/retrieval/index.d.ts +13 -6
  62. package/dist/retrieval/index.d.ts.map +1 -1
  63. package/dist/retrieval/index.js +10 -5
  64. package/dist/retrieval/index.js.map +1 -1
  65. package/dist/retrieval/mmr.d.ts +60 -0
  66. package/dist/retrieval/mmr.d.ts.map +1 -0
  67. package/dist/retrieval/mmr.js +94 -0
  68. package/dist/retrieval/mmr.js.map +1 -0
  69. package/dist/retrieval/weightedFusion.d.ts +64 -0
  70. package/dist/retrieval/weightedFusion.d.ts.map +1 -0
  71. package/dist/retrieval/weightedFusion.js +80 -0
  72. package/dist/retrieval/weightedFusion.js.map +1 -0
  73. package/dist/similarity/index.d.ts +6 -4
  74. package/dist/similarity/index.d.ts.map +1 -1
  75. package/dist/similarity/index.js +5 -3
  76. package/dist/similarity/index.js.map +1 -1
  77. package/dist/wire/chatClient.d.ts +100 -0
  78. package/dist/wire/chatClient.d.ts.map +1 -0
  79. package/dist/wire/chatClient.js +123 -0
  80. package/dist/wire/chatClient.js.map +1 -0
  81. package/dist/wire/index.d.ts +20 -0
  82. package/dist/wire/index.d.ts.map +1 -0
  83. package/dist/wire/index.js +18 -0
  84. package/dist/wire/index.js.map +1 -0
  85. package/dist/wire/sse.d.ts +37 -0
  86. package/dist/wire/sse.d.ts.map +1 -0
  87. package/dist/wire/sse.js +104 -0
  88. package/dist/wire/sse.js.map +1 -0
  89. package/dist/wire/usage.d.ts +46 -0
  90. package/dist/wire/usage.d.ts.map +1 -0
  91. package/dist/wire/usage.js +78 -0
  92. package/dist/wire/usage.js.map +1 -0
  93. package/package.json +11 -3
  94. package/src/bridges/AnthropicBridge.ts +24 -68
  95. package/src/bridges/FetchBridge.ts +15 -6
  96. package/src/bridges/OpenAIBridge.ts +84 -113
  97. package/src/bridges/TransformerBridge.ts +6 -0
  98. package/src/bridges/VertexAIBridge.ts +3 -32
  99. package/src/distillation/DistillationEngine.ts +176 -141
  100. package/src/distillation/index.ts +15 -1
  101. package/src/distillation/ports.ts +55 -0
  102. package/src/eval/EvalHarness.ts +12 -1
  103. package/src/eval/baseline.ts +51 -0
  104. package/src/eval/index.ts +1 -0
  105. package/src/eval/types.ts +7 -0
  106. package/src/index.ts +5 -2
  107. package/src/rag/EnterpriseRetriever.ts +2 -1
  108. package/src/retrieval/HybridRetriever.ts +2 -1
  109. package/src/retrieval/decay.ts +29 -0
  110. package/src/retrieval/fusion.ts +19 -52
  111. package/src/retrieval/index.ts +24 -6
  112. package/src/retrieval/mmr.ts +126 -0
  113. package/src/retrieval/weightedFusion.ts +101 -0
  114. package/src/similarity/index.ts +6 -4
  115. package/src/wire/chatClient.ts +196 -0
  116. package/src/wire/index.ts +20 -0
  117. package/src/wire/sse.ts +96 -0
  118. package/src/wire/usage.ts +95 -0
@@ -8,6 +8,7 @@
8
8
  import { SSMError } from '../errors/SSMError.js';
9
9
  import type { LlmUsage } from '../telemetry/types.js';
10
10
  import type { TransformerBridge, BridgeGenerateOptions, BridgeCallInfo } from './TransformerBridge.js';
11
+ import { readSseDataFrames, readUsageFields, AnthropicStreamUsage } from '../wire/index.js';
11
12
 
12
13
  export interface AnthropicBridgeOptions {
13
14
  /** Anthropic API key. */
@@ -110,30 +111,26 @@ export class AnthropicBridge implements TransformerBridge {
110
111
  throw new SSMError('BRIDGE_RESPONSE_INVALID', 'Anthropic streaming response has no body.');
111
112
  }
112
113
 
113
- const model = opts.model ?? this._model;
114
- let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, reportedModel = model;
115
-
116
- yield* parseAnthropicStream(res.body, (event) => {
117
- // `message_start` carries the input split; `message_delta` the running
118
- // output count. Both are folded so the final usage is provider-measured.
119
- const start = (event as any).message?.usage;
120
- const delta = (event as any).usage;
121
- const usage = start ?? delta;
122
- if (!usage) return;
123
- if (typeof (event as any).message?.model === 'string') reportedModel = (event as any).message.model;
124
- input = Math.max(input, Number(usage.input_tokens) || 0);
125
- output = Math.max(output, Number(usage.output_tokens) || 0);
126
- cacheRead = Math.max(cacheRead, Number(usage.cache_read_input_tokens) || 0);
127
- cacheWrite = Math.max(cacheWrite, Number(usage.cache_creation_input_tokens) || 0);
128
- });
114
+ // `message_start` carries the input split; `message_delta` the running
115
+ // output count. Both are folded so the final usage is provider-measured.
116
+ const usage = new AnthropicStreamUsage();
117
+ for await (const frame of readSseDataFrames(res.body)) {
118
+ usage.observe(frame);
119
+ const ev = frame as { type?: unknown; delta?: { text?: unknown } };
120
+ // content_block_delta events carry the streamed text
121
+ if (ev.type === 'content_block_delta' && typeof ev.delta?.text === 'string' && ev.delta.text.length > 0) {
122
+ yield ev.delta.text;
123
+ }
124
+ }
129
125
 
126
+ const totals = usage.result();
130
127
  this._lastCall = {
131
128
  usage: {
132
- model: reportedModel,
133
- inputTokens: input,
134
- outputTokens: output,
135
- cachedInputTokens: cacheRead,
136
- cacheWriteTokens: cacheWrite,
129
+ model: totals.model ?? opts.model ?? this._model,
130
+ inputTokens: totals.inputTokens,
131
+ outputTokens: totals.outputTokens,
132
+ cachedInputTokens: totals.cacheReadTokens,
133
+ cacheWriteTokens: totals.cacheCreationTokens,
137
134
  },
138
135
  };
139
136
  }
@@ -171,48 +168,6 @@ export class AnthropicBridge implements TransformerBridge {
171
168
  }
172
169
  }
173
170
 
174
- // ── SSE parser (Anthropic event format) ──────────────────────────────────────
175
-
176
- async function* parseAnthropicStream(
177
- body: ReadableStream<Uint8Array>,
178
- onEvent?: (event: Record<string, unknown>) => void,
179
- ): AsyncIterable<string> {
180
- const reader = body.getReader();
181
- const decoder = new TextDecoder();
182
- let buffer = '';
183
-
184
- try {
185
- while (true) {
186
- const { done, value } = await reader.read();
187
- if (done) break;
188
-
189
- buffer += decoder.decode(value, { stream: true });
190
- const lines = buffer.split('\n');
191
- buffer = lines.pop() as string; // split() always yields ≥1 element → never undefined
192
-
193
- for (const line of lines) {
194
- const trimmed = line.trim();
195
- if (!trimmed.startsWith('data: ')) continue;
196
-
197
- const data = trimmed.slice(6);
198
- try {
199
- const event = JSON.parse(data) as Record<string, unknown>;
200
- onEvent?.(event);
201
- // content_block_delta events carry the streamed text
202
- if (event['type'] === 'content_block_delta') {
203
- const text = (event as any).delta?.text;
204
- if (typeof text === 'string' && text.length > 0) yield text;
205
- }
206
- } catch {
207
- // Skip malformed SSE lines
208
- }
209
- }
210
- }
211
- } finally {
212
- reader.releaseLock();
213
- }
214
- }
215
-
216
171
  /**
217
172
  * Maps an Anthropic `usage` object onto the canonical {@link LlmUsage} shape.
218
173
  * Absent when the response omits usage (an older gateway shim), in which case
@@ -220,12 +175,13 @@ async function* parseAnthropicStream(
220
175
  */
221
176
  function readAnthropicUsage(usage: unknown, model: string): LlmUsage | undefined {
222
177
  if (!usage || typeof usage !== 'object') return undefined;
223
- const u = usage as Record<string, unknown>;
178
+ // Anthropic's `input_tokens` already excludes both cache counts.
179
+ const u = readUsageFields(usage);
224
180
  return {
225
181
  model,
226
- inputTokens: Number(u['input_tokens']) || 0,
227
- outputTokens: Number(u['output_tokens']) || 0,
228
- cachedInputTokens: Number(u['cache_read_input_tokens']) || 0,
229
- cacheWriteTokens: Number(u['cache_creation_input_tokens']) || 0,
182
+ inputTokens: u.promptTokens ?? 0,
183
+ outputTokens: u.completionTokens ?? 0,
184
+ cachedInputTokens: u.cacheReadTokens ?? 0,
185
+ cacheWriteTokens: u.cacheCreationTokens ?? 0,
230
186
  };
231
187
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * FetchBridge – generic OpenAI-compatible bridge for local or hosted endpoints.
3
3
  *
4
- * Works with Ollama, LM Studio, vLLM, llama.cpp server, or any service that
5
- * exposes a /chat/completions endpoint compatible with the OpenAI request
6
- * and response schema.
4
+ * Works with Ollama, LM Studio, vLLM, llama.cpp server, a routing gateway, or any
5
+ * service that exposes a /chat/completions endpoint compatible with the OpenAI
6
+ * request and response schema.
7
7
  */
8
8
 
9
9
  import { OpenAIBridge } from './OpenAIBridge.js';
@@ -14,12 +14,19 @@ export interface FetchBridgeOptions {
14
14
  baseUrl : string;
15
15
  /** API key — many local servers require any non-empty string. Default: 'local'. */
16
16
  apiKey? : string;
17
- /** Model name understood by the server. Default: 'default'. */
17
+ /** Model name understood by the server. Default: 'default' (none with `serverDefaults`). */
18
18
  model? : string;
19
19
  /** Default system prompt. */
20
20
  systemPrompt? : string;
21
- /** Default max tokens. Default: 512. */
21
+ /** Default max tokens. Default: 512 (none with `serverDefaults`). */
22
22
  maxTokens? : number;
23
+ /**
24
+ * Send only what a call states, so a routing gateway picks the model and the
25
+ * sampling settings. Default false.
26
+ */
27
+ serverDefaults?: boolean;
28
+ /** Abort a request after this long. */
29
+ timeoutMs? : number;
23
30
  }
24
31
 
25
32
  /**
@@ -30,10 +37,12 @@ export class FetchBridge extends OpenAIBridge {
30
37
  constructor(opts: FetchBridgeOptions) {
31
38
  super({
32
39
  apiKey : opts.apiKey ?? 'local',
33
- model : opts.model ?? 'default',
40
+ model : opts.model ?? (opts.serverDefaults ? undefined : 'default'),
34
41
  baseUrl : opts.baseUrl,
35
42
  systemPrompt : opts.systemPrompt,
36
43
  maxTokens : opts.maxTokens,
44
+ serverDefaults: opts.serverDefaults,
45
+ timeoutMs : opts.timeoutMs,
37
46
  });
38
47
  }
39
48
  }
@@ -3,11 +3,25 @@
3
3
  *
4
4
  * Supports both non-streaming and streaming (SSE) completions.
5
5
  * Compatible with any OpenAI-compatible endpoint via the `baseUrl` option.
6
+ *
7
+ * The request, error mapping and stream reading are the `/wire` chat client's — the
8
+ * same one a host uses for tool-calling turns — so this class only supplies the
9
+ * bridge's defaults and maps usage onto {@link LlmUsage}.
6
10
  */
7
11
 
8
12
  import { SSMError } from '../errors/SSMError.js';
9
13
  import type { LlmUsage } from '../telemetry/types.js';
10
14
  import type { TransformerBridge, BridgeGenerateOptions, BridgeCallInfo } from './TransformerBridge.js';
15
+ import {
16
+ chatComplete,
17
+ chatStream,
18
+ ChatCompletionError,
19
+ readUsageFields,
20
+ type ChatClient,
21
+ type ChatMessage,
22
+ type ChatRequest,
23
+ type ChatStreamEvent,
24
+ } from '../wire/index.js';
11
25
 
12
26
  export interface OpenAIBridgeOptions {
13
27
  /** OpenAI API key (or compatible service key). */
@@ -20,6 +34,13 @@ export interface OpenAIBridgeOptions {
20
34
  systemPrompt? : string;
21
35
  /** Default max tokens. Default: 512. */
22
36
  maxTokens? : number;
37
+ /**
38
+ * Send only what a call states — no model, max-tokens, temperature or top-p
39
+ * defaults — so a routing gateway picks them. Default false.
40
+ */
41
+ serverDefaults?: boolean;
42
+ /** Abort a request after this long. */
43
+ timeoutMs? : number;
23
44
  }
24
45
 
25
46
  export class OpenAIBridge implements TransformerBridge {
@@ -27,18 +48,22 @@ export class OpenAIBridge implements TransformerBridge {
27
48
 
28
49
  readonly supportsStreaming = true as const;
29
50
 
30
- private readonly _apiKey : string;
31
- private readonly _model : string;
32
- private readonly _baseUrl : string;
51
+ private readonly _client : ChatClient;
52
+ private readonly _model : string | undefined;
33
53
  private readonly _systemPrompt: string;
34
- private readonly _maxTokens : number;
54
+ private readonly _maxTokens : number | undefined;
55
+ private readonly _serverDefaults: boolean;
35
56
 
36
57
  constructor(opts: OpenAIBridgeOptions) {
37
- this._apiKey = opts.apiKey;
38
- this._model = opts.model ?? 'gpt-4o-mini';
39
- this._baseUrl = (opts.baseUrl ?? 'https://api.openai.com/v1').replace(/\/$/, '');
58
+ this._serverDefaults = opts.serverDefaults ?? false;
59
+ this._client = {
60
+ apiKey: opts.apiKey,
61
+ baseUrl: opts.baseUrl ?? 'https://api.openai.com/v1',
62
+ ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}),
63
+ };
64
+ this._model = opts.model ?? (this._serverDefaults ? undefined : 'gpt-4o-mini');
40
65
  this._systemPrompt = opts.systemPrompt ?? '';
41
- this._maxTokens = opts.maxTokens ?? 512;
66
+ this._maxTokens = opts.maxTokens ?? (this._serverDefaults ? undefined : 512);
42
67
  }
43
68
 
44
69
  /** Provider-reported usage for the last call — see {@link BridgeCallInfo}. */
@@ -47,142 +72,88 @@ export class OpenAIBridge implements TransformerBridge {
47
72
  }
48
73
 
49
74
  async generate(prompt: string, opts: BridgeGenerateOptions = {}): Promise<string> {
50
- const body = this._buildBody(prompt, opts, false);
51
- const res = await this._fetch(body);
52
-
53
- if (!res.ok) {
54
- const text = await res.text().catch(() => '');
55
- throw new SSMError(
56
- 'BRIDGE_REQUEST_FAILED',
57
- `OpenAI API returned ${res.status}: ${text}`,
58
- );
75
+ let result;
76
+ try {
77
+ result = await chatComplete(this._client, this._request(prompt, opts));
78
+ } catch (err) {
79
+ throw this._requestError('OpenAI API', err);
59
80
  }
60
-
61
- const json = await res.json() as Record<string, unknown>;
62
- const content = (json as any).choices?.[0]?.message?.content;
63
- if (typeof content !== 'string') {
81
+ if (!result.hasTextContent) {
64
82
  throw new SSMError('BRIDGE_RESPONSE_INVALID', 'Unexpected OpenAI response shape.');
65
83
  }
66
84
  this._lastCall = {
67
- usage: readOpenAIUsage((json as any).usage, (json as any).model ?? opts.model ?? this._model),
85
+ usage: readOpenAIUsage(result.usage, result.model ?? opts.model ?? this._model ?? ''),
68
86
  };
69
- return content;
87
+ return result.content;
70
88
  }
71
89
 
72
90
  async *stream(prompt: string, opts: BridgeGenerateOptions = {}): AsyncIterable<string> {
73
- const body = this._buildBody(prompt, opts, true);
74
- const res = await this._fetch(body);
75
-
76
- if (!res.ok) {
77
- const text = await res.text().catch(() => '');
78
- throw new SSMError(
79
- 'BRIDGE_REQUEST_FAILED',
80
- `OpenAI streaming API returned ${res.status}: ${text}`,
81
- );
91
+ let events;
92
+ try {
93
+ events = chatStream(this._client, { ...this._request(prompt, opts), includeUsage: true });
94
+ // The request is sent on the first pull; surface a failed request as a bridge error.
95
+ const first = await events.next();
96
+ if (first.done) return;
97
+ yield* this._text(first.value);
98
+ } catch (err) {
99
+ throw this._requestError('OpenAI streaming API', err);
82
100
  }
101
+ for await (const event of events) yield* this._text(event);
102
+ }
83
103
 
84
- if (!res.body) {
85
- throw new SSMError('BRIDGE_RESPONSE_INVALID', 'OpenAI streaming response has no body.');
104
+ /** Text deltas out; the closing event records usage (sent because of `includeUsage`). */
105
+ private *_text(event: ChatStreamEvent): Generator<string> {
106
+ if (event.type === 'text-delta') yield event.delta;
107
+ else if (event.type === 'done') {
108
+ const usage = readOpenAIUsage(event.result.usage, event.result.model ?? this._model ?? '');
109
+ this._lastCall = usage ? { usage } : {};
86
110
  }
87
-
88
- const model = opts.model ?? this._model;
89
- let usage: LlmUsage | undefined;
90
-
91
- yield* parseOpenAIStream(res.body, (event) => {
92
- // Only emitted because `_buildBody` sets `stream_options.include_usage`;
93
- // without it OpenAI streams no usage at all and cost would be a guess.
94
- const reported = readOpenAIUsage((event as any).usage, (event as any).model ?? model);
95
- if (reported) usage = reported;
96
- });
97
-
98
- this._lastCall = usage ? { usage } : {};
99
111
  }
100
112
 
101
- private _buildBody(prompt: string, opts: BridgeGenerateOptions, stream: boolean): string {
113
+ private _request(prompt: string, opts: BridgeGenerateOptions): ChatRequest {
102
114
  const sys = opts.systemPrompt ?? this._systemPrompt;
103
- const messages: { role: string; content: string }[] = [];
115
+ const messages: ChatMessage[] = [];
104
116
  if (sys) messages.push({ role: 'system', content: sys });
105
117
  messages.push({ role: 'user', content: prompt });
106
-
107
- return JSON.stringify({
108
- model : opts.model ?? this._model,
118
+ const d = !this._serverDefaults;
119
+ const model = opts.model ?? this._model;
120
+ const maxTokens = opts.maxTokens ?? this._maxTokens;
121
+ const temperature = opts.temperature ?? (d ? 0.7 : undefined);
122
+ const topP = opts.topP ?? (d ? 0.9 : undefined);
123
+ return {
109
124
  messages,
110
- max_tokens : opts.maxTokens ?? this._maxTokens,
111
- temperature: opts.temperature ?? 0.7,
112
- top_p : opts.topP ?? 0.9,
113
- stream,
114
- // Without this the streaming response carries no usage block, so
115
- // cost-per-request on streamed calls would silently fall back to an
116
- // estimate. Non-streaming requests reject the field, hence the guard.
117
- ...(stream ? { stream_options: { include_usage: true } } : {}),
118
- });
119
- }
120
-
121
- private _fetch(body: string): Promise<Response> {
122
- return fetch(`${this._baseUrl}/chat/completions`, {
123
- method : 'POST',
124
- headers: {
125
- 'Content-Type' : 'application/json',
126
- 'Authorization': `Bearer ${this._apiKey}`,
127
- },
128
- body,
129
- });
125
+ ...(model ? { model } : {}),
126
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
127
+ ...(temperature !== undefined ? { temperature } : {}),
128
+ ...(topP !== undefined ? { topP } : {}),
129
+ ...(opts.extra ? { extra: opts.extra } : {}),
130
+ };
130
131
  }
131
- }
132
-
133
- // ── SSE parser ────────────────────────────────────────────────────────────────
134
-
135
- async function* parseOpenAIStream(
136
- body: ReadableStream<Uint8Array>,
137
- onEvent?: (event: Record<string, unknown>) => void,
138
- ): AsyncIterable<string> {
139
- const reader = body.getReader();
140
- const decoder = new TextDecoder();
141
- let buffer = '';
142
-
143
- try {
144
- while (true) {
145
- const { done, value } = await reader.read();
146
- if (done) break;
147
-
148
- buffer += decoder.decode(value, { stream: true });
149
- const lines = buffer.split('\n');
150
- buffer = lines.pop() as string; // keep the last (possibly partial) line; split() always yields ≥1 element
151
-
152
- for (const line of lines) {
153
- const trimmed = line.trim();
154
- if (!trimmed.startsWith('data: ')) continue;
155
-
156
- const data = trimmed.slice(6);
157
- if (data === '[DONE]') return;
158
132
 
159
- try {
160
- const chunk = JSON.parse(data) as Record<string, unknown>;
161
- onEvent?.(chunk);
162
- const delta = (chunk as any).choices?.[0]?.delta?.content;
163
- if (typeof delta === 'string' && delta.length > 0) yield delta;
164
- } catch {
165
- // Malformed JSON in stream — skip silently
166
- }
133
+ private _requestError(what: string, err: unknown): SSMError {
134
+ if (err instanceof SSMError) return err;
135
+ if (err instanceof ChatCompletionError) {
136
+ // A 2xx that still failed is a stream with no body: the server answered, badly.
137
+ if (err.status >= 200 && err.status < 300) {
138
+ return new SSMError('BRIDGE_RESPONSE_INVALID', `${what} returned no response body.`);
167
139
  }
140
+ return new SSMError('BRIDGE_REQUEST_FAILED', `${what} returned ${err.status}: ${err.body}`);
168
141
  }
169
- } finally {
170
- reader.releaseLock();
142
+ return new SSMError('BRIDGE_REQUEST_FAILED', `${what} request failed: ${err instanceof Error ? err.message : String(err)}`, err);
171
143
  }
172
144
  }
173
145
 
174
146
  /** Maps an OpenAI `usage` object onto the canonical {@link LlmUsage} shape. */
175
147
  function readOpenAIUsage(usage: unknown, model: string): LlmUsage | undefined {
176
148
  if (!usage || typeof usage !== 'object') return undefined;
177
- const u = usage as Record<string, unknown>;
178
- const cached = Number((u['prompt_tokens_details'] as Record<string, unknown> | undefined)?.['cached_tokens']) || 0;
179
- const prompt = Number(u['prompt_tokens']) || 0;
149
+ const u = readUsageFields(usage);
150
+ const cached = u.cacheReadTokens ?? 0;
180
151
  return {
181
152
  model,
182
153
  // OpenAI reports `prompt_tokens` INCLUSIVE of cached tokens; the canonical
183
154
  // shape keeps them disjoint so the two rates are applied exactly once each.
184
- inputTokens: Math.max(0, prompt - cached),
185
- outputTokens: Number(u['completion_tokens']) || 0,
155
+ inputTokens: Math.max(0, (u.promptTokens ?? 0) - cached),
156
+ outputTokens: u.completionTokens ?? 0,
186
157
  cachedInputTokens: cached,
187
158
  };
188
159
  }
@@ -22,6 +22,12 @@ export interface BridgeGenerateOptions {
22
22
  systemPrompt? : string;
23
23
  /** Model string, overriding the adapter's default. */
24
24
  model? : string;
25
+ /**
26
+ * Vendor extras merged into an OpenAI-compatible request body (a gateway's
27
+ * routing `provider`, `response_format`, …). Bridges that do not speak that
28
+ * wire format ignore it.
29
+ */
30
+ extra? : Record<string, unknown>;
25
31
  }
26
32
 
27
33
  export interface TransformerBridge {
@@ -25,6 +25,7 @@
25
25
  import { SSMError } from '../errors/SSMError.js';
26
26
  import type { LlmUsage } from '../telemetry/types.js';
27
27
  import type { TransformerBridge, BridgeGenerateOptions, BridgeCallInfo } from './TransformerBridge.js';
28
+ import { readSseDataFrames } from '../wire/index.js';
28
29
 
29
30
  export interface VertexAIBridgeOptions {
30
31
  /** GCP project id. */
@@ -101,7 +102,8 @@ export class VertexAIBridge implements TransformerBridge {
101
102
  }
102
103
 
103
104
  let usage: LlmUsage | undefined;
104
- for await (const event of parseSseJson(res.body)) {
105
+ for await (const frame of readSseDataFrames(res.body)) {
106
+ const event = frame as Record<string, unknown>;
105
107
  const text = readCandidateText(event);
106
108
  if (text) yield text;
107
109
  // Vertex repeats cumulative usage on each chunk; the last one wins.
@@ -264,34 +266,3 @@ function readVertexUsage(usage: unknown, model: string): LlmUsage | undefined {
264
266
  cachedInputTokens: cached,
265
267
  };
266
268
  }
267
-
268
- /** Parses an `alt=sse` stream into JSON events. */
269
- async function* parseSseJson(body: ReadableStream<Uint8Array>): AsyncIterable<Record<string, unknown>> {
270
- const reader = body.getReader();
271
- const decoder = new TextDecoder();
272
- let buffer = '';
273
-
274
- try {
275
- while (true) {
276
- const { done, value } = await reader.read();
277
- if (done) break;
278
- buffer += decoder.decode(value, { stream: true });
279
-
280
- const lines = buffer.split('\n');
281
- buffer = lines.pop() as string;
282
- for (const line of lines) {
283
- const trimmed = line.trim();
284
- if (!trimmed.startsWith('data:')) continue;
285
- const payload = trimmed.slice(5).trim();
286
- if (!payload || payload === '[DONE]') continue;
287
- try {
288
- yield JSON.parse(payload) as Record<string, unknown>;
289
- } catch {
290
- // Skip malformed SSE frames rather than failing the stream.
291
- }
292
- }
293
- }
294
- } finally {
295
- reader.releaseLock();
296
- }
297
- }