@seanhogg/builderforce-memory 2026.9.30 → 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.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The OpenAI-compatible chat-completions client — ONE request builder, error mapping
3
+ * and stream reader for every caller that speaks `/chat/completions`: this package's
4
+ * `OpenAIBridge` / `FetchBridge`, and any host that reaches models through an
5
+ * OpenAI-compatible gateway (tools, streamed tool-call deltas, vendor extras).
6
+ *
7
+ * The body carries exactly what the request states — no sampling defaults are added
8
+ * here, so a routing gateway that picks the model and limits can be called with none.
9
+ * Callers that want defaults (the bridges) state them.
10
+ *
11
+ * Zero-dependency and engine-free (part of the `/wire` subpath).
12
+ */
13
+
14
+ import { readSseDataFrames } from './sse.js';
15
+
16
+ /** An OpenAI-compatible chat message. */
17
+ export interface ChatMessage {
18
+ role: 'system' | 'user' | 'assistant' | 'tool';
19
+ /** Text, or an OpenAI multi-part array (text + `image_url`, …). */
20
+ content?: string | null | unknown[];
21
+ tool_calls?: unknown;
22
+ tool_call_id?: string;
23
+ [k: string]: unknown;
24
+ }
25
+
26
+ /** An OpenAI-compatible function-tool schema. */
27
+ export interface ChatToolSchema {
28
+ type: 'function';
29
+ function: { name: string; description: string; parameters: Record<string, unknown> };
30
+ }
31
+
32
+ export interface ChatRequest {
33
+ messages: ChatMessage[];
34
+ tools?: ChatToolSchema[];
35
+ model?: string;
36
+ maxTokens?: number;
37
+ temperature?: number;
38
+ topP?: number;
39
+ /**
40
+ * On a stream, ask for the closing usage block (`stream_options.include_usage`).
41
+ * Opt-in: not every OpenAI-compatible server accepts the field.
42
+ */
43
+ includeUsage?: boolean;
44
+ /** Vendor extras merged into the body last (a routing `provider`, `response_format`, …). */
45
+ extra?: Record<string, unknown>;
46
+ }
47
+
48
+ export interface ChatToolCall {
49
+ id?: string;
50
+ type?: string;
51
+ function?: { name?: string; arguments?: string };
52
+ }
53
+
54
+ export interface ChatResult {
55
+ /** The reply text ('' when there was none — e.g. a tool-call-only turn). */
56
+ content: string;
57
+ /** True when the server sent text content (a string), false when it sent none. */
58
+ hasTextContent: boolean;
59
+ toolCalls: ChatToolCall[];
60
+ finishReason?: string;
61
+ /** The model the server reports having used. */
62
+ model?: string;
63
+ /** The raw `usage` object, when the server sent one — read it with `readUsageFields`. */
64
+ usage?: unknown;
65
+ }
66
+
67
+ /** Incremental events of a streamed completion. */
68
+ export type ChatStreamEvent =
69
+ | { type: 'text-delta'; delta: string }
70
+ | { type: 'tool-call'; index: number; id?: string; name?: string; argsDelta?: string }
71
+ | { type: 'done'; result: ChatResult };
72
+
73
+ export interface ChatClient {
74
+ /** The OpenAI-style base, e.g. `https://api.openai.com/v1`; `/chat/completions` is appended. */
75
+ baseUrl: string;
76
+ apiKey: string;
77
+ /** Abort a request after this long. Default: no limit beyond the caller's signal. */
78
+ timeoutMs?: number;
79
+ }
80
+
81
+ /** A non-2xx answer, with the status and (bounded) body kept apart for the caller's message. */
82
+ export class ChatCompletionError extends Error {
83
+ constructor(readonly status: number, readonly body: string) {
84
+ super(`chat completion failed with HTTP ${status}: ${body}`);
85
+ this.name = 'ChatCompletionError';
86
+ }
87
+ }
88
+
89
+ const ERROR_BODY_CHARS = 300;
90
+
91
+ function chatBody(req: ChatRequest, stream: boolean): string {
92
+ return JSON.stringify({
93
+ ...(req.model ? { model: req.model } : {}),
94
+ messages: req.messages,
95
+ ...(req.tools?.length ? { tools: req.tools, tool_choice: 'auto' } : {}),
96
+ ...(typeof req.maxTokens === 'number' ? { max_tokens: req.maxTokens } : {}),
97
+ ...(typeof req.temperature === 'number' ? { temperature: req.temperature } : {}),
98
+ ...(typeof req.topP === 'number' ? { top_p: req.topP } : {}),
99
+ ...(stream ? { stream: true } : {}),
100
+ // Without include_usage a streamed response carries no usage block at all.
101
+ ...(stream && req.includeUsage ? { stream_options: { include_usage: true } } : {}),
102
+ ...(req.extra ?? {}),
103
+ });
104
+ }
105
+
106
+ async function post(client: ChatClient, req: ChatRequest, stream: boolean, signal?: AbortSignal): Promise<Response> {
107
+ const timeout = client.timeoutMs ? AbortSignal.timeout(client.timeoutMs) : undefined;
108
+ const res = await fetch(`${client.baseUrl.replace(/\/$/, '')}/chat/completions`, {
109
+ method: 'POST',
110
+ headers: {
111
+ 'Content-Type': 'application/json',
112
+ Authorization: `Bearer ${client.apiKey}`,
113
+ ...(stream ? { Accept: 'text/event-stream' } : {}),
114
+ },
115
+ body: chatBody(req, stream),
116
+ signal: signal && timeout ? AbortSignal.any([signal, timeout]) : (signal ?? timeout),
117
+ });
118
+ if (!res.ok || (stream && !res.body)) {
119
+ const body = await res.text().catch(() => '');
120
+ throw new ChatCompletionError(res.status, body.slice(0, ERROR_BODY_CHARS));
121
+ }
122
+ return res;
123
+ }
124
+
125
+ /** One non-streamed completion. Throws {@link ChatCompletionError} on a non-2xx answer. */
126
+ export async function chatComplete(client: ChatClient, req: ChatRequest, signal?: AbortSignal): Promise<ChatResult> {
127
+ const res = await post(client, req, false, signal);
128
+ const json = (await res.json().catch(() => null)) as {
129
+ model?: unknown;
130
+ usage?: unknown;
131
+ choices?: Array<{ message?: { content?: unknown; tool_calls?: unknown }; finish_reason?: string }>;
132
+ } | null;
133
+ const choice = json?.choices?.[0];
134
+ const msg = choice?.message;
135
+ return {
136
+ content: typeof msg?.content === 'string' ? msg.content : '',
137
+ hasTextContent: typeof msg?.content === 'string',
138
+ toolCalls: Array.isArray(msg?.tool_calls) ? (msg.tool_calls as ChatToolCall[]) : [],
139
+ finishReason: choice?.finish_reason,
140
+ ...(typeof json?.model === 'string' ? { model: json.model } : {}),
141
+ ...(json?.usage !== undefined ? { usage: json.usage } : {}),
142
+ };
143
+ }
144
+
145
+ /**
146
+ * A streamed completion as events: text and tool-call deltas as they arrive, then one
147
+ * `done` carrying the assembled result (tool-call arguments concatenated per index).
148
+ */
149
+ export async function* chatStream(client: ChatClient, req: ChatRequest, signal?: AbortSignal): AsyncGenerator<ChatStreamEvent> {
150
+ const res = await post(client, req, true, signal);
151
+ let content = '';
152
+ let finishReason: string | undefined;
153
+ let model: string | undefined;
154
+ let usage: unknown;
155
+ const calls: Array<{ id?: string; name?: string; args: string }> = [];
156
+
157
+ for await (const frame of readSseDataFrames(res.body!)) {
158
+ const chunk = frame as {
159
+ model?: unknown;
160
+ usage?: unknown;
161
+ choices?: Array<{
162
+ delta?: { content?: unknown; tool_calls?: Array<{ index?: number; id?: string; function?: { name?: string; arguments?: string } }> };
163
+ finish_reason?: string;
164
+ }>;
165
+ };
166
+ if (typeof chunk.model === 'string') model = chunk.model;
167
+ if (chunk.usage) usage = chunk.usage;
168
+ const choice = chunk.choices?.[0];
169
+ if (!choice) continue;
170
+ if (choice.finish_reason) finishReason = choice.finish_reason;
171
+ const delta = choice.delta;
172
+ if (typeof delta?.content === 'string' && delta.content) {
173
+ content += delta.content;
174
+ yield { type: 'text-delta', delta: delta.content };
175
+ }
176
+ for (const tc of Array.isArray(delta?.tool_calls) ? delta.tool_calls : []) {
177
+ const index = typeof tc.index === 'number' ? tc.index : 0;
178
+ const acc = (calls[index] ??= { args: '' });
179
+ if (tc.id) acc.id = tc.id;
180
+ if (tc.function?.name) acc.name = tc.function.name;
181
+ const argsDelta = tc.function?.arguments;
182
+ if (typeof argsDelta === 'string') acc.args += argsDelta;
183
+ yield { type: 'tool-call', index, id: tc.id, name: tc.function?.name, argsDelta };
184
+ }
185
+ }
186
+
187
+ const result: ChatResult = {
188
+ content,
189
+ hasTextContent: content !== '',
190
+ finishReason,
191
+ toolCalls: calls.filter(Boolean).map((t) => ({ id: t.id, type: 'function', function: { name: t.name, arguments: t.args } })),
192
+ ...(model ? { model } : {}),
193
+ ...(usage !== undefined ? { usage } : {}),
194
+ };
195
+ yield { type: 'done', result };
196
+ }
package/src/wire/index.ts CHANGED
@@ -13,6 +13,8 @@
13
13
  * Zero-dependency and engine-free: the `@seanhogg/builderforce-memory/wire` subpath.
14
14
  */
15
15
 
16
- export { parseSseDataLine, parseSseDataFrames, readSseDataFrames, isSseDoneLine } from './sse.js';
16
+ export { sseDataPayload, parseSseDataLine, parseSseDataFrames, readSseDataPayloads, readSseDataFrames, isSseDoneLine } from './sse.js';
17
17
  export { finiteNumber, readUsageFields, AnthropicStreamUsage } from './usage.js';
18
18
  export type { UsageFields, AnthropicUsageTotals } from './usage.js';
19
+ export { chatComplete, chatStream, ChatCompletionError } from './chatClient.js';
20
+ export type { ChatClient, ChatMessage, ChatToolSchema, ChatRequest, ChatToolCall, ChatResult, ChatStreamEvent } from './chatClient.js';
package/src/wire/sse.ts CHANGED
@@ -2,24 +2,38 @@
2
2
  * Server-Sent-Events `data:` framing.
3
3
  *
4
4
  * Every consumer of an SSE LLM stream does the same line-level dance: trim the line,
5
- * keep only `data:` frames, skip empty payloads, `JSON.parse` the rest and skip
6
- * anything malformed — and stop at the `[DONE]` sentinel, which ends an
7
- * OpenAI-style stream (anything after it is not part of the response).
5
+ * keep only `data:` frames, and stop at the `[DONE]` sentinel, which ends an
6
+ * OpenAI-style stream (anything after it is not part of the response). JSON streams
7
+ * then parse each payload and skip empty or malformed ones.
8
8
  *
9
9
  * The line rule is `slice(5).trim()`, which accepts both `data: {…}` and the
10
10
  * spaceless `data:{…}` some providers emit — `slice(6)` silently drops the latter.
11
11
  *
12
+ * Two levels, one rule: {@link readSseDataPayloads} yields payload TEXT (for callers
13
+ * whose frames are not all JSON, or that parse per wire shape), and
14
+ * {@link readSseDataFrames} yields parsed JSON on top of it.
15
+ *
12
16
  * Pure and defensive: a malformed frame is skipped, never thrown.
13
17
  */
14
18
 
19
+ /** The payload text of a `data:` line, or `undefined` for any other line. */
20
+ export function sseDataPayload(line: string): string | undefined {
21
+ const trimmed = line.trim();
22
+ if (!trimmed.startsWith('data:')) return undefined;
23
+ return trimmed.slice(5).trim();
24
+ }
25
+
26
+ /** True for the `data: [DONE]` line that ends an OpenAI-style stream. */
27
+ export function isSseDoneLine(line: string): boolean {
28
+ return sseDataPayload(line) === '[DONE]';
29
+ }
30
+
15
31
  /**
16
32
  * One SSE line's decoded JSON payload, or `undefined` when the line is not a usable
17
33
  * data frame (not a `data:` line, the `[DONE]` sentinel, empty, or malformed JSON).
18
34
  */
19
35
  export function parseSseDataLine(line: string): unknown | undefined {
20
- const trimmed = line.trim();
21
- if (!trimmed.startsWith('data:')) return undefined;
22
- const data = trimmed.slice(5).trim();
36
+ const data = sseDataPayload(line);
23
37
  if (!data || data === '[DONE]') return undefined;
24
38
  try {
25
39
  return JSON.parse(data);
@@ -28,12 +42,6 @@ export function parseSseDataLine(line: string): unknown | undefined {
28
42
  }
29
43
  }
30
44
 
31
- /** True for the `data: [DONE]` line that ends an OpenAI-style stream. */
32
- export function isSseDoneLine(line: string): boolean {
33
- const trimmed = line.trim();
34
- return trimmed.startsWith('data:') && trimmed.slice(5).trim() === '[DONE]';
35
- }
36
-
37
45
  /** Every parsed `data:` frame of a whole, already-buffered SSE body, up to `[DONE]`. */
38
46
  export function* parseSseDataFrames(raw: string): Generator<unknown> {
39
47
  for (const line of raw.split('\n')) {
@@ -44,11 +52,12 @@ export function* parseSseDataFrames(raw: string): Generator<unknown> {
44
52
  }
45
53
 
46
54
  /**
47
- * Every parsed `data:` frame of a live SSE byte stream, as it arrives, up to `[DONE]`.
48
- * A frame split across chunks is held until its line completes; the reader lock is
49
- * released however iteration ends.
55
+ * Every `data:` payload of a live SSE byte stream, as text, as it arrives, up to
56
+ * `[DONE]`. A line split across chunks is held until its newline arrives; the reader
57
+ * lock is released however iteration ends. A missing body yields nothing.
50
58
  */
51
- export async function* readSseDataFrames(body: ReadableStream<Uint8Array>): AsyncGenerator<unknown> {
59
+ export async function* readSseDataPayloads(body: ReadableStream<Uint8Array> | null | undefined): AsyncGenerator<string> {
60
+ if (!body) return;
52
61
  const reader = body.getReader();
53
62
  const decoder = new TextDecoder();
54
63
  let buffer = '';
@@ -60,15 +69,28 @@ export async function* readSseDataFrames(body: ReadableStream<Uint8Array>): Asyn
60
69
  const lines = buffer.split('\n');
61
70
  buffer = lines.pop() as string; // split() always yields ≥1 element
62
71
  for (const line of lines) {
63
- if (isSseDoneLine(line)) return;
64
- const parsed = parseSseDataLine(line);
65
- if (parsed !== undefined) yield parsed;
72
+ const data = sseDataPayload(line);
73
+ if (data === undefined) continue;
74
+ if (data === '[DONE]') return;
75
+ yield data;
66
76
  }
67
77
  }
68
78
  buffer += decoder.decode();
69
- const tail = parseSseDataLine(buffer);
70
- if (tail !== undefined) yield tail;
79
+ const tail = sseDataPayload(buffer);
80
+ if (tail !== undefined && tail !== '[DONE]') yield tail;
71
81
  } finally {
72
82
  reader.releaseLock();
73
83
  }
74
84
  }
85
+
86
+ /** Every parsed JSON `data:` frame of a live SSE byte stream, up to `[DONE]`. */
87
+ export async function* readSseDataFrames(body: ReadableStream<Uint8Array> | null | undefined): AsyncGenerator<unknown> {
88
+ for await (const data of readSseDataPayloads(body)) {
89
+ if (!data) continue;
90
+ try {
91
+ yield JSON.parse(data);
92
+ } catch {
93
+ // Skip malformed SSE frames rather than failing the stream.
94
+ }
95
+ }
96
+ }