@yeaft/webchat-agent 0.1.702 → 0.1.704
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/package.json +1 -1
- package/unify/engine.js +11 -19
- package/unify/llm/adapter.js +29 -53
- package/unify/llm/anthropic.js +18 -41
- package/unify/llm/openai-responses.js +69 -8
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -74,10 +74,11 @@ const MAX_CONTINUE_TURNS = 3;
|
|
|
74
74
|
* - `toolCalls` on assistant turns (the LLM's function_call requests)
|
|
75
75
|
* - `toolCallId` + `isError` on tool turns (the paired tool_result)
|
|
76
76
|
*
|
|
77
|
-
* Content is
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
77
|
+
* Content is passed through verbatim — never truncated. Debug traces must
|
|
78
|
+
* mirror exactly what we sent to the LLM; a truncated copy is misleading.
|
|
79
|
+
* If the resulting payload is too large for the client debug store the
|
|
80
|
+
* bound is per-loop-count (see `MAX_UNIFY_DEBUG_LOOPS` in
|
|
81
|
+
* `web/stores/chat.js`), not per-payload mutilation here.
|
|
81
82
|
*
|
|
82
83
|
* Pure function — no side effects on the input message.
|
|
83
84
|
*
|
|
@@ -86,22 +87,13 @@ const MAX_CONTINUE_TURNS = 3;
|
|
|
86
87
|
*/
|
|
87
88
|
export function mapDebugMessage(m) {
|
|
88
89
|
const out = { role: m.role };
|
|
89
|
-
out.content =
|
|
90
|
+
out.content = m.content;
|
|
90
91
|
if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
91
|
-
out.toolCalls = m.toolCalls.map(tc => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
input = { __truncated: true, preview: s.slice(0, 10000) };
|
|
97
|
-
}
|
|
98
|
-
} catch {
|
|
99
|
-
// Non-serializable input — fall through with raw reference; the
|
|
100
|
-
// frontend's JSON.stringify will hit the same failure and replace
|
|
101
|
-
// it with a placeholder string.
|
|
102
|
-
}
|
|
103
|
-
return { id: tc.id, name: tc.name, input };
|
|
104
|
-
});
|
|
92
|
+
out.toolCalls = m.toolCalls.map(tc => ({
|
|
93
|
+
id: tc.id,
|
|
94
|
+
name: tc.name,
|
|
95
|
+
input: tc.input,
|
|
96
|
+
}));
|
|
105
97
|
}
|
|
106
98
|
if (m.toolCallId) out.toolCallId = m.toolCallId;
|
|
107
99
|
if (m.isError != null) out.isError = m.isError;
|
package/unify/llm/adapter.js
CHANGED
|
@@ -105,65 +105,20 @@ export class LLMAbortError extends Error {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
// ───
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* task-344 follow-up (N2): cap raw payload size exposed via onRawExchange.
|
|
112
|
-
* Prevents the web debug store from linear-growing when a single turn
|
|
113
|
-
* sends / receives megabytes of content. 256 KiB per field per turn.
|
|
114
|
-
*/
|
|
115
|
-
export const RAW_PAYLOAD_CAP_BYTES = 256 * 1024;
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Truncate a string to at most `cap` bytes (UTF-8). When within cap the
|
|
119
|
-
* original is returned; otherwise a prefix with a trailing
|
|
120
|
-
* `…[truncated, original N bytes]` marker. Non-string inputs pass through.
|
|
121
|
-
*
|
|
122
|
-
* @param {string} s
|
|
123
|
-
* @param {number} [cap=RAW_PAYLOAD_CAP_BYTES]
|
|
124
|
-
* @returns {string}
|
|
125
|
-
*/
|
|
126
|
-
export function capRawString(s, cap = RAW_PAYLOAD_CAP_BYTES) {
|
|
127
|
-
if (typeof s !== 'string') return s;
|
|
128
|
-
const encoder = new TextEncoder();
|
|
129
|
-
const fullBytes = encoder.encode(s);
|
|
130
|
-
if (fullBytes.length <= cap) return s;
|
|
131
|
-
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
132
|
-
const prefix = decoder.decode(fullBytes.slice(0, cap));
|
|
133
|
-
return `${prefix}…[truncated, original ${fullBytes.length} bytes]`;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Cap the `body` field of a rawRequest envelope. JSON-stringifies objects
|
|
138
|
-
* before sizing so an oversized `messages` array does not escape the cap.
|
|
139
|
-
* When truncation fires, body becomes a string (JSON prefix + marker);
|
|
140
|
-
* objects under cap stay objects.
|
|
141
|
-
*
|
|
142
|
-
* @param {{ url: string, method: string, headers: object, body: any }} req
|
|
143
|
-
* @param {number} [cap=RAW_PAYLOAD_CAP_BYTES]
|
|
144
|
-
* @returns {{ url: string, method: string, headers: object, body: any }}
|
|
145
|
-
*/
|
|
146
|
-
export function capRawRequest(req, cap = RAW_PAYLOAD_CAP_BYTES) {
|
|
147
|
-
if (!req || typeof req !== 'object') return req;
|
|
148
|
-
let body = req.body;
|
|
149
|
-
if (body != null && typeof body !== 'string') {
|
|
150
|
-
let serialized;
|
|
151
|
-
try { serialized = JSON.stringify(body); }
|
|
152
|
-
catch { serialized = String(body); }
|
|
153
|
-
if (typeof serialized === 'string' && new TextEncoder().encode(serialized).length > cap) {
|
|
154
|
-
body = capRawString(serialized, cap);
|
|
155
|
-
}
|
|
156
|
-
} else if (typeof body === 'string') {
|
|
157
|
-
body = capRawString(body, cap);
|
|
158
|
-
}
|
|
159
|
-
return { url: req.url, method: req.method, headers: req.headers, body };
|
|
160
|
-
}
|
|
108
|
+
// ─── Raw payload redaction helper ──────────────────────────────
|
|
161
109
|
|
|
162
110
|
/**
|
|
163
111
|
* Redact sensitive headers (API keys / bearer tokens) from a raw request
|
|
164
112
|
* shape before exposing it to debug UI. Always returns a NEW object — never
|
|
165
113
|
* mutates the input.
|
|
166
114
|
*
|
|
115
|
+
* NOTE: there is intentionally NO body / response truncation here. The whole
|
|
116
|
+
* point of the "copy request" debug feature is to capture EXACTLY what we
|
|
117
|
+
* sent to the LLM. A truncated copy is worse than useless — it lies about
|
|
118
|
+
* what the model saw. If the resulting payload is too large for the debug
|
|
119
|
+
* store, the fix is to bound retention (drop oldest turns), not to mutilate
|
|
120
|
+
* individual payloads. See `MAX_UNIFY_DEBUG_LOOPS` in `web/stores/chat.js`.
|
|
121
|
+
*
|
|
167
122
|
* @param {{ url: string, method: string, headers: object, body: any }} req
|
|
168
123
|
* @returns {{ url: string, method: string, headers: object, body: any }}
|
|
169
124
|
*/
|
|
@@ -180,6 +135,27 @@ export function redactRawRequest(req) {
|
|
|
180
135
|
return { url: req.url, method: req.method, headers, body: req.body };
|
|
181
136
|
}
|
|
182
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Snapshot a Fetch Response's headers into a plain object for the debug
|
|
140
|
+
* panel. Defensive against polyfilled / mocked Response shapes that don't
|
|
141
|
+
* implement `Headers#entries()` — falls back to `{}` rather than throwing.
|
|
142
|
+
*
|
|
143
|
+
* NOTE: multi-valued headers (e.g. `Set-Cookie`) collapse to the last value
|
|
144
|
+
* because `Object.fromEntries` can't represent duplicates. For LLM debug
|
|
145
|
+
* traffic this is fine; if a future use case needs multi-valued capture,
|
|
146
|
+
* switch the return to an array of [k, v] pairs.
|
|
147
|
+
*
|
|
148
|
+
* @param {Response | { headers?: { entries?: () => Iterable<[string, string]> } }} response
|
|
149
|
+
* @returns {Record<string, string>}
|
|
150
|
+
*/
|
|
151
|
+
export function safeHeaders(response) {
|
|
152
|
+
const h = response && response.headers;
|
|
153
|
+
if (h && typeof h.entries === 'function') {
|
|
154
|
+
return Object.fromEntries(h.entries());
|
|
155
|
+
}
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
|
|
183
159
|
// ─── Base Class ────────────────────────────────────────────────
|
|
184
160
|
|
|
185
161
|
/**
|
package/unify/llm/anthropic.js
CHANGED
|
@@ -14,9 +14,7 @@ import {
|
|
|
14
14
|
LLMServerError,
|
|
15
15
|
LLMAbortError,
|
|
16
16
|
redactRawRequest,
|
|
17
|
-
|
|
18
|
-
capRawString,
|
|
19
|
-
RAW_PAYLOAD_CAP_BYTES,
|
|
17
|
+
safeHeaders,
|
|
20
18
|
} from './adapter.js';
|
|
21
19
|
import {
|
|
22
20
|
normalizeEffort,
|
|
@@ -184,9 +182,10 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
184
182
|
'anthropic-version': API_VERSION,
|
|
185
183
|
};
|
|
186
184
|
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
185
|
+
// Expose the raw request (auth-redacted) for the debug panel. The body
|
|
186
|
+
// is captured verbatim — never truncated — so "copy request" matches
|
|
187
|
+
// exactly what we POST to the LLM.
|
|
188
|
+
const rawRequest = redactRawRequest({ url, method: 'POST', headers, body });
|
|
190
189
|
|
|
191
190
|
const response = await fetch(url, {
|
|
192
191
|
method: 'POST',
|
|
@@ -197,18 +196,15 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
197
196
|
|
|
198
197
|
if (!response.ok) {
|
|
199
198
|
const errorBody = await response.text();
|
|
200
|
-
//
|
|
199
|
+
// Capture error response too, then throw.
|
|
201
200
|
if (onRawExchange) {
|
|
202
201
|
try {
|
|
203
202
|
onRawExchange({
|
|
204
203
|
rawRequest,
|
|
205
204
|
rawResponse: {
|
|
206
205
|
status: response.status,
|
|
207
|
-
headers: response
|
|
208
|
-
|
|
209
|
-
: {},
|
|
210
|
-
// task-344 follow-up (N2): cap error body.
|
|
211
|
-
body: capRawString(errorBody),
|
|
206
|
+
headers: safeHeaders(response),
|
|
207
|
+
body: errorBody,
|
|
212
208
|
},
|
|
213
209
|
});
|
|
214
210
|
} catch { /* ignore */ }
|
|
@@ -223,15 +219,12 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
223
219
|
let currentToolCallId = null;
|
|
224
220
|
let currentToolName = null;
|
|
225
221
|
let currentToolInput = '';
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const responseHeaders = response.headers && typeof response.headers.entries === 'function'
|
|
233
|
-
? Object.fromEntries(response.headers.entries())
|
|
234
|
-
: {};
|
|
222
|
+
// Accumulate raw SSE body verbatim for the debug panel. No truncation:
|
|
223
|
+
// see `redactRawRequest` in adapter.js for the verbatim-design rationale.
|
|
224
|
+
// Push-then-join keeps allocation bounded for multi-MiB payloads (avoids
|
|
225
|
+
// O(n²) string concat).
|
|
226
|
+
const rawSseBodyChunks = [];
|
|
227
|
+
const responseHeaders = safeHeaders(response);
|
|
235
228
|
const responseStatus = response.status;
|
|
236
229
|
|
|
237
230
|
try {
|
|
@@ -241,20 +234,7 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
241
234
|
|
|
242
235
|
const chunkText = decoder.decode(value, { stream: true });
|
|
243
236
|
buffer += chunkText;
|
|
244
|
-
|
|
245
|
-
rawSseTotalBytes += value.byteLength;
|
|
246
|
-
if (!rawSseCapped) {
|
|
247
|
-
if (rawSseTotalBytes <= RAW_PAYLOAD_CAP_BYTES) {
|
|
248
|
-
rawSseBody += chunkText;
|
|
249
|
-
} else {
|
|
250
|
-
// Append enough of this chunk to meet the cap, then freeze.
|
|
251
|
-
const remaining = RAW_PAYLOAD_CAP_BYTES - (rawSseTotalBytes - value.byteLength);
|
|
252
|
-
if (remaining > 0) {
|
|
253
|
-
rawSseBody += chunkText.slice(0, remaining);
|
|
254
|
-
}
|
|
255
|
-
rawSseCapped = true;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
237
|
+
rawSseBodyChunks.push(chunkText);
|
|
258
238
|
const lines = buffer.split('\n');
|
|
259
239
|
buffer = lines.pop() || ''; // Keep incomplete line
|
|
260
240
|
|
|
@@ -344,19 +324,16 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
344
324
|
}
|
|
345
325
|
} finally {
|
|
346
326
|
reader.releaseLock();
|
|
347
|
-
//
|
|
327
|
+
// Emit raw exchange after stream completes (or errors). Body is the
|
|
328
|
+
// verbatim SSE — never truncated.
|
|
348
329
|
if (onRawExchange) {
|
|
349
330
|
try {
|
|
350
|
-
// task-344 follow-up (N2): append truncation marker when capped.
|
|
351
|
-
const finalBody = rawSseCapped
|
|
352
|
-
? `${rawSseBody}…[truncated, original ${rawSseTotalBytes} bytes]`
|
|
353
|
-
: rawSseBody;
|
|
354
331
|
onRawExchange({
|
|
355
332
|
rawRequest,
|
|
356
333
|
rawResponse: {
|
|
357
334
|
status: responseStatus,
|
|
358
335
|
headers: responseHeaders,
|
|
359
|
-
body:
|
|
336
|
+
body: rawSseBodyChunks.join(''),
|
|
360
337
|
format: 'sse',
|
|
361
338
|
},
|
|
362
339
|
});
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
LLMContextError,
|
|
33
33
|
LLMServerError,
|
|
34
34
|
LLMAbortError,
|
|
35
|
+
redactRawRequest,
|
|
36
|
+
safeHeaders,
|
|
35
37
|
} from './adapter.js';
|
|
36
38
|
import {
|
|
37
39
|
normalizeEffort,
|
|
@@ -225,9 +227,16 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
225
227
|
// ─── Streaming ──────────────────────────────────────────
|
|
226
228
|
|
|
227
229
|
/**
|
|
228
|
-
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal }} params
|
|
230
|
+
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void }} params
|
|
231
|
+
*
|
|
232
|
+
* NOTE on `extraBody`: any keys you spread here are merged verbatim into
|
|
233
|
+
* the wire body and — because the verbatim debug feature is intentionally
|
|
234
|
+
* non-truncating — will surface in the debug panel via `rawRequest.body`.
|
|
235
|
+
* Do NOT put secrets in `extraBody`. Only `Authorization` / `x-api-key` /
|
|
236
|
+
* `api-key` headers are auto-redacted (see `redactRawRequest` in
|
|
237
|
+
* `adapter.js`); request-body fields are caller-controlled.
|
|
229
238
|
*/
|
|
230
|
-
async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal }) {
|
|
239
|
+
async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal, onRawExchange }) {
|
|
231
240
|
if (signal?.aborted) throw new LLMAbortError();
|
|
232
241
|
|
|
233
242
|
const body = {
|
|
@@ -255,14 +264,21 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
255
264
|
|
|
256
265
|
if (extraBody) Object.assign(body, extraBody);
|
|
257
266
|
|
|
267
|
+
const url = `${this.#baseUrl}/responses`;
|
|
268
|
+
const headers = {
|
|
269
|
+
'Content-Type': 'application/json',
|
|
270
|
+
'Authorization': `Bearer ${this.#apiKey}`,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Expose the raw request (auth-redacted) for the debug panel. See
|
|
274
|
+
// `redactRawRequest` in adapter.js for the verbatim-design rationale.
|
|
275
|
+
const rawRequest = redactRawRequest({ url, method: 'POST', headers, body });
|
|
276
|
+
|
|
258
277
|
let response;
|
|
259
278
|
try {
|
|
260
|
-
response = await fetch(
|
|
279
|
+
response = await fetch(url, {
|
|
261
280
|
method: 'POST',
|
|
262
|
-
headers
|
|
263
|
-
'Content-Type': 'application/json',
|
|
264
|
-
'Authorization': `Bearer ${this.#apiKey}`,
|
|
265
|
-
},
|
|
281
|
+
headers,
|
|
266
282
|
body: JSON.stringify(body),
|
|
267
283
|
signal,
|
|
268
284
|
});
|
|
@@ -273,6 +289,19 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
273
289
|
|
|
274
290
|
if (!response.ok) {
|
|
275
291
|
const errorBody = await response.text();
|
|
292
|
+
// Capture error response too, then throw. Parity with anthropic.js.
|
|
293
|
+
if (onRawExchange) {
|
|
294
|
+
try {
|
|
295
|
+
onRawExchange({
|
|
296
|
+
rawRequest,
|
|
297
|
+
rawResponse: {
|
|
298
|
+
status: response.status,
|
|
299
|
+
headers: safeHeaders(response),
|
|
300
|
+
body: errorBody,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
} catch { /* ignore */ }
|
|
304
|
+
}
|
|
276
305
|
throw this.#classifyError(response.status, errorBody);
|
|
277
306
|
}
|
|
278
307
|
|
|
@@ -287,11 +316,21 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
287
316
|
const emittedToolCallIds = new Set();
|
|
288
317
|
let sawToolCall = false;
|
|
289
318
|
|
|
319
|
+
// Accumulate raw SSE body verbatim for the debug panel. No truncation:
|
|
320
|
+
// see `redactRawRequest` in adapter.js for the verbatim-design rationale.
|
|
321
|
+
// Push-then-join keeps allocation bounded for multi-MiB payloads (avoids
|
|
322
|
+
// O(n²) string concat).
|
|
323
|
+
const rawSseBodyChunks = [];
|
|
324
|
+
const responseHeaders = safeHeaders(response);
|
|
325
|
+
const responseStatus = response.status;
|
|
326
|
+
|
|
290
327
|
try {
|
|
291
328
|
while (true) {
|
|
292
329
|
const { done, value } = await reader.read();
|
|
293
330
|
if (done) break;
|
|
294
|
-
|
|
331
|
+
const chunkText = decoder.decode(value, { stream: true });
|
|
332
|
+
buffer += chunkText;
|
|
333
|
+
rawSseBodyChunks.push(chunkText);
|
|
295
334
|
|
|
296
335
|
// SSE events are separated by blank lines; split on \n
|
|
297
336
|
const lines = buffer.split('\n');
|
|
@@ -409,11 +448,33 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
409
448
|
throw err;
|
|
410
449
|
} finally {
|
|
411
450
|
try { reader.releaseLock(); } catch { /* noop */ }
|
|
451
|
+
// Emit raw exchange after stream completes (or errors). Body is the
|
|
452
|
+
// verbatim SSE — never truncated. Parity with anthropic.js.
|
|
453
|
+
if (onRawExchange) {
|
|
454
|
+
try {
|
|
455
|
+
onRawExchange({
|
|
456
|
+
rawRequest,
|
|
457
|
+
rawResponse: {
|
|
458
|
+
status: responseStatus,
|
|
459
|
+
headers: responseHeaders,
|
|
460
|
+
body: rawSseBodyChunks.join(''),
|
|
461
|
+
format: 'sse',
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
} catch { /* ignore */ }
|
|
465
|
+
}
|
|
412
466
|
}
|
|
413
467
|
}
|
|
414
468
|
|
|
415
469
|
// ─── Non-streaming call() ───────────────────────────────
|
|
416
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Side-query (consolidate / dream / recall / light) entry point. Does
|
|
473
|
+
* NOT accept `onRawExchange` — these calls intentionally don't surface
|
|
474
|
+
* in the user-facing debug panel. If a future product change wants to
|
|
475
|
+
* expose them, mirror the stream() instrumentation. Parity with
|
|
476
|
+
* anthropic.js's `call()`.
|
|
477
|
+
*/
|
|
417
478
|
async call({ model, system, messages, maxTokens = 4096, effort, extraBody, signal }) {
|
|
418
479
|
if (signal?.aborted) throw new LLMAbortError();
|
|
419
480
|
|