@loreai/gateway 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.cjs +27 -0
- package/dist/index.cjs +1058 -0
- package/dist/index.d.cts +21 -0
- package/package.json +10 -10
- package/dist/index.js +0 -50087
- package/src/auth.ts +0 -133
- package/src/batch-queue.ts +0 -575
- package/src/cache-analytics.ts +0 -344
- package/src/cli/agents.ts +0 -107
- package/src/cli/bin.ts +0 -11
- package/src/cli/help.ts +0 -55
- package/src/cli/lib/binary.ts +0 -353
- package/src/cli/lib/bspatch.ts +0 -306
- package/src/cli/lib/delta-upgrade.ts +0 -790
- package/src/cli/lib/errors.ts +0 -48
- package/src/cli/lib/ghcr.ts +0 -389
- package/src/cli/lib/patch-cache.ts +0 -342
- package/src/cli/lib/upgrade.ts +0 -454
- package/src/cli/lib/version-check.ts +0 -385
- package/src/cli/main.ts +0 -152
- package/src/cli/run.ts +0 -181
- package/src/cli/start.ts +0 -82
- package/src/cli/upgrade.ts +0 -311
- package/src/cli/version.ts +0 -22
- package/src/compaction.ts +0 -195
- package/src/config.ts +0 -199
- package/src/idle.ts +0 -240
- package/src/index.ts +0 -41
- package/src/llm-adapter.ts +0 -182
- package/src/pipeline.ts +0 -1681
- package/src/recall.ts +0 -433
- package/src/recorder.ts +0 -192
- package/src/server.ts +0 -250
- package/src/session.ts +0 -207
- package/src/stream/anthropic.ts +0 -708
- package/src/temporal-adapter.ts +0 -310
- package/src/translate/anthropic.ts +0 -469
- package/src/translate/openai.ts +0 -536
- package/src/translate/types.ts +0 -222
- package/src/worker-model.ts +0 -408
package/src/recall.ts
DELETED
|
@@ -1,433 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Gateway recall interception — transparent memory search for any client.
|
|
3
|
-
*
|
|
4
|
-
* Uses a unified "Marker and Expand" strategy:
|
|
5
|
-
*
|
|
6
|
-
* 1. **On response (to client):** The recall `tool_use` block is replaced
|
|
7
|
-
* with a human-readable marker text block
|
|
8
|
-
* (`📚 Searching <scope> for "<query>"…`). The recall is executed
|
|
9
|
-
* internally and the result is stored in session state.
|
|
10
|
-
*
|
|
11
|
-
* 2. **On request (from client):** Marker text blocks in the conversation
|
|
12
|
-
* are expanded back into the original `tool_use` + `tool_result` pairs
|
|
13
|
-
* before forwarding upstream.
|
|
14
|
-
*
|
|
15
|
-
* For recall-only responses, a follow-up call is still made internally
|
|
16
|
-
* so the model can continue in the same HTTP response (seamless UX).
|
|
17
|
-
*
|
|
18
|
-
* All recall execution delegates to `runRecall()` from `@loreai/core`.
|
|
19
|
-
*/
|
|
20
|
-
import {
|
|
21
|
-
runRecall,
|
|
22
|
-
RECALL_TOOL_DESCRIPTION,
|
|
23
|
-
RECALL_PARAM_DESCRIPTIONS,
|
|
24
|
-
log,
|
|
25
|
-
config as loreConfig,
|
|
26
|
-
type RecallScope,
|
|
27
|
-
} from "@loreai/core";
|
|
28
|
-
|
|
29
|
-
import type {
|
|
30
|
-
GatewayTool,
|
|
31
|
-
GatewayRequest,
|
|
32
|
-
GatewayResponse,
|
|
33
|
-
GatewayToolUseBlock,
|
|
34
|
-
GatewayMessage,
|
|
35
|
-
RecallStore,
|
|
36
|
-
} from "./translate/types";
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Tool definition
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
/** Recall tool definition for injection into upstream requests. */
|
|
43
|
-
export const RECALL_GATEWAY_TOOL: GatewayTool = {
|
|
44
|
-
name: "recall",
|
|
45
|
-
description: RECALL_TOOL_DESCRIPTION,
|
|
46
|
-
inputSchema: {
|
|
47
|
-
type: "object",
|
|
48
|
-
properties: {
|
|
49
|
-
query: {
|
|
50
|
-
type: "string",
|
|
51
|
-
description: RECALL_PARAM_DESCRIPTIONS.query,
|
|
52
|
-
},
|
|
53
|
-
scope: {
|
|
54
|
-
type: "string",
|
|
55
|
-
enum: ["all", "session", "project", "knowledge"],
|
|
56
|
-
description: RECALL_PARAM_DESCRIPTIONS.scope,
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
required: ["query"],
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
export const RECALL_TOOL_NAME = "recall";
|
|
64
|
-
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
// Marker utilities — human-readable text ↔ recall tool round-trip
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
|
|
69
|
-
/** Scope → human-readable label for marker text. */
|
|
70
|
-
const SCOPE_LABELS: Record<string, string> = {
|
|
71
|
-
all: "all archives",
|
|
72
|
-
session: "session history",
|
|
73
|
-
project: "project archives",
|
|
74
|
-
knowledge: "knowledge base",
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
/** Reverse: label → scope enum. */
|
|
78
|
-
const LABEL_TO_SCOPE: Record<string, RecallScope> = Object.fromEntries(
|
|
79
|
-
Object.entries(SCOPE_LABELS).map(([k, v]) => [v, k as RecallScope]),
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
/** Map a recall scope to a human-readable label. */
|
|
83
|
-
export function scopeToLabel(scope: string = "all"): string {
|
|
84
|
-
return SCOPE_LABELS[scope] ?? SCOPE_LABELS.all;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Map a human-readable label back to a scope enum value. */
|
|
88
|
-
export function labelToScope(label: string): RecallScope {
|
|
89
|
-
return LABEL_TO_SCOPE[label] ?? "all";
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Build a marker text string for a recall tool call.
|
|
94
|
-
*
|
|
95
|
-
* Format: `📚 Searching <scope-label> for "<query>"…`
|
|
96
|
-
*/
|
|
97
|
-
export function buildRecallMarker(query: string, scope: string = "all"): string {
|
|
98
|
-
return `📚 Searching ${scopeToLabel(scope)} for "${query}"…`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Regex to parse a recall marker back into query + scope. */
|
|
102
|
-
const MARKER_REGEX = /📚 Searching (.+?) for "(.+?)"…/;
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Parse a recall marker text block, returning query and scope if valid.
|
|
106
|
-
* Returns null if the text doesn't match the marker format.
|
|
107
|
-
*/
|
|
108
|
-
export function parseRecallMarker(
|
|
109
|
-
text: string,
|
|
110
|
-
): { query: string; scope: RecallScope } | null {
|
|
111
|
-
const match = MARKER_REGEX.exec(text);
|
|
112
|
-
if (!match) return null;
|
|
113
|
-
return {
|
|
114
|
-
query: match[2],
|
|
115
|
-
scope: labelToScope(match[1]),
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Derive a store key from query + scope. */
|
|
120
|
-
export function recallStoreKey(query: string, scope: string = "all"): string {
|
|
121
|
-
return `${scope}:${query}`;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
// Marker expansion — restore tool_use + tool_result from markers on inbound
|
|
126
|
-
// ---------------------------------------------------------------------------
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Find recall marker text blocks in the conversation and expand them
|
|
130
|
-
* back into tool_use + tool_result pairs for the upstream API.
|
|
131
|
-
*
|
|
132
|
-
* Scans ALL assistant messages (not just the last one) since markers
|
|
133
|
-
* persist across turns until gradient evicts the message.
|
|
134
|
-
*
|
|
135
|
-
* Mutates the request in-place. Returns true if any expansion was performed.
|
|
136
|
-
*/
|
|
137
|
-
export function expandRecallMarkers(
|
|
138
|
-
req: GatewayRequest,
|
|
139
|
-
store: RecallStore,
|
|
140
|
-
): boolean {
|
|
141
|
-
let expanded = false;
|
|
142
|
-
|
|
143
|
-
// Iterate forward; when we splice messages the index is adjusted.
|
|
144
|
-
for (let i = 0; i < req.messages.length; i++) {
|
|
145
|
-
const msg = req.messages[i];
|
|
146
|
-
if (msg.role !== "assistant") continue;
|
|
147
|
-
|
|
148
|
-
// Find the first (should be only) recall marker in this message.
|
|
149
|
-
// We process one marker per assistant message per pass; the outer
|
|
150
|
-
// loop will revisit if there's more than one (rare).
|
|
151
|
-
let markerIdx = -1;
|
|
152
|
-
let parsed: { query: string; scope: RecallScope } | null = null;
|
|
153
|
-
for (let j = 0; j < msg.content.length; j++) {
|
|
154
|
-
const block = msg.content[j];
|
|
155
|
-
if (block.type !== "text") continue;
|
|
156
|
-
parsed = parseRecallMarker(block.text);
|
|
157
|
-
if (parsed) {
|
|
158
|
-
markerIdx = j;
|
|
159
|
-
break;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (markerIdx < 0 || !parsed) continue;
|
|
164
|
-
|
|
165
|
-
const key = recallStoreKey(parsed.query, parsed.scope);
|
|
166
|
-
const stored = store.get(key);
|
|
167
|
-
if (!stored) continue; // No stored result — leave marker as-is
|
|
168
|
-
|
|
169
|
-
// Check if there's non-tool content AFTER the marker in this message.
|
|
170
|
-
// This happens when recall-only follow-up piped continuation content
|
|
171
|
-
// (text blocks) into the same assistant message. Tool_use blocks after
|
|
172
|
-
// the marker are from the same turn (mixed tools) and stay together.
|
|
173
|
-
const afterMarker = msg.content.slice(markerIdx + 1);
|
|
174
|
-
const hasContinuationAfter = afterMarker.length > 0 &&
|
|
175
|
-
afterMarker.some((b) => b.type !== "tool_use");
|
|
176
|
-
|
|
177
|
-
// Replace marker with tool_use
|
|
178
|
-
msg.content[markerIdx] = {
|
|
179
|
-
type: "tool_use",
|
|
180
|
-
id: stored.toolUseId,
|
|
181
|
-
name: RECALL_TOOL_NAME,
|
|
182
|
-
input: stored.input,
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
// Truncate assistant message at the tool_use (remove continuation)
|
|
186
|
-
if (hasContinuationAfter) {
|
|
187
|
-
msg.content.length = markerIdx + 1;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Build synthetic tool_result user message
|
|
191
|
-
const toolResultMsg: GatewayMessage = {
|
|
192
|
-
role: "user",
|
|
193
|
-
content: [
|
|
194
|
-
{
|
|
195
|
-
type: "tool_result",
|
|
196
|
-
toolUseId: stored.toolUseId,
|
|
197
|
-
content: stored.result,
|
|
198
|
-
},
|
|
199
|
-
],
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
if (hasContinuationAfter) {
|
|
203
|
-
// Split: insert tool_result user message + continuation assistant
|
|
204
|
-
// message after the current assistant message.
|
|
205
|
-
const continuationMsg: GatewayMessage = {
|
|
206
|
-
role: "assistant",
|
|
207
|
-
content: afterMarker,
|
|
208
|
-
};
|
|
209
|
-
req.messages.splice(i + 1, 0, toolResultMsg, continuationMsg);
|
|
210
|
-
// Skip past the two newly inserted messages
|
|
211
|
-
i += 2;
|
|
212
|
-
} else {
|
|
213
|
-
// No split needed — insert tool_result into the following user message.
|
|
214
|
-
// Prepend (unshift) so the recall result appears before existing
|
|
215
|
-
// tool_results — matching the tool_use order in the assistant message.
|
|
216
|
-
const nextMsg = req.messages[i + 1];
|
|
217
|
-
if (nextMsg?.role === "user") {
|
|
218
|
-
nextMsg.content.unshift({
|
|
219
|
-
type: "tool_result",
|
|
220
|
-
toolUseId: stored.toolUseId,
|
|
221
|
-
content: stored.result,
|
|
222
|
-
});
|
|
223
|
-
} else {
|
|
224
|
-
// No following user message — insert a synthetic one
|
|
225
|
-
req.messages.splice(i + 1, 0, toolResultMsg);
|
|
226
|
-
i += 1;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
expanded = true;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return expanded;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Clean up orphaned recall store entries whose markers no longer
|
|
238
|
-
* appear in the conversation (e.g. gradient evicted the turn).
|
|
239
|
-
*/
|
|
240
|
-
export function cleanupRecallStore(
|
|
241
|
-
req: GatewayRequest,
|
|
242
|
-
store: RecallStore,
|
|
243
|
-
): void {
|
|
244
|
-
if (store.size === 0) return;
|
|
245
|
-
|
|
246
|
-
// Collect all marker keys still present in assistant messages
|
|
247
|
-
const activeKeys = new Set<string>();
|
|
248
|
-
for (const msg of req.messages) {
|
|
249
|
-
if (msg.role !== "assistant") continue;
|
|
250
|
-
for (const block of msg.content) {
|
|
251
|
-
if (block.type !== "text") continue;
|
|
252
|
-
const parsed = parseRecallMarker(block.text);
|
|
253
|
-
if (parsed) {
|
|
254
|
-
activeKeys.add(recallStoreKey(parsed.query, parsed.scope));
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Remove entries not referenced by any current marker
|
|
260
|
-
for (const key of store.keys()) {
|
|
261
|
-
if (!activeKeys.has(key)) {
|
|
262
|
-
store.delete(key);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// ---------------------------------------------------------------------------
|
|
268
|
-
// Detection helpers
|
|
269
|
-
// ---------------------------------------------------------------------------
|
|
270
|
-
|
|
271
|
-
/** Find the recall tool_use block in a GatewayResponse, if any. */
|
|
272
|
-
export function findRecallToolUse(
|
|
273
|
-
resp: GatewayResponse,
|
|
274
|
-
): GatewayToolUseBlock | undefined {
|
|
275
|
-
return resp.content.find(
|
|
276
|
-
(b): b is GatewayToolUseBlock =>
|
|
277
|
-
b.type === "tool_use" && b.name === RECALL_TOOL_NAME,
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** Check whether a response contains a recall tool_use. */
|
|
282
|
-
export function hasRecallToolUse(resp: GatewayResponse): boolean {
|
|
283
|
-
return findRecallToolUse(resp) !== undefined;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Check whether the response contains non-recall tool_use blocks. */
|
|
287
|
-
export function hasOtherToolUse(resp: GatewayResponse): boolean {
|
|
288
|
-
return resp.content.some(
|
|
289
|
-
(b) => b.type === "tool_use" && b.name !== RECALL_TOOL_NAME,
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Check whether the client's tools list already includes a recall tool. */
|
|
294
|
-
export function clientHasRecallTool(tools: GatewayTool[]): boolean {
|
|
295
|
-
return tools.some((t) => t.name === RECALL_TOOL_NAME);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ---------------------------------------------------------------------------
|
|
299
|
-
// Recall execution
|
|
300
|
-
// ---------------------------------------------------------------------------
|
|
301
|
-
|
|
302
|
-
/** Parse recall input from the tool_use block. */
|
|
303
|
-
function parseRecallInput(block: GatewayToolUseBlock): {
|
|
304
|
-
query: string;
|
|
305
|
-
scope: RecallScope;
|
|
306
|
-
} {
|
|
307
|
-
const input = block.input as Record<string, unknown>;
|
|
308
|
-
return {
|
|
309
|
-
query: typeof input.query === "string" ? input.query : "",
|
|
310
|
-
scope: (input.scope as RecallScope) ?? "all",
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Execute the recall tool and return formatted results.
|
|
316
|
-
*
|
|
317
|
-
* Wraps `runRecall()` with error handling — on failure returns a
|
|
318
|
-
* user-friendly error string rather than throwing.
|
|
319
|
-
*/
|
|
320
|
-
export async function executeRecall(
|
|
321
|
-
block: GatewayToolUseBlock,
|
|
322
|
-
projectPath: string,
|
|
323
|
-
sessionID: string,
|
|
324
|
-
): Promise<{ result: string; input: { query: string; scope?: RecallScope } }> {
|
|
325
|
-
const { query, scope } = parseRecallInput(block);
|
|
326
|
-
const cfg = loreConfig();
|
|
327
|
-
|
|
328
|
-
try {
|
|
329
|
-
const result = await runRecall({
|
|
330
|
-
query,
|
|
331
|
-
scope,
|
|
332
|
-
projectPath,
|
|
333
|
-
sessionID,
|
|
334
|
-
knowledgeEnabled: cfg.knowledge?.enabled ?? true,
|
|
335
|
-
searchConfig: cfg.search,
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
return { result, input: { query, scope } };
|
|
339
|
-
} catch (e) {
|
|
340
|
-
log.error("gateway recall execution failed:", e);
|
|
341
|
-
return {
|
|
342
|
-
result: "Recall search failed. The memory system encountered an error.",
|
|
343
|
-
input: { query, scope },
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// ---------------------------------------------------------------------------
|
|
349
|
-
// Follow-up request builder (Case 1: recall-only)
|
|
350
|
-
// ---------------------------------------------------------------------------
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Build a follow-up request after recall execution.
|
|
354
|
-
*
|
|
355
|
-
* The follow-up includes:
|
|
356
|
-
* - All original messages
|
|
357
|
-
* - The assistant's full response (including the recall tool_use)
|
|
358
|
-
* - A user message with the recall tool_result
|
|
359
|
-
* - Tools list WITHOUT recall (so the model won't call it again)
|
|
360
|
-
*
|
|
361
|
-
* The model continues from where it left off, now with recall results
|
|
362
|
-
* in context. Its new response streams directly to the client.
|
|
363
|
-
*/
|
|
364
|
-
export function buildRecallFollowUp(
|
|
365
|
-
originalReq: GatewayRequest,
|
|
366
|
-
resp: GatewayResponse,
|
|
367
|
-
recallResult: string,
|
|
368
|
-
recallToolUseBlock: GatewayToolUseBlock,
|
|
369
|
-
): GatewayRequest {
|
|
370
|
-
// Build assistant message with ONLY the recall tool_use block.
|
|
371
|
-
// Exclude any pre-recall text/thinking blocks — those were already streamed
|
|
372
|
-
// to the client. By presenting only the tool_use, the model understands it
|
|
373
|
-
// called recall and hasn't yet produced a substantive response, so it will
|
|
374
|
-
// generate new content after receiving the tool_result.
|
|
375
|
-
const assistantMessage: GatewayMessage = {
|
|
376
|
-
role: "assistant",
|
|
377
|
-
content: [recallToolUseBlock],
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
// Build user message with tool_result
|
|
381
|
-
const toolResultMessage: GatewayMessage = {
|
|
382
|
-
role: "user",
|
|
383
|
-
content: [
|
|
384
|
-
{
|
|
385
|
-
type: "tool_result",
|
|
386
|
-
toolUseId: recallToolUseBlock.id,
|
|
387
|
-
content: recallResult || "[No results found.]",
|
|
388
|
-
},
|
|
389
|
-
],
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
// Strip recall from tools list
|
|
393
|
-
const toolsWithoutRecall = originalReq.tools.filter(
|
|
394
|
-
(t) => t.name !== RECALL_TOOL_NAME,
|
|
395
|
-
);
|
|
396
|
-
|
|
397
|
-
return {
|
|
398
|
-
...originalReq,
|
|
399
|
-
messages: [
|
|
400
|
-
...originalReq.messages,
|
|
401
|
-
assistantMessage,
|
|
402
|
-
toolResultMessage,
|
|
403
|
-
],
|
|
404
|
-
tools: toolsWithoutRecall,
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// ---------------------------------------------------------------------------
|
|
409
|
-
// Response content rewriting — replace recall tool_use with marker text
|
|
410
|
-
// ---------------------------------------------------------------------------
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Build a GatewayResponse with recall tool_use blocks replaced by marker text.
|
|
414
|
-
*
|
|
415
|
-
* Used for both recall-only and mixed-tools cases to produce a response
|
|
416
|
-
* where the client sees human-readable markers instead of tool call mechanics.
|
|
417
|
-
*/
|
|
418
|
-
export function replaceRecallWithMarker(
|
|
419
|
-
resp: GatewayResponse,
|
|
420
|
-
): GatewayResponse {
|
|
421
|
-
return {
|
|
422
|
-
...resp,
|
|
423
|
-
content: resp.content.map((b) => {
|
|
424
|
-
if (b.type === "tool_use" && b.name === RECALL_TOOL_NAME) {
|
|
425
|
-
const input = b.input as Record<string, unknown>;
|
|
426
|
-
const query = typeof input.query === "string" ? input.query : "";
|
|
427
|
-
const scope = (input.scope as string) ?? "all";
|
|
428
|
-
return { type: "text" as const, text: buildRecallMarker(query, scope) };
|
|
429
|
-
}
|
|
430
|
-
return b;
|
|
431
|
-
}),
|
|
432
|
-
};
|
|
433
|
-
}
|
package/src/recorder.ts
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fixture recorder and replayer for the Lore gateway.
|
|
3
|
-
*
|
|
4
|
-
* Recording mode: intercepts every upstream API call, writes the
|
|
5
|
-
* (request, response) pair to an NDJSON fixture file, then returns
|
|
6
|
-
* the real response to the caller unchanged.
|
|
7
|
-
*
|
|
8
|
-
* Replay mode: replays stored fixtures in sequence, never touching
|
|
9
|
-
* the upstream API. Useful for deterministic integration tests.
|
|
10
|
-
*/
|
|
11
|
-
import { appendFileSync } from "node:fs";
|
|
12
|
-
import { log } from "@loreai/core";
|
|
13
|
-
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
// Public types
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
/** One entry per upstream API call, stored in the fixture file. */
|
|
19
|
-
export interface FixtureEntry {
|
|
20
|
-
/** Sequence number within the recording session (0-based). */
|
|
21
|
-
seq: number;
|
|
22
|
-
/** Wall-clock timestamp (ms since Unix epoch) when the call was made. */
|
|
23
|
-
ts: number;
|
|
24
|
-
/** The upstream request body as sent (Anthropic /v1/messages JSON). */
|
|
25
|
-
request: unknown;
|
|
26
|
-
/** The full upstream response body (non-streaming, even if original was streaming). */
|
|
27
|
-
response: unknown;
|
|
28
|
-
/** Whether the original request asked for a streaming response. */
|
|
29
|
-
wasStreaming: boolean;
|
|
30
|
-
/** Model that was used for the request. */
|
|
31
|
-
model: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Interceptor function injected into the upstream forwarding path.
|
|
36
|
-
*
|
|
37
|
-
* @param requestBody - The request body that will be sent upstream.
|
|
38
|
-
* @param model - Model identifier from the request.
|
|
39
|
-
* @param wasStreaming - Whether the original request was streaming.
|
|
40
|
-
* @param makeRealRequest - Thunk that performs the actual HTTP request.
|
|
41
|
-
* The interceptor decides whether to call it.
|
|
42
|
-
*/
|
|
43
|
-
export type UpstreamInterceptor = (
|
|
44
|
-
requestBody: unknown,
|
|
45
|
-
model: string,
|
|
46
|
-
wasStreaming: boolean,
|
|
47
|
-
makeRealRequest: () => Promise<Response>,
|
|
48
|
-
) => Promise<Response>;
|
|
49
|
-
|
|
50
|
-
// ---------------------------------------------------------------------------
|
|
51
|
-
// Module-level state
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
|
-
|
|
54
|
-
/** Non-null when recording is active; holds the path of the fixture file. */
|
|
55
|
-
let recordingPath: string | null = null;
|
|
56
|
-
|
|
57
|
-
/** Monotonically increasing counter for fixture sequence numbers. */
|
|
58
|
-
let seqCounter = 0;
|
|
59
|
-
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
// Recording control
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
/** Enable recording mode. All upstream calls will be appended to `fixturePath`. */
|
|
65
|
-
export function startRecording(fixturePath: string): void {
|
|
66
|
-
recordingPath = fixturePath;
|
|
67
|
-
seqCounter = 0;
|
|
68
|
-
log.info(`[recorder] recording to: ${fixturePath}`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Disable recording mode. */
|
|
72
|
-
export function stopRecording(): void {
|
|
73
|
-
recordingPath = null;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
// Recording interceptor
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Returns an `UpstreamInterceptor` when recording mode is active, or
|
|
82
|
-
* `null` when it is not.
|
|
83
|
-
*
|
|
84
|
-
* The returned interceptor:
|
|
85
|
-
* 1. Calls `makeRealRequest()` to get the real upstream response.
|
|
86
|
-
* 2. Reads the full response body text (works for both streaming and
|
|
87
|
-
* non-streaming — the raw body is always valid JSON from Anthropic
|
|
88
|
-
* even for streaming responses because we force `stream:false` when
|
|
89
|
-
* we need the body for the fixture; for streaming the body is SSE
|
|
90
|
-
* text which we store verbatim).
|
|
91
|
-
* 3. Appends a `FixtureEntry` line to the fixture file.
|
|
92
|
-
* 4. Returns a new `Response` with the same status, headers, and body
|
|
93
|
-
* (the original body stream is already consumed, so we reconstitute it).
|
|
94
|
-
*/
|
|
95
|
-
export function getRecordedInterceptor(): UpstreamInterceptor | null {
|
|
96
|
-
if (!recordingPath) return null;
|
|
97
|
-
|
|
98
|
-
// Capture the path at interceptor creation time so closure is stable
|
|
99
|
-
const fixturePath = recordingPath;
|
|
100
|
-
|
|
101
|
-
return async (
|
|
102
|
-
requestBody: unknown,
|
|
103
|
-
model: string,
|
|
104
|
-
wasStreaming: boolean,
|
|
105
|
-
makeRealRequest: () => Promise<Response>,
|
|
106
|
-
): Promise<Response> => {
|
|
107
|
-
const ts = Date.now();
|
|
108
|
-
const seq = seqCounter++;
|
|
109
|
-
|
|
110
|
-
// Perform the real upstream request
|
|
111
|
-
const realResponse = await makeRealRequest();
|
|
112
|
-
|
|
113
|
-
// Collect all response headers before consuming the body
|
|
114
|
-
const responseHeaders: Record<string, string> = {};
|
|
115
|
-
realResponse.headers.forEach((value, key) => {
|
|
116
|
-
responseHeaders[key] = value;
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// Read the full body text — this consumes the stream
|
|
120
|
-
const bodyText = await realResponse.text();
|
|
121
|
-
|
|
122
|
-
// Parse body as JSON for structured storage; fall back to raw string
|
|
123
|
-
let responseBody: unknown;
|
|
124
|
-
try {
|
|
125
|
-
responseBody = JSON.parse(bodyText);
|
|
126
|
-
} catch {
|
|
127
|
-
responseBody = bodyText;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Write the fixture entry
|
|
131
|
-
const entry: FixtureEntry = {
|
|
132
|
-
seq,
|
|
133
|
-
ts,
|
|
134
|
-
request: requestBody,
|
|
135
|
-
response: responseBody,
|
|
136
|
-
wasStreaming,
|
|
137
|
-
model,
|
|
138
|
-
};
|
|
139
|
-
appendFileSync(fixturePath, JSON.stringify(entry) + "\n", "utf8");
|
|
140
|
-
|
|
141
|
-
log.info(`[recorder] captured turn seq=${seq} model=${model}`);
|
|
142
|
-
|
|
143
|
-
// Return a new Response with the same status and headers but a fresh body
|
|
144
|
-
return new Response(bodyText, {
|
|
145
|
-
status: realResponse.status,
|
|
146
|
-
headers: responseHeaders,
|
|
147
|
-
});
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ---------------------------------------------------------------------------
|
|
152
|
-
// Replay interceptor
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Returns an interceptor that replays the given fixtures in sequence,
|
|
157
|
-
* without ever calling `makeRealRequest()`.
|
|
158
|
-
*
|
|
159
|
-
* Each call advances an internal counter. When the counter exceeds
|
|
160
|
-
* `fixtures.length`, an error is thrown.
|
|
161
|
-
*/
|
|
162
|
-
export function getReplayInterceptor(fixtures: FixtureEntry[]): UpstreamInterceptor {
|
|
163
|
-
let replayCounter = 0;
|
|
164
|
-
|
|
165
|
-
return async (
|
|
166
|
-
_requestBody: unknown,
|
|
167
|
-
_model: string,
|
|
168
|
-
_wasStreaming: boolean,
|
|
169
|
-
_makeRealRequest: () => Promise<Response>,
|
|
170
|
-
): Promise<Response> => {
|
|
171
|
-
if (replayCounter >= fixtures.length) {
|
|
172
|
-
throw new Error(
|
|
173
|
-
`Replay exhausted: no more fixtures (tried to replay entry ${replayCounter}, ` +
|
|
174
|
-
`but only ${fixtures.length} fixture(s) are available)`,
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const fixture = fixtures[replayCounter++];
|
|
179
|
-
|
|
180
|
-
log.info(
|
|
181
|
-
`[recorder] replaying seq=${fixture.seq} model=${fixture.model} ` +
|
|
182
|
-
`(${replayCounter}/${fixtures.length})`,
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
// Always return a non-streaming JSON response — the pipeline handles
|
|
186
|
-
// re-streaming if the client originally requested SSE.
|
|
187
|
-
return new Response(JSON.stringify(fixture.response), {
|
|
188
|
-
status: 200,
|
|
189
|
-
headers: { "content-type": "application/json" },
|
|
190
|
-
});
|
|
191
|
-
};
|
|
192
|
-
}
|