@intx/inference 0.1.2 → 0.2.2
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/LICENSE +176 -0
- package/dist/actions.d.ts +16 -0
- package/dist/actions.js +200 -0
- package/dist/adapter.d.ts +38 -0
- package/dist/adapter.js +31 -0
- package/dist/assembly.d.ts +68 -0
- package/dist/assembly.js +132 -0
- package/dist/audit-collector.d.ts +10 -0
- package/dist/audit-collector.js +139 -0
- package/dist/auth.d.ts +24 -0
- package/{src/auth.ts → dist/auth.js} +13 -19
- package/dist/authz-extension.d.ts +32 -0
- package/dist/authz-extension.js +100 -0
- package/dist/correlation.d.ts +25 -0
- package/dist/correlation.js +32 -0
- package/dist/default-director.d.ts +111 -0
- package/dist/default-director.js +199 -0
- package/dist/director.d.ts +6 -0
- package/dist/director.js +56 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +83 -0
- package/dist/gates.d.ts +27 -0
- package/dist/gates.js +80 -0
- package/dist/harness.d.ts +147 -0
- package/dist/harness.js +1319 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +21 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +44 -0
- package/dist/providers/anthropic.d.ts +33 -0
- package/dist/providers/anthropic.js +670 -0
- package/dist/providers/google-genai-files.d.ts +48 -0
- package/dist/providers/google-genai-files.js +205 -0
- package/dist/providers/google-genai.d.ts +3 -0
- package/dist/providers/google-genai.js +1196 -0
- package/dist/providers/index.d.ts +38 -0
- package/dist/providers/index.js +56 -0
- package/dist/providers/openai.d.ts +3 -0
- package/dist/providers/openai.js +609 -0
- package/dist/reactor.d.ts +50 -0
- package/dist/reactor.js +920 -0
- package/dist/retry-policy.d.ts +31 -0
- package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
- package/dist/sse.d.ts +1 -0
- package/dist/sse.js +63 -0
- package/dist/state.d.ts +23 -0
- package/dist/state.js +100 -0
- package/dist/tool-name.d.ts +6 -0
- package/dist/tool-name.js +110 -0
- package/dist/transform.d.ts +11 -0
- package/dist/transform.js +117 -0
- package/dist/transforms/index.d.ts +2 -0
- package/dist/transforms/index.js +1 -0
- package/dist/transforms/size-cap.d.ts +12 -0
- package/dist/transforms/size-cap.js +80 -0
- package/dist/turns.d.ts +21 -0
- package/dist/turns.js +135 -0
- package/package.json +21 -6
- package/src/actions.ts +0 -245
- package/src/adapter.ts +0 -57
- package/src/assembly.test.ts +0 -728
- package/src/assembly.ts +0 -250
- package/src/audit-collector.test.ts +0 -332
- package/src/audit-collector.ts +0 -172
- package/src/auth.test.ts +0 -117
- package/src/authz-extension.test.ts +0 -269
- package/src/authz-extension.ts +0 -145
- package/src/correlation.ts +0 -61
- package/src/default-director.test.ts +0 -314
- package/src/default-director.ts +0 -344
- package/src/director.ts +0 -87
- package/src/errors.test.ts +0 -133
- package/src/errors.ts +0 -115
- package/src/gates.ts +0 -128
- package/src/harness.test.ts +0 -655
- package/src/harness.ts +0 -1571
- package/src/index.ts +0 -76
- package/src/providers/anthropic.test.ts +0 -771
- package/src/providers/anthropic.ts +0 -810
- package/src/providers/google-genai-files.ts +0 -289
- package/src/providers/google-genai.ts +0 -1518
- package/src/providers/openai.ts +0 -719
- package/src/providers/registry.ts +0 -33
- package/src/reactor.test.ts +0 -3660
- package/src/reactor.ts +0 -1058
- package/src/scheduler.test.ts +0 -41
- package/src/sse.test.ts +0 -133
- package/src/sse.ts +0 -76
- package/src/state.ts +0 -135
- package/src/transform.test.ts +0 -207
- package/src/transform.ts +0 -159
- package/src/transforms/index.ts +0 -2
- package/src/transforms/size-cap.test.ts +0 -172
- package/src/transforms/size-cap.ts +0 -110
- package/src/turns.ts +0 -54
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
package/src/harness.test.ts
DELETED
|
@@ -1,655 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
createDefaultDependencies,
|
|
5
|
-
createDefaultScheduler,
|
|
6
|
-
HarnessId,
|
|
7
|
-
runInference,
|
|
8
|
-
type Dependencies,
|
|
9
|
-
type InferenceHarnessOptions,
|
|
10
|
-
} from "./harness";
|
|
11
|
-
import type {
|
|
12
|
-
ConversationTurn,
|
|
13
|
-
InferenceEvent,
|
|
14
|
-
InferenceSource,
|
|
15
|
-
} from "@intx/types/runtime";
|
|
16
|
-
|
|
17
|
-
const SOURCE: InferenceSource = {
|
|
18
|
-
id: "anthropic:claude-3-5-sonnet-20240620",
|
|
19
|
-
provider: "anthropic",
|
|
20
|
-
baseURL: "https://api.anthropic.com",
|
|
21
|
-
apiKey: "test",
|
|
22
|
-
model: "claude-3-5-sonnet-20240620",
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
function userTurn(text: string): ConversationTurn {
|
|
26
|
-
return {
|
|
27
|
-
role: "user",
|
|
28
|
-
content: [{ type: "text", text }],
|
|
29
|
-
timestamp: 0,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function collect(
|
|
34
|
-
iter: AsyncIterable<InferenceEvent>,
|
|
35
|
-
): Promise<InferenceEvent[]> {
|
|
36
|
-
const out: InferenceEvent[] = [];
|
|
37
|
-
for await (const ev of iter) out.push(ev);
|
|
38
|
-
return out;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Only needed because `globalThis.fetch` reassignment must satisfy the
|
|
42
|
-
// Bun-augmented type (which carries a `preconnect` static). `deps.fetch`
|
|
43
|
-
// uses the narrow `Dependencies.fetch` shape and accepts a plain function.
|
|
44
|
-
function makeGlobalFetchStub(
|
|
45
|
-
handler: (
|
|
46
|
-
input: string | URL | Request,
|
|
47
|
-
init?: RequestInit,
|
|
48
|
-
) => Promise<Response>,
|
|
49
|
-
): typeof globalThis.fetch {
|
|
50
|
-
return Object.assign(handler, { preconnect: () => undefined });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
describe("runInference — Dependencies parameter", () => {
|
|
54
|
-
test("invokes deps.fetch instead of globalThis.fetch", async () => {
|
|
55
|
-
const calls: { url: string; method: string | undefined }[] = [];
|
|
56
|
-
|
|
57
|
-
const deps: Dependencies = {
|
|
58
|
-
fetch: (input, init) => {
|
|
59
|
-
const url =
|
|
60
|
-
typeof input === "string"
|
|
61
|
-
? input
|
|
62
|
-
: input instanceof URL
|
|
63
|
-
? input.toString()
|
|
64
|
-
: input.url;
|
|
65
|
-
calls.push({ url, method: init?.method });
|
|
66
|
-
return Promise.resolve(
|
|
67
|
-
new Response("", {
|
|
68
|
-
status: 200,
|
|
69
|
-
headers: { "content-type": "text/event-stream" },
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
},
|
|
73
|
-
scheduler: createDefaultScheduler(),
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const originalFetch = globalThis.fetch;
|
|
77
|
-
globalThis.fetch = makeGlobalFetchStub(() => {
|
|
78
|
-
throw new Error(
|
|
79
|
-
"globalThis.fetch must not be called when deps.fetch is provided",
|
|
80
|
-
);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
let events: InferenceEvent[];
|
|
84
|
-
try {
|
|
85
|
-
let seq = 0;
|
|
86
|
-
events = await collect(
|
|
87
|
-
runInference({
|
|
88
|
-
turns: [userTurn("hello")],
|
|
89
|
-
source: SOURCE,
|
|
90
|
-
nextSeq: () => ++seq,
|
|
91
|
-
deps,
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
} finally {
|
|
95
|
-
globalThis.fetch = originalFetch;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
expect(calls).toHaveLength(1);
|
|
99
|
-
const firstCall = calls[0];
|
|
100
|
-
if (firstCall === undefined) {
|
|
101
|
-
throw new Error("expected one fetch call");
|
|
102
|
-
}
|
|
103
|
-
expect(firstCall.url).toBe("https://api.anthropic.com/v1/messages");
|
|
104
|
-
expect(firstCall.method).toBe("POST");
|
|
105
|
-
|
|
106
|
-
const startEvent = events.find((e) => e.type === "inference.start");
|
|
107
|
-
const doneEvent = events.find((e) => e.type === "inference.done");
|
|
108
|
-
if (startEvent === undefined) throw new Error("missing inference.start");
|
|
109
|
-
if (doneEvent === undefined) throw new Error("missing inference.done");
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("propagates errors from deps.fetch without falling back to globalThis.fetch", async () => {
|
|
113
|
-
const deps: Dependencies = {
|
|
114
|
-
fetch: () => Promise.reject(new Error("simulated network failure")),
|
|
115
|
-
scheduler: createDefaultScheduler(),
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const originalFetch = globalThis.fetch;
|
|
119
|
-
let globalFetchCalled = false;
|
|
120
|
-
globalThis.fetch = makeGlobalFetchStub(() => {
|
|
121
|
-
globalFetchCalled = true;
|
|
122
|
-
throw new Error("globalThis.fetch must not be called");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
let events: InferenceEvent[];
|
|
126
|
-
try {
|
|
127
|
-
let seq = 0;
|
|
128
|
-
events = await collect(
|
|
129
|
-
runInference({
|
|
130
|
-
turns: [userTurn("hello")],
|
|
131
|
-
source: SOURCE,
|
|
132
|
-
nextSeq: () => ++seq,
|
|
133
|
-
deps,
|
|
134
|
-
}),
|
|
135
|
-
);
|
|
136
|
-
} finally {
|
|
137
|
-
globalThis.fetch = originalFetch;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
expect(globalFetchCalled).toBe(false);
|
|
141
|
-
const errorEvent = events.find((e) => e.type === "inference.error");
|
|
142
|
-
if (errorEvent === undefined) throw new Error("missing inference.error");
|
|
143
|
-
expect(errorEvent.data.error.category).toBe("retryable");
|
|
144
|
-
expect(errorEvent.data.error.message).toContain(
|
|
145
|
-
"simulated network failure",
|
|
146
|
-
);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
// The crash-loudly contract: a missing or malformed `deps.fetch` is a
|
|
150
|
-
// programmer bug, not a transport failure. `runInference` must throw a
|
|
151
|
-
// plain Error out of the generator (lazily, on first iteration — the
|
|
152
|
-
// throw fires from inside `for await`, not at the `runInference(...)`
|
|
153
|
-
// call site) and must not yield any event, including `inference.start`.
|
|
154
|
-
|
|
155
|
-
test("throws plainly when deps.fetch is undefined", async () => {
|
|
156
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- modeling a JS caller that assigned undefined to deps.fetch
|
|
157
|
-
const deps = { fetch: undefined } as unknown as Dependencies;
|
|
158
|
-
const iter = runInference({
|
|
159
|
-
turns: [userTurn("hello")],
|
|
160
|
-
source: SOURCE,
|
|
161
|
-
nextSeq: () => 1,
|
|
162
|
-
deps,
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
const events: InferenceEvent[] = [];
|
|
166
|
-
let thrown: unknown;
|
|
167
|
-
try {
|
|
168
|
-
for await (const ev of iter) events.push(ev);
|
|
169
|
-
} catch (e) {
|
|
170
|
-
thrown = e;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (!(thrown instanceof Error)) {
|
|
174
|
-
throw new Error("expected runInference to throw an Error");
|
|
175
|
-
}
|
|
176
|
-
expect(thrown.message).toContain("deps.fetch must be a function");
|
|
177
|
-
expect(thrown.message).toContain("undefined");
|
|
178
|
-
expect(events).toEqual([]);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test("throws plainly when deps.fetch is a non-function value", async () => {
|
|
182
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- modeling a JS caller that assigned a non-function value to deps.fetch
|
|
183
|
-
const deps = { fetch: "not a function" } as unknown as Dependencies;
|
|
184
|
-
const iter = runInference({
|
|
185
|
-
turns: [userTurn("hello")],
|
|
186
|
-
source: SOURCE,
|
|
187
|
-
nextSeq: () => 1,
|
|
188
|
-
deps,
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
const events: InferenceEvent[] = [];
|
|
192
|
-
let thrown: unknown;
|
|
193
|
-
try {
|
|
194
|
-
for await (const ev of iter) events.push(ev);
|
|
195
|
-
} catch (e) {
|
|
196
|
-
thrown = e;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (!(thrown instanceof Error)) {
|
|
200
|
-
throw new Error("expected runInference to throw an Error");
|
|
201
|
-
}
|
|
202
|
-
expect(thrown.message).toContain("deps.fetch must be a function");
|
|
203
|
-
expect(thrown.message).toContain("string");
|
|
204
|
-
expect(events).toEqual([]);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("throws plainly when deps is omitted entirely", async () => {
|
|
208
|
-
const baseOpts: Omit<InferenceHarnessOptions, "deps"> = {
|
|
209
|
-
turns: [userTurn("hello")],
|
|
210
|
-
source: SOURCE,
|
|
211
|
-
nextSeq: () => 1,
|
|
212
|
-
};
|
|
213
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- modeling a JS caller that omitted the required `deps` field
|
|
214
|
-
const opts = baseOpts as unknown as InferenceHarnessOptions;
|
|
215
|
-
const iter = runInference(opts);
|
|
216
|
-
|
|
217
|
-
const events: InferenceEvent[] = [];
|
|
218
|
-
let thrown: unknown;
|
|
219
|
-
try {
|
|
220
|
-
for await (const ev of iter) events.push(ev);
|
|
221
|
-
} catch (e) {
|
|
222
|
-
thrown = e;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (!(thrown instanceof Error)) {
|
|
226
|
-
throw new Error("expected runInference to throw an Error");
|
|
227
|
-
}
|
|
228
|
-
expect(thrown.message).toContain("deps.fetch must be a function");
|
|
229
|
-
expect(events).toEqual([]);
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
describe("createDefaultDependencies", () => {
|
|
234
|
-
test("delegates calls to globalThis.fetch as bound at factory-call time", async () => {
|
|
235
|
-
const calls: { url: string; method: string | undefined }[] = [];
|
|
236
|
-
const originalFetch = globalThis.fetch;
|
|
237
|
-
globalThis.fetch = makeGlobalFetchStub((input, init) => {
|
|
238
|
-
const url =
|
|
239
|
-
typeof input === "string"
|
|
240
|
-
? input
|
|
241
|
-
: input instanceof URL
|
|
242
|
-
? input.toString()
|
|
243
|
-
: input.url;
|
|
244
|
-
calls.push({ url, method: init?.method });
|
|
245
|
-
return Promise.resolve(new Response("", { status: 204 }));
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
try {
|
|
249
|
-
const deps = createDefaultDependencies();
|
|
250
|
-
const response = await deps.fetch("https://example.test/ping", {
|
|
251
|
-
method: "POST",
|
|
252
|
-
});
|
|
253
|
-
expect(response.status).toBe(204);
|
|
254
|
-
} finally {
|
|
255
|
-
globalThis.fetch = originalFetch;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
expect(calls).toEqual([
|
|
259
|
-
{ url: "https://example.test/ping", method: "POST" },
|
|
260
|
-
]);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
test("does not stamp the HarnessId tag", () => {
|
|
264
|
-
const deps = createDefaultDependencies();
|
|
265
|
-
expect(Object.getOwnPropertySymbols(deps)).toEqual([]);
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
// `source.defaults` carries model-bound knobs that the harness merges
|
|
270
|
-
// into the per-call `InferenceOptions` at the top of `runInference`
|
|
271
|
-
// before the adapter sees anything. The contract: per-call wins over
|
|
272
|
-
// source-bound; source-bound applies when per-call omits the key.
|
|
273
|
-
//
|
|
274
|
-
// These tests use the openai-compatible adapter because it carries the
|
|
275
|
-
// fully-populated `max_tokens` floor through to the request body
|
|
276
|
-
// (anthropic's adapter only forwards `max_tokens` when set), so the
|
|
277
|
-
// merge result is observable at the wire.
|
|
278
|
-
describe("runInference — source.defaults merge precedence", () => {
|
|
279
|
-
const OPENAI_SOURCE: InferenceSource = {
|
|
280
|
-
id: "openai:gpt-test",
|
|
281
|
-
provider: "openai",
|
|
282
|
-
baseURL: "https://api.openai.test/v1",
|
|
283
|
-
apiKey: "test",
|
|
284
|
-
model: "gpt-test",
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
288
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async function captureMaxTokens(opts: {
|
|
292
|
-
source: InferenceSource;
|
|
293
|
-
perCallMaxTokens?: number;
|
|
294
|
-
}): Promise<number | undefined> {
|
|
295
|
-
let captured: Record<string, unknown> | undefined;
|
|
296
|
-
const deps: Dependencies = {
|
|
297
|
-
fetch: (_input, init) => {
|
|
298
|
-
const body = typeof init?.body === "string" ? init.body : "";
|
|
299
|
-
const parsed: unknown = body === "" ? {} : JSON.parse(body);
|
|
300
|
-
if (isRecord(parsed)) {
|
|
301
|
-
captured = parsed;
|
|
302
|
-
}
|
|
303
|
-
return Promise.resolve(
|
|
304
|
-
new Response("", {
|
|
305
|
-
status: 200,
|
|
306
|
-
headers: { "content-type": "text/event-stream" },
|
|
307
|
-
}),
|
|
308
|
-
);
|
|
309
|
-
},
|
|
310
|
-
scheduler: createDefaultScheduler(),
|
|
311
|
-
};
|
|
312
|
-
let seq = 0;
|
|
313
|
-
await collect(
|
|
314
|
-
runInference({
|
|
315
|
-
turns: [userTurn("hi")],
|
|
316
|
-
source: opts.source,
|
|
317
|
-
...(opts.perCallMaxTokens !== undefined
|
|
318
|
-
? { inferenceOptions: { maxTokens: opts.perCallMaxTokens } }
|
|
319
|
-
: {}),
|
|
320
|
-
nextSeq: () => ++seq,
|
|
321
|
-
deps,
|
|
322
|
-
}),
|
|
323
|
-
);
|
|
324
|
-
if (captured === undefined) throw new Error("no request body captured");
|
|
325
|
-
const value = captured["max_tokens"];
|
|
326
|
-
return typeof value === "number" ? value : undefined;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
test("source-bound default applies when the per-call option is absent", async () => {
|
|
330
|
-
const sourceWithDefault: InferenceSource = {
|
|
331
|
-
...OPENAI_SOURCE,
|
|
332
|
-
defaults: { maxTokens: 1024 },
|
|
333
|
-
};
|
|
334
|
-
const max = await captureMaxTokens({ source: sourceWithDefault });
|
|
335
|
-
expect(max).toBe(1024);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
test("per-call option overrides the source-bound default", async () => {
|
|
339
|
-
const sourceWithDefault: InferenceSource = {
|
|
340
|
-
...OPENAI_SOURCE,
|
|
341
|
-
defaults: { maxTokens: 1024 },
|
|
342
|
-
};
|
|
343
|
-
const max = await captureMaxTokens({
|
|
344
|
-
source: sourceWithDefault,
|
|
345
|
-
perCallMaxTokens: 8192,
|
|
346
|
-
});
|
|
347
|
-
expect(max).toBe(8192);
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
test("neither set: the adapter's compile-time floor applies", async () => {
|
|
351
|
-
const max = await captureMaxTokens({ source: OPENAI_SOURCE });
|
|
352
|
-
// Falls through to openai.ts's `options.maxTokens ?? 4096` floor.
|
|
353
|
-
expect(max).toBe(4096);
|
|
354
|
-
});
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
// providerOptions on InferenceSourceDefaults is the model-bound bag of
|
|
358
|
-
// provider-native knobs. Per-call InferenceOptions.providerOptions
|
|
359
|
-
// overrides via the same shallow-spread merge that handles maxTokens.
|
|
360
|
-
// The merge is shallow: a per-call providerOptions object wholesale
|
|
361
|
-
// replaces the source-bound one rather than deep-merging per key.
|
|
362
|
-
describe("runInference — providerOptions merge precedence", () => {
|
|
363
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
364
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
async function captureAdapterOptions(opts: {
|
|
368
|
-
sourceProviderOptions?: Record<string, unknown>;
|
|
369
|
-
perCallProviderOptions?: Record<string, unknown>;
|
|
370
|
-
}): Promise<Record<string, unknown> | undefined> {
|
|
371
|
-
// Use a fresh provider name per call so concurrent test runs don't
|
|
372
|
-
// race on the global registry. The `registerProvider` API has no
|
|
373
|
-
// unregister counterpart; leaked test providers are inert.
|
|
374
|
-
const providerName = `test-provideroptions-${Math.random().toString(36).slice(2)}`;
|
|
375
|
-
let captured: Record<string, unknown> | undefined | "absent" = "absent";
|
|
376
|
-
|
|
377
|
-
const { registerProvider } = await import("./providers/registry");
|
|
378
|
-
registerProvider(providerName, () => ({
|
|
379
|
-
buildRequest: (_messages, _model, options) => {
|
|
380
|
-
captured = isRecord(options.providerOptions)
|
|
381
|
-
? options.providerOptions
|
|
382
|
-
: undefined;
|
|
383
|
-
return {
|
|
384
|
-
url: "/test",
|
|
385
|
-
headers: { "content-type": "application/json" },
|
|
386
|
-
body: "{}",
|
|
387
|
-
};
|
|
388
|
-
},
|
|
389
|
-
parseResponse: () => [],
|
|
390
|
-
}));
|
|
391
|
-
|
|
392
|
-
const source: InferenceSource = {
|
|
393
|
-
id: `${providerName}:test-model`,
|
|
394
|
-
provider: providerName,
|
|
395
|
-
baseURL: "https://test.invalid",
|
|
396
|
-
apiKey: "test",
|
|
397
|
-
model: "test-model",
|
|
398
|
-
...(opts.sourceProviderOptions !== undefined
|
|
399
|
-
? { defaults: { providerOptions: opts.sourceProviderOptions } }
|
|
400
|
-
: {}),
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
const deps: Dependencies = {
|
|
404
|
-
fetch: () =>
|
|
405
|
-
Promise.resolve(
|
|
406
|
-
new Response("", {
|
|
407
|
-
status: 200,
|
|
408
|
-
headers: { "content-type": "text/event-stream" },
|
|
409
|
-
}),
|
|
410
|
-
),
|
|
411
|
-
scheduler: createDefaultScheduler(),
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
let seq = 0;
|
|
415
|
-
await collect(
|
|
416
|
-
runInference({
|
|
417
|
-
turns: [userTurn("hi")],
|
|
418
|
-
source,
|
|
419
|
-
...(opts.perCallProviderOptions !== undefined
|
|
420
|
-
? {
|
|
421
|
-
inferenceOptions: {
|
|
422
|
-
providerOptions: opts.perCallProviderOptions,
|
|
423
|
-
},
|
|
424
|
-
}
|
|
425
|
-
: {}),
|
|
426
|
-
nextSeq: () => ++seq,
|
|
427
|
-
deps,
|
|
428
|
-
}),
|
|
429
|
-
);
|
|
430
|
-
|
|
431
|
-
if (captured === "absent")
|
|
432
|
-
throw new Error("adapter buildRequest not called");
|
|
433
|
-
return captured;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
test("source-bound providerOptions reaches the adapter when no per-call override", async () => {
|
|
437
|
-
const seen = await captureAdapterOptions({
|
|
438
|
-
sourceProviderOptions: { user: "user_123", store: false },
|
|
439
|
-
});
|
|
440
|
-
expect(seen).toEqual({ user: "user_123", store: false });
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
test("per-call providerOptions wholesale replaces the source-bound bag", async () => {
|
|
444
|
-
const seen = await captureAdapterOptions({
|
|
445
|
-
sourceProviderOptions: { user: "user_123", store: false },
|
|
446
|
-
perCallProviderOptions: { user: "user_999" },
|
|
447
|
-
});
|
|
448
|
-
// Shallow merge: per-call object wins entirely, source-bound `store`
|
|
449
|
-
// does NOT survive the override.
|
|
450
|
-
expect(seen).toEqual({ user: "user_999" });
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
test("neither set: the adapter sees options.providerOptions === undefined", async () => {
|
|
454
|
-
const seen = await captureAdapterOptions({});
|
|
455
|
-
expect(seen).toBeUndefined();
|
|
456
|
-
});
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
// The JSDoc on `Dependencies` documents which reflective APIs leak the
|
|
460
|
-
// optional `[HarnessId]` tag. Pin those claims so a future refactor that
|
|
461
|
-
// makes the tag enumerable (e.g., renaming it to a string key) cannot
|
|
462
|
-
// silently turn a safe serializer into a leak.
|
|
463
|
-
describe("Dependencies — reflective exposure of HarnessId", () => {
|
|
464
|
-
function stampedDeps(): Dependencies {
|
|
465
|
-
return {
|
|
466
|
-
fetch: () => Promise.resolve(new Response("")),
|
|
467
|
-
scheduler: createDefaultScheduler(),
|
|
468
|
-
[HarnessId]: Symbol("test-harness"),
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
test("JSON.stringify ignores symbol-keyed fields", () => {
|
|
473
|
-
// Probe with a serializable string value at the symbol key. If
|
|
474
|
-
// `HarnessId` were ever changed from a symbol to a string key, the
|
|
475
|
-
// serializer would walk it and the assertion would fail. The
|
|
476
|
-
// string-keyed control proves the test isn't passing just because
|
|
477
|
-
// `JSON.stringify` produced an empty object for unrelated reasons.
|
|
478
|
-
const probe = {
|
|
479
|
-
visible: "yes",
|
|
480
|
-
[HarnessId]: "leaked-value",
|
|
481
|
-
};
|
|
482
|
-
expect(JSON.stringify(probe)).toBe('{"visible":"yes"}');
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
test("Object.getOwnPropertySymbols exposes the tag", () => {
|
|
486
|
-
const deps = stampedDeps();
|
|
487
|
-
expect(Object.getOwnPropertySymbols(deps)).toContain(HarnessId);
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
test("Reflect.ownKeys exposes the tag", () => {
|
|
491
|
-
const deps = stampedDeps();
|
|
492
|
-
expect(Reflect.ownKeys(deps)).toContain(HarnessId);
|
|
493
|
-
});
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
// ---------------------------------------------------------------------------
|
|
497
|
-
// runInference — source-identity stamping on inference events
|
|
498
|
-
//
|
|
499
|
-
// The harness snapshots `{id, provider, model}` from the active source at
|
|
500
|
-
// the top of the call and stamps that descriptor onto every inference.usage
|
|
501
|
-
// and inference.done event for that call. The snapshot defends against
|
|
502
|
-
// `applyInferenceSourceFields` (or any other in-place mutation of the
|
|
503
|
-
// shared source object) firing between call start and inference.done: the
|
|
504
|
-
// identity stamped onto the events must reflect the source that *began*
|
|
505
|
-
// the call, not whatever the active source happens to be at done-time.
|
|
506
|
-
// ---------------------------------------------------------------------------
|
|
507
|
-
|
|
508
|
-
describe("runInference — source-identity stamping", () => {
|
|
509
|
-
test("emits LastCycleSource on inference.done matching the call-start source", async () => {
|
|
510
|
-
const deps: Dependencies = {
|
|
511
|
-
fetch: () =>
|
|
512
|
-
Promise.resolve(
|
|
513
|
-
new Response("", {
|
|
514
|
-
status: 200,
|
|
515
|
-
headers: { "content-type": "text/event-stream" },
|
|
516
|
-
}),
|
|
517
|
-
),
|
|
518
|
-
scheduler: createDefaultScheduler(),
|
|
519
|
-
};
|
|
520
|
-
let seq = 0;
|
|
521
|
-
const events = await collect(
|
|
522
|
-
runInference({
|
|
523
|
-
turns: [userTurn("hi")],
|
|
524
|
-
source: SOURCE,
|
|
525
|
-
nextSeq: () => ++seq,
|
|
526
|
-
deps,
|
|
527
|
-
}),
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
const doneEvent = events.find((e) => e.type === "inference.done");
|
|
531
|
-
if (doneEvent === undefined) throw new Error("missing inference.done");
|
|
532
|
-
expect(doneEvent.data.source).toEqual({
|
|
533
|
-
sourceId: SOURCE.id,
|
|
534
|
-
provider: SOURCE.provider,
|
|
535
|
-
model: SOURCE.model,
|
|
536
|
-
});
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
test("two calls with different sources stamp their own descriptor", async () => {
|
|
540
|
-
const sourceA: InferenceSource = {
|
|
541
|
-
id: "anthropic:claude-A",
|
|
542
|
-
provider: "anthropic",
|
|
543
|
-
baseURL: "https://api.anthropic.com",
|
|
544
|
-
apiKey: "test",
|
|
545
|
-
model: "claude-A",
|
|
546
|
-
};
|
|
547
|
-
const sourceB: InferenceSource = {
|
|
548
|
-
id: "openai:gpt-B",
|
|
549
|
-
provider: "openai",
|
|
550
|
-
baseURL: "https://api.openai.test/v1",
|
|
551
|
-
apiKey: "test",
|
|
552
|
-
model: "gpt-B",
|
|
553
|
-
};
|
|
554
|
-
const deps: Dependencies = {
|
|
555
|
-
fetch: () =>
|
|
556
|
-
Promise.resolve(
|
|
557
|
-
new Response("", {
|
|
558
|
-
status: 200,
|
|
559
|
-
headers: { "content-type": "text/event-stream" },
|
|
560
|
-
}),
|
|
561
|
-
),
|
|
562
|
-
scheduler: createDefaultScheduler(),
|
|
563
|
-
};
|
|
564
|
-
|
|
565
|
-
let seq = 0;
|
|
566
|
-
const eventsA = await collect(
|
|
567
|
-
runInference({
|
|
568
|
-
turns: [userTurn("call-A")],
|
|
569
|
-
source: sourceA,
|
|
570
|
-
nextSeq: () => ++seq,
|
|
571
|
-
deps,
|
|
572
|
-
}),
|
|
573
|
-
);
|
|
574
|
-
const doneA = eventsA.find((e) => e.type === "inference.done");
|
|
575
|
-
if (doneA === undefined) throw new Error("missing inference.done for A");
|
|
576
|
-
expect(doneA.data.source).toEqual({
|
|
577
|
-
sourceId: "anthropic:claude-A",
|
|
578
|
-
provider: "anthropic",
|
|
579
|
-
model: "claude-A",
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
seq = 0;
|
|
583
|
-
const eventsB = await collect(
|
|
584
|
-
runInference({
|
|
585
|
-
turns: [userTurn("call-B")],
|
|
586
|
-
source: sourceB,
|
|
587
|
-
nextSeq: () => ++seq,
|
|
588
|
-
deps,
|
|
589
|
-
}),
|
|
590
|
-
);
|
|
591
|
-
const doneB = eventsB.find((e) => e.type === "inference.done");
|
|
592
|
-
if (doneB === undefined) throw new Error("missing inference.done for B");
|
|
593
|
-
expect(doneB.data.source).toEqual({
|
|
594
|
-
sourceId: "openai:gpt-B",
|
|
595
|
-
provider: "openai",
|
|
596
|
-
model: "gpt-B",
|
|
597
|
-
});
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
test("hot-swap mid-call: inference.done reflects the call-start source, not the post-mutation fields", async () => {
|
|
601
|
-
// Simulate the harness's `setSource` pattern: a single InferenceSource
|
|
602
|
-
// object is mutated in place via `applyInferenceSourceFields`. If the
|
|
603
|
-
// call captured a reference to the live object instead of snapshotting
|
|
604
|
-
// its identifying fields, the descriptor on inference.done would
|
|
605
|
-
// observe whatever id/provider/model the swap mutated in.
|
|
606
|
-
const activeSource: InferenceSource = {
|
|
607
|
-
id: "anthropic:claude-pre",
|
|
608
|
-
provider: "anthropic",
|
|
609
|
-
baseURL: "https://api.anthropic.com",
|
|
610
|
-
apiKey: "test",
|
|
611
|
-
model: "claude-pre",
|
|
612
|
-
};
|
|
613
|
-
|
|
614
|
-
// Mutate the source between fetch invocation and stream consumption.
|
|
615
|
-
// The Response body resolves synchronously here, but the harness still
|
|
616
|
-
// reads `source.*` (model in the request body) on the way out; the
|
|
617
|
-
// post-mutation values must NOT show up on the inference.done event
|
|
618
|
-
// because the snapshot at call start already captured the pre-swap
|
|
619
|
-
// identity.
|
|
620
|
-
const deps: Dependencies = {
|
|
621
|
-
fetch: () => {
|
|
622
|
-
activeSource.id = "openai:gpt-post";
|
|
623
|
-
activeSource.provider = "openai";
|
|
624
|
-
activeSource.baseURL = "https://api.openai.test/v1";
|
|
625
|
-
activeSource.apiKey = "test-post";
|
|
626
|
-
activeSource.model = "gpt-post";
|
|
627
|
-
return Promise.resolve(
|
|
628
|
-
new Response("", {
|
|
629
|
-
status: 200,
|
|
630
|
-
headers: { "content-type": "text/event-stream" },
|
|
631
|
-
}),
|
|
632
|
-
);
|
|
633
|
-
},
|
|
634
|
-
scheduler: createDefaultScheduler(),
|
|
635
|
-
};
|
|
636
|
-
|
|
637
|
-
let seq = 0;
|
|
638
|
-
const events = await collect(
|
|
639
|
-
runInference({
|
|
640
|
-
turns: [userTurn("hi")],
|
|
641
|
-
source: activeSource,
|
|
642
|
-
nextSeq: () => ++seq,
|
|
643
|
-
deps,
|
|
644
|
-
}),
|
|
645
|
-
);
|
|
646
|
-
|
|
647
|
-
const doneEvent = events.find((e) => e.type === "inference.done");
|
|
648
|
-
if (doneEvent === undefined) throw new Error("missing inference.done");
|
|
649
|
-
expect(doneEvent.data.source).toEqual({
|
|
650
|
-
sourceId: "anthropic:claude-pre",
|
|
651
|
-
provider: "anthropic",
|
|
652
|
-
model: "claude-pre",
|
|
653
|
-
});
|
|
654
|
-
});
|
|
655
|
-
});
|