@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 +80 -0
- package/package.json +16 -0
- package/src/deployments/opencode-zen.ts +93 -0
- package/src/index.ts +6 -0
- package/src/plugin.test.ts +488 -0
- package/src/protocol/auth.ts +5 -0
- package/src/protocol/body.ts +302 -0
- package/src/protocol/endpoint.ts +3 -0
- package/src/protocol/iterator.ts +59 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
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,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
|
+
});
|