@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.
Files changed (97) hide show
  1. package/LICENSE +176 -0
  2. package/dist/actions.d.ts +16 -0
  3. package/dist/actions.js +200 -0
  4. package/dist/adapter.d.ts +38 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +68 -0
  7. package/dist/assembly.js +132 -0
  8. package/dist/audit-collector.d.ts +10 -0
  9. package/dist/audit-collector.js +139 -0
  10. package/dist/auth.d.ts +24 -0
  11. package/{src/auth.ts → dist/auth.js} +13 -19
  12. package/dist/authz-extension.d.ts +32 -0
  13. package/dist/authz-extension.js +100 -0
  14. package/dist/correlation.d.ts +25 -0
  15. package/dist/correlation.js +32 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +199 -0
  18. package/dist/director.d.ts +6 -0
  19. package/dist/director.js +56 -0
  20. package/dist/errors.d.ts +18 -0
  21. package/dist/errors.js +83 -0
  22. package/dist/gates.d.ts +27 -0
  23. package/dist/gates.js +80 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1319 -0
  26. package/dist/index.d.ts +37 -0
  27. package/dist/index.js +21 -0
  28. package/dist/manifest.d.ts +31 -0
  29. package/dist/manifest.js +44 -0
  30. package/dist/providers/anthropic.d.ts +33 -0
  31. package/dist/providers/anthropic.js +670 -0
  32. package/dist/providers/google-genai-files.d.ts +48 -0
  33. package/dist/providers/google-genai-files.js +205 -0
  34. package/dist/providers/google-genai.d.ts +3 -0
  35. package/dist/providers/google-genai.js +1196 -0
  36. package/dist/providers/index.d.ts +38 -0
  37. package/dist/providers/index.js +56 -0
  38. package/dist/providers/openai.d.ts +3 -0
  39. package/dist/providers/openai.js +609 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +920 -0
  42. package/dist/retry-policy.d.ts +31 -0
  43. package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
  44. package/dist/sse.d.ts +1 -0
  45. package/dist/sse.js +63 -0
  46. package/dist/state.d.ts +23 -0
  47. package/dist/state.js +100 -0
  48. package/dist/tool-name.d.ts +6 -0
  49. package/dist/tool-name.js +110 -0
  50. package/dist/transform.d.ts +11 -0
  51. package/dist/transform.js +117 -0
  52. package/dist/transforms/index.d.ts +2 -0
  53. package/dist/transforms/index.js +1 -0
  54. package/dist/transforms/size-cap.d.ts +12 -0
  55. package/dist/transforms/size-cap.js +80 -0
  56. package/dist/turns.d.ts +21 -0
  57. package/dist/turns.js +135 -0
  58. package/package.json +21 -6
  59. package/src/actions.ts +0 -245
  60. package/src/adapter.ts +0 -57
  61. package/src/assembly.test.ts +0 -728
  62. package/src/assembly.ts +0 -250
  63. package/src/audit-collector.test.ts +0 -332
  64. package/src/audit-collector.ts +0 -172
  65. package/src/auth.test.ts +0 -117
  66. package/src/authz-extension.test.ts +0 -269
  67. package/src/authz-extension.ts +0 -145
  68. package/src/correlation.ts +0 -61
  69. package/src/default-director.test.ts +0 -314
  70. package/src/default-director.ts +0 -344
  71. package/src/director.ts +0 -87
  72. package/src/errors.test.ts +0 -133
  73. package/src/errors.ts +0 -115
  74. package/src/gates.ts +0 -128
  75. package/src/harness.test.ts +0 -655
  76. package/src/harness.ts +0 -1571
  77. package/src/index.ts +0 -76
  78. package/src/providers/anthropic.test.ts +0 -771
  79. package/src/providers/anthropic.ts +0 -810
  80. package/src/providers/google-genai-files.ts +0 -289
  81. package/src/providers/google-genai.ts +0 -1518
  82. package/src/providers/openai.ts +0 -719
  83. package/src/providers/registry.ts +0 -33
  84. package/src/reactor.test.ts +0 -3660
  85. package/src/reactor.ts +0 -1058
  86. package/src/scheduler.test.ts +0 -41
  87. package/src/sse.test.ts +0 -133
  88. package/src/sse.ts +0 -76
  89. package/src/state.ts +0 -135
  90. package/src/transform.test.ts +0 -207
  91. package/src/transform.ts +0 -159
  92. package/src/transforms/index.ts +0 -2
  93. package/src/transforms/size-cap.test.ts +0 -172
  94. package/src/transforms/size-cap.ts +0 -110
  95. package/src/turns.ts +0 -54
  96. package/tsconfig.json +0 -4
  97. package/tsconfig.tsbuildinfo +0 -1
@@ -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
- });