@intx/inference-discovery-openai 0.1.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/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # @intx/inference-discovery-openai
2
+
3
+ OpenAI-protocol provider plug-in for the discovery rig. The package
4
+ is organised in two layers:
5
+
6
+ - `protocol/` — the OpenAI Chat Completions wire format. Auth
7
+ header construction, endpoint URL assembly, request-body builder,
8
+ and the multi-step iterator. Reusable across any relay that
9
+ speaks the OpenAI surface.
10
+ - `deployments/` — concrete deployments built on the protocol
11
+ layer. Each deployment names a provider, lists its models,
12
+ declares its auth and redaction policy, and (where needed)
13
+ overrides reasoning extraction. Today the only deployment is
14
+ OpenCode Zen.
15
+
16
+ See [`@intx/inference-discovery`](../inference-discovery/README.md)
17
+ for the runtime, the plug-in contract, and the `discover` CLI.
18
+
19
+ ## OpenCode Zen
20
+
21
+ OpenCode Zen is an OpenAI-compatible Chat Completions relay that
22
+ fronts upstream model providers behind a single endpoint. The
23
+ `/zen/v1` tier exposes hosted GPT, Claude, and Gemini alongside the
24
+ open-weights catalog (Moonshot, Z.AI, DeepSeek, Alibaba, Xiaomi
25
+ MiMo). Earlier captures targeted the narrower `/zen/go/v1` open-
26
+ weights tier; the v1 endpoint is a superset and existing fixtures
27
+ re-run unchanged against it.
28
+
29
+ ```ts
30
+ import { createOpencodeZenPlugin } from "@intx/inference-discovery-openai";
31
+
32
+ const plugin = createOpencodeZenPlugin({
33
+ apiKey: process.env.OPENAI_API_KEY,
34
+ baseUrl: process.env.OPENAI_BASE_URL,
35
+ });
36
+ // Hand off to runCapture from @intx/inference-discovery.
37
+ ```
38
+
39
+ Models: `kimi-k2.6`, `glm-5.1`, `deepseek-v4-pro`, `qwen3.6-plus`,
40
+ `mimo-v2-omni`.
41
+
42
+ For the per-model, per-capability behaviour observed at capture
43
+ time — including the discrepancies between vendor documentation
44
+ and the actual wire bytes — see
45
+ [`docs/OPENCODE_DISCOVERY.md`](../../docs/OPENCODE_DISCOVERY.md).
46
+ The matrix entries for this deployment live in `SUPPORT_MATRIX`;
47
+ two vision entries are marked `refused` and `http-error` and so
48
+ produce no fixtures.
49
+
50
+ ### Reasoning trace extraction
51
+
52
+ OpenCode Zen routes `kimi-k2.6` between two upstream backends that
53
+ emit reasoning content under different field paths. The deployment
54
+ ships a reasoning extractor that probes the known paths and records
55
+ which one held the non-empty value. For non-streaming reasoning
56
+ captures the runner writes the result to `reasoning-trace.json`
57
+ next to the response so a later routing change is detectable from
58
+ the fixtures alone; streaming reasoning captures do not get the
59
+ sidecar (the runner does not parse SSE bodies), and the routing
60
+ signal lives in the captured event stream itself.
61
+
62
+ ### Environment
63
+
64
+ | Variable | Purpose |
65
+ | ----------------- | ------------------------------------------------------------ |
66
+ | `OPENAI_API_KEY` | Sent as `Authorization: Bearer <key>`. Redacted in fixtures. |
67
+ | `OPENAI_BASE_URL` | Relay base URL (e.g. `https://opencode.ai/zen/v1`). |
68
+
69
+ ## Adding a new deployment
70
+
71
+ A new OpenAI-compatible relay is a new file under `deployments/`
72
+ that imports the protocol-layer helpers, declares the provider
73
+ name + model list + redaction policy, and exports a factory that
74
+ returns a plug-in for the runtime.
75
+
76
+ Then register the new plug-in in `bin/discover.ts` and add its
77
+ (model, capability) entries to `SUPPORT_MATRIX` in
78
+ `@intx/inference-discovery/catalog`. Run `bun bin/discover.ts
79
+ --provider <new-name> --all` against a funded account to produce
80
+ the fixture corpus.
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@intx/inference-discovery-openai",
3
+ "version": "0.1.2",
4
+ "license": "LGPL-2.1-only",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@intx/inference-discovery": "0.0.0",
14
+ "arktype": "^2.1.29"
15
+ }
16
+ }
@@ -0,0 +1,93 @@
1
+ import type { ProviderPlugin } from "@intx/inference-discovery";
2
+ import { buildAuthHeaders } from "../protocol/auth";
3
+ import { createOpenaiIterator } from "../protocol/iterator";
4
+
5
+ const PROVIDER_NAME = "opencode-zen";
6
+
7
+ const OPENCODE_ZEN_MODELS: readonly string[] = [
8
+ "kimi-k2.6",
9
+ "glm-5.1",
10
+ "deepseek-v4-pro",
11
+ "qwen3.6-plus",
12
+ "mimo-v2-omni",
13
+ ];
14
+
15
+ const REDACT_REQUEST_HEADERS: readonly string[] = ["authorization"];
16
+ const REDACT_RESPONSE_HEADERS: readonly string[] = [
17
+ "set-cookie",
18
+ "x-request-id",
19
+ ];
20
+
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+
25
+ function lookupPath(
26
+ value: unknown,
27
+ path: readonly (string | number)[],
28
+ ): unknown {
29
+ let cursor: unknown = value;
30
+ for (const segment of path) {
31
+ if (cursor === null || cursor === undefined) return undefined;
32
+ if (typeof segment === "number") {
33
+ if (!Array.isArray(cursor)) return undefined;
34
+ cursor = cursor[segment];
35
+ } else {
36
+ if (!isRecord(cursor)) return undefined;
37
+ cursor = cursor[segment];
38
+ }
39
+ }
40
+ return cursor;
41
+ }
42
+
43
+ function isNonEmpty(value: unknown): boolean {
44
+ if (value === null || value === undefined) return false;
45
+ if (typeof value === "string") return value.length > 0;
46
+ if (Array.isArray(value)) return value.length > 0;
47
+ if (typeof value === "object") return Object.keys(value).length > 0;
48
+ return true;
49
+ }
50
+
51
+ export interface ReasoningTrace {
52
+ fieldPath: string;
53
+ sample: unknown;
54
+ }
55
+
56
+ // kimi-k2.6 silently routes between two upstream backends that emit
57
+ // reasoning under different field paths. Recording which path a given
58
+ // capture hit is the cheapest way to detect routing changes later.
59
+ const REASONING_FIELD_PATHS: readonly (readonly (string | number)[])[] = [
60
+ ["choices", 0, "message", "reasoning_content"],
61
+ ["choices", 0, "message", "reasoning"],
62
+ ["choices", 0, "message", "reasoning_details"],
63
+ ];
64
+
65
+ export function extractReasoningTrace(parsed: unknown): ReasoningTrace | null {
66
+ for (const path of REASONING_FIELD_PATHS) {
67
+ const value = lookupPath(parsed, path);
68
+ if (isNonEmpty(value)) {
69
+ return { fieldPath: path.join("."), sample: value };
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ export interface CreateOpencodeZenPluginOpts {
76
+ apiKey: string;
77
+ baseUrl: string;
78
+ }
79
+
80
+ export function createOpencodeZenPlugin(
81
+ opts: CreateOpencodeZenPluginOpts,
82
+ ): ProviderPlugin {
83
+ const { apiKey, baseUrl } = opts;
84
+ return {
85
+ name: PROVIDER_NAME,
86
+ models: OPENCODE_ZEN_MODELS,
87
+ redactRequestHeaders: REDACT_REQUEST_HEADERS,
88
+ redactResponseHeaders: REDACT_RESPONSE_HEADERS,
89
+ buildAuthHeaders: () => buildAuthHeaders(apiKey),
90
+ extractReasoningTrace,
91
+ iterateCaptureSteps: createOpenaiIterator(baseUrl),
92
+ };
93
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export {
2
+ createOpencodeZenPlugin,
3
+ extractReasoningTrace,
4
+ type CreateOpencodeZenPluginOpts,
5
+ type ReasoningTrace,
6
+ } from "./deployments/opencode-zen";
@@ -0,0 +1,488 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ INTENTS,
6
+ SUPPORT_MATRIX,
7
+ getFixtureDir,
8
+ type Capability,
9
+ type SupportEntry,
10
+ } from "@intx/inference-discovery/catalog";
11
+ import type { CaptureStep, CapturedResponse } from "@intx/inference-discovery";
12
+ import { createOpencodeZenPlugin } from "./index";
13
+ import { extractReasoningTrace } from "./deployments/opencode-zen";
14
+ import { createOpenaiIterator } from "./protocol/iterator";
15
+ import { buildRequestBody } from "./protocol/body";
16
+
17
+ const REPO_ROOT = path.resolve(import.meta.dirname, "..", "..", "..");
18
+
19
+ const TEST_API_KEY = "test-key";
20
+ const TEST_BASE_URL = "https://opencode.ai/zen/go/v1";
21
+ const TEST_CHAT_URL = `${TEST_BASE_URL}/chat/completions`;
22
+
23
+ const MULTI_TURN_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
24
+ "function-calling-multi-turn",
25
+ ]);
26
+
27
+ function makePlugin() {
28
+ return createOpencodeZenPlugin({
29
+ apiKey: TEST_API_KEY,
30
+ baseUrl: TEST_BASE_URL,
31
+ });
32
+ }
33
+
34
+ function loadFixtureJSON(entry: SupportEntry, ...parts: string[]): unknown {
35
+ const relDir = getFixtureDir(entry);
36
+ if (relDir === null) {
37
+ throw new Error(
38
+ `entry has no fixture dir: ${entry.provider}/${entry.model}/${entry.capability}`,
39
+ );
40
+ }
41
+ const filePath = path.join(REPO_ROOT, relDir, ...parts);
42
+ return JSON.parse(readFileSync(filePath, "utf8"));
43
+ }
44
+
45
+ type Schema =
46
+ | { kind: "scalar" }
47
+ | { kind: "array"; element: Schema }
48
+ | { kind: "object"; fields: Record<string, Schema> }
49
+ | { kind: "empty-array" };
50
+
51
+ function isRecord(value: unknown): value is Record<string, unknown> {
52
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53
+ }
54
+
55
+ function isScalar(value: unknown): boolean {
56
+ return (
57
+ value === null ||
58
+ typeof value === "boolean" ||
59
+ typeof value === "number" ||
60
+ typeof value === "string"
61
+ );
62
+ }
63
+
64
+ function mergeSchemas(a: Schema, b: Schema): Schema {
65
+ if (a.kind === "empty-array") return b;
66
+ if (b.kind === "empty-array") return a;
67
+ if (a.kind !== b.kind) {
68
+ throw new Error(
69
+ `incompatible array element schemas: ${a.kind} vs ${b.kind}`,
70
+ );
71
+ }
72
+ if (a.kind === "object" && b.kind === "object") {
73
+ const merged: Record<string, Schema> = { ...a.fields };
74
+ for (const [key, schemaB] of Object.entries(b.fields)) {
75
+ const existing = merged[key];
76
+ merged[key] = existing ? mergeSchemas(existing, schemaB) : schemaB;
77
+ }
78
+ return { kind: "object", fields: merged };
79
+ }
80
+ if (a.kind === "array" && b.kind === "array") {
81
+ return { kind: "array", element: mergeSchemas(a.element, b.element) };
82
+ }
83
+ return a;
84
+ }
85
+
86
+ function extractSchema(value: unknown): Schema {
87
+ if (isScalar(value)) return { kind: "scalar" };
88
+ if (Array.isArray(value)) {
89
+ if (value.length === 0) return { kind: "empty-array" };
90
+ let element: Schema = extractSchema(value[0]);
91
+ for (let i = 1; i < value.length; i++) {
92
+ element = mergeSchemas(element, extractSchema(value[i]));
93
+ }
94
+ return { kind: "array", element };
95
+ }
96
+ if (isRecord(value)) {
97
+ const fields: Record<string, Schema> = {};
98
+ for (const [key, child] of Object.entries(value)) {
99
+ fields[key] = extractSchema(child);
100
+ }
101
+ return { kind: "object", fields };
102
+ }
103
+ throw new Error(`unsupported value type: ${typeof value}`);
104
+ }
105
+
106
+ const EPHEMERAL_KEYS = new Set(["reasoning_content", "name", "index"]);
107
+
108
+ function pruneEphemeral(value: unknown): unknown {
109
+ if (Array.isArray(value)) {
110
+ return value.map((v) => pruneEphemeral(v));
111
+ }
112
+ if (isRecord(value)) {
113
+ const out: Record<string, unknown> = {};
114
+ for (const [key, child] of Object.entries(value)) {
115
+ if (EPHEMERAL_KEYS.has(key)) continue;
116
+ out[key] = pruneEphemeral(child);
117
+ }
118
+ return out;
119
+ }
120
+ return value;
121
+ }
122
+
123
+ function schemaContains(actual: Schema, expected: Schema): boolean {
124
+ if (expected.kind === "empty-array") {
125
+ return actual.kind === "array" || actual.kind === "empty-array";
126
+ }
127
+ if (actual.kind === "empty-array") {
128
+ return false;
129
+ }
130
+ if (actual.kind !== expected.kind) return false;
131
+ if (actual.kind === "object" && expected.kind === "object") {
132
+ for (const [key, expectedChild] of Object.entries(expected.fields)) {
133
+ const actualChild = actual.fields[key];
134
+ if (!actualChild) return false;
135
+ if (!schemaContains(actualChild, expectedChild)) return false;
136
+ }
137
+ return true;
138
+ }
139
+ if (actual.kind === "array" && expected.kind === "array") {
140
+ return schemaContains(actual.element, expected.element);
141
+ }
142
+ return true;
143
+ }
144
+
145
+ function describeSchema(schema: Schema, indent = 0): string {
146
+ const pad = " ".repeat(indent);
147
+ if (schema.kind === "object") {
148
+ const entries = Object.entries(schema.fields)
149
+ .map(([k, v]) => `${pad} ${k}: ${describeSchema(v, indent + 1)}`)
150
+ .join("\n");
151
+ return `{\n${entries}\n${pad}}`;
152
+ }
153
+ if (schema.kind === "array") {
154
+ return `${describeSchema(schema.element, indent)}[]`;
155
+ }
156
+ return schema.kind;
157
+ }
158
+
159
+ const OPENCODE_CAPTURED: SupportEntry[] = SUPPORT_MATRIX.filter(
160
+ (e) => e.provider === "opencode-zen" && e.outcome === "captured",
161
+ );
162
+
163
+ function collectSteps(opts: {
164
+ model: string;
165
+ capability: Capability;
166
+ responses: readonly CapturedResponse[];
167
+ }): CaptureStep[] {
168
+ const intent = INTENTS[opts.capability];
169
+ const iter = createOpenaiIterator(TEST_BASE_URL)({
170
+ model: opts.model,
171
+ capability: opts.capability,
172
+ intent,
173
+ });
174
+ const steps: CaptureStep[] = [];
175
+ let i = 0;
176
+ let next = iter.next();
177
+ while (!next.done) {
178
+ steps.push(next.value);
179
+ const response = opts.responses[i];
180
+ i += 1;
181
+ if (response === undefined) break;
182
+ next = iter.next(response);
183
+ }
184
+ return steps;
185
+ }
186
+
187
+ describe("createOpencodeZenPlugin", () => {
188
+ test("exposes the five OpenCode-Zen models", () => {
189
+ const plugin = makePlugin();
190
+ expect(plugin.name).toBe("opencode-zen");
191
+ expect([...plugin.models].sort()).toEqual(
192
+ [
193
+ "deepseek-v4-pro",
194
+ "glm-5.1",
195
+ "kimi-k2.6",
196
+ "mimo-v2-omni",
197
+ "qwen3.6-plus",
198
+ ].sort(),
199
+ );
200
+ });
201
+
202
+ test("redacts the Authorization header", () => {
203
+ const plugin = makePlugin();
204
+ expect(plugin.redactRequestHeaders).toContain("authorization");
205
+ });
206
+
207
+ test("buildAuthHeaders attaches Bearer token", () => {
208
+ const plugin = makePlugin();
209
+ const headers = plugin.buildAuthHeaders();
210
+ expect(headers.Authorization).toBe("Bearer test-key");
211
+ });
212
+ });
213
+
214
+ describe("buildRequestBody capability dispatch", () => {
215
+ test("plain-text returns user message with no stream flag", () => {
216
+ const body = buildRequestBody({
217
+ model: "kimi-k2.6",
218
+ capability: "plain-text",
219
+ intent: INTENTS["plain-text"],
220
+ });
221
+ if (!isRecord(body)) throw new Error("expected record body");
222
+ expect(body.model).toBe("kimi-k2.6");
223
+ expect(body.stream).toBeUndefined();
224
+ const messages = body.messages;
225
+ if (!Array.isArray(messages)) throw new Error("expected messages array");
226
+ expect(messages.length).toBe(1);
227
+ });
228
+
229
+ test("plain-text-streaming sets stream: true", () => {
230
+ const body = buildRequestBody({
231
+ model: "kimi-k2.6",
232
+ capability: "plain-text-streaming",
233
+ intent: INTENTS["plain-text-streaming"],
234
+ });
235
+ if (!isRecord(body)) throw new Error("expected record body");
236
+ expect(body.stream).toBe(true);
237
+ });
238
+
239
+ test("function-calling produces messages + tools", () => {
240
+ const body = buildRequestBody({
241
+ model: "kimi-k2.6",
242
+ capability: "function-calling",
243
+ intent: INTENTS["function-calling"],
244
+ });
245
+ if (!isRecord(body)) throw new Error("expected record body");
246
+ expect(Array.isArray(body.tools)).toBe(true);
247
+ });
248
+
249
+ test("function-calling-multi-turn throws (multi-step capability)", () => {
250
+ expect(() =>
251
+ buildRequestBody({
252
+ model: "kimi-k2.6",
253
+ capability: "function-calling-multi-turn",
254
+ intent: INTENTS["function-calling-multi-turn"],
255
+ }),
256
+ ).toThrow(/multi-step capability/);
257
+ });
258
+
259
+ test("vision-input embeds image as data URI", () => {
260
+ const body = buildRequestBody({
261
+ model: "kimi-k2.6",
262
+ capability: "vision-input",
263
+ intent: INTENTS["vision-input"],
264
+ });
265
+ if (!isRecord(body)) throw new Error("expected record body");
266
+ const messages = body.messages;
267
+ if (!Array.isArray(messages) || !isRecord(messages[0])) {
268
+ throw new Error("expected messages[0] to be a record");
269
+ }
270
+ const content = messages[0].content;
271
+ if (!Array.isArray(content)) throw new Error("expected content array");
272
+ const imagePart = content.find(
273
+ (p) => isRecord(p) && p.type === "image_url",
274
+ );
275
+ if (!isRecord(imagePart)) throw new Error("expected image_url part");
276
+ const imageUrl = imagePart.image_url;
277
+ if (!isRecord(imageUrl)) throw new Error("expected image_url record");
278
+ expect(typeof imageUrl.url).toBe("string");
279
+ expect(String(imageUrl.url).startsWith("data:image/jpeg;base64,")).toBe(
280
+ true,
281
+ );
282
+ });
283
+
284
+ test("reasoning-content uses user message only", () => {
285
+ const body = buildRequestBody({
286
+ model: "kimi-k2.6",
287
+ capability: "reasoning-content",
288
+ intent: INTENTS["reasoning-content"],
289
+ });
290
+ if (!isRecord(body)) throw new Error("expected record body");
291
+ expect(body.stream).toBeUndefined();
292
+ });
293
+
294
+ test("reasoning-content-streaming sets stream: true", () => {
295
+ const body = buildRequestBody({
296
+ model: "kimi-k2.6",
297
+ capability: "reasoning-content-streaming",
298
+ intent: INTENTS["reasoning-content-streaming"],
299
+ });
300
+ if (!isRecord(body)) throw new Error("expected record body");
301
+ expect(body.stream).toBe(true);
302
+ });
303
+
304
+ test("throws on capability not in opencode-zen support set", () => {
305
+ expect(() =>
306
+ buildRequestBody({
307
+ model: "kimi-k2.6",
308
+ capability: "audio-input",
309
+ intent: INTENTS["audio-input"],
310
+ }),
311
+ ).toThrow();
312
+ });
313
+ });
314
+
315
+ describe("fixture oracle: every captured (model, capability) matches structure", () => {
316
+ test("there is at least one captured opencode-zen entry", () => {
317
+ expect(OPENCODE_CAPTURED.length).toBeGreaterThan(0);
318
+ });
319
+
320
+ for (const entry of OPENCODE_CAPTURED) {
321
+ test(`structural match: ${entry.model} / ${entry.capability}`, () => {
322
+ if (MULTI_TURN_CAPABILITIES.has(entry.capability)) {
323
+ const turn1Response: CapturedResponse = {
324
+ status: 200,
325
+ headers: {},
326
+ parsed: loadFixtureJSON(entry, "turn-1", "response.json"),
327
+ bytes: null,
328
+ };
329
+ const steps = collectSteps({
330
+ model: entry.model,
331
+ capability: entry.capability,
332
+ responses: [turn1Response],
333
+ });
334
+ expect(steps.length).toBe(2);
335
+ const [step1, step2] = steps;
336
+ if (step1 === undefined || step2 === undefined) {
337
+ throw new Error("expected two steps for multi-turn");
338
+ }
339
+ expect(step1.subdir).toBe("turn-1");
340
+ expect(step2.subdir).toBe("turn-2");
341
+ expect(step1.url).toBe(TEST_CHAT_URL);
342
+ expect(step2.url).toBe(TEST_CHAT_URL);
343
+
344
+ const captured1 = loadFixtureJSON(entry, "turn-1", "request.json");
345
+ const captured2 = loadFixtureJSON(entry, "turn-2", "request.json");
346
+
347
+ const cap1Schema = extractSchema(pruneEphemeral(captured1));
348
+ const built1Schema = extractSchema(pruneEphemeral(step1.body));
349
+ if (!schemaContains(built1Schema, cap1Schema)) {
350
+ throw new Error(
351
+ [
352
+ "turn-1 schema mismatch",
353
+ `captured: ${describeSchema(cap1Schema)}`,
354
+ `built: ${describeSchema(built1Schema)}`,
355
+ ].join("\n"),
356
+ );
357
+ }
358
+
359
+ const cap2Schema = extractSchema(pruneEphemeral(captured2));
360
+ const built2Schema = extractSchema(pruneEphemeral(step2.body));
361
+ if (!schemaContains(built2Schema, cap2Schema)) {
362
+ throw new Error(
363
+ [
364
+ "turn-2 schema mismatch",
365
+ `captured: ${describeSchema(cap2Schema)}`,
366
+ `built: ${describeSchema(built2Schema)}`,
367
+ ].join("\n"),
368
+ );
369
+ }
370
+ return;
371
+ }
372
+
373
+ const captured = loadFixtureJSON(entry, "request.json");
374
+ const steps = collectSteps({
375
+ model: entry.model,
376
+ capability: entry.capability,
377
+ responses: [],
378
+ });
379
+ expect(steps.length).toBe(1);
380
+ const [only] = steps;
381
+ if (only === undefined) throw new Error("expected one step");
382
+ expect(only.subdir).toBeNull();
383
+ expect(only.url).toBe(TEST_CHAT_URL);
384
+
385
+ const capturedSchema = extractSchema(pruneEphemeral(captured));
386
+ const builtSchema = extractSchema(pruneEphemeral(only.body));
387
+
388
+ const ok = schemaContains(builtSchema, capturedSchema);
389
+ if (!ok) {
390
+ const msg = [
391
+ "schema mismatch",
392
+ `captured: ${describeSchema(capturedSchema)}`,
393
+ `built: ${describeSchema(builtSchema)}`,
394
+ ].join("\n");
395
+ throw new Error(msg);
396
+ }
397
+
398
+ if (!isRecord(only.body)) {
399
+ throw new Error("expected built body to be record");
400
+ }
401
+ expect(only.body.model).toBe(entry.model);
402
+
403
+ const wantsStream = entry.capability.endsWith("-streaming");
404
+ if (wantsStream) {
405
+ expect(only.body.stream).toBe(true);
406
+ }
407
+ });
408
+ }
409
+ });
410
+
411
+ describe("extractReasoningTrace", () => {
412
+ test("returns trace for Moonshot-style reasoning_details", () => {
413
+ const parsed = {
414
+ choices: [
415
+ {
416
+ message: {
417
+ reasoning_details: [{ text: "step one" }],
418
+ },
419
+ },
420
+ ],
421
+ };
422
+ const trace = extractReasoningTrace(parsed);
423
+ expect(trace).not.toBeNull();
424
+ if (trace === null) throw new Error("expected trace");
425
+ expect(trace.fieldPath).toBe("choices.0.message.reasoning_details");
426
+ });
427
+
428
+ test("returns trace for Moonshot-style reasoning field", () => {
429
+ const parsed = {
430
+ choices: [
431
+ {
432
+ message: {
433
+ reasoning: "thought process",
434
+ },
435
+ },
436
+ ],
437
+ };
438
+ const trace = extractReasoningTrace(parsed);
439
+ expect(trace).not.toBeNull();
440
+ if (trace === null) throw new Error("expected trace");
441
+ expect(trace.fieldPath).toBe("choices.0.message.reasoning");
442
+ });
443
+
444
+ test("returns trace for Fireworks-style reasoning_content", () => {
445
+ const parsed = {
446
+ choices: [
447
+ {
448
+ message: {
449
+ reasoning_content: "chain of thought",
450
+ },
451
+ },
452
+ ],
453
+ };
454
+ const trace = extractReasoningTrace(parsed);
455
+ expect(trace).not.toBeNull();
456
+ if (trace === null) throw new Error("expected trace");
457
+ expect(trace.fieldPath).toBe("choices.0.message.reasoning_content");
458
+ });
459
+
460
+ test("returns null when no reasoning fields present", () => {
461
+ const parsed = {
462
+ choices: [{ message: { content: "regular text" } }],
463
+ };
464
+ expect(extractReasoningTrace(parsed)).toBeNull();
465
+ });
466
+
467
+ test("returns null for empty reasoning fields", () => {
468
+ const parsed = {
469
+ choices: [{ message: { reasoning: "" } }],
470
+ };
471
+ expect(extractReasoningTrace(parsed)).toBeNull();
472
+ });
473
+
474
+ test("returns null for malformed input", () => {
475
+ expect(extractReasoningTrace(null)).toBeNull();
476
+ expect(extractReasoningTrace("string")).toBeNull();
477
+ expect(extractReasoningTrace({})).toBeNull();
478
+ });
479
+ });
480
+
481
+ describe("plugin via stub fetch", () => {
482
+ test("plug-in returned by factory is callable and well-formed", () => {
483
+ const plugin = makePlugin();
484
+ expect(typeof plugin.buildAuthHeaders).toBe("function");
485
+ expect(typeof plugin.iterateCaptureSteps).toBe("function");
486
+ expect(typeof plugin.extractReasoningTrace).toBe("function");
487
+ });
488
+ });
@@ -0,0 +1,5 @@
1
+ export function buildAuthHeaders(apiKey: string): Record<string, string> {
2
+ return {
3
+ Authorization: `Bearer ${apiKey}`,
4
+ };
5
+ }