@intx/inference-discovery 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.
@@ -0,0 +1,72 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { type } from "arktype";
3
+ import { FixtureManifest } from "./manifest";
4
+
5
+ describe("FixtureManifest validator", () => {
6
+ test("accepts a well-formed manifest", () => {
7
+ const result = FixtureManifest({
8
+ provider: "google-genai",
9
+ model: "gemini-2.5-flash",
10
+ capability: "plain-text",
11
+ capturedAt: "2026-05-20T15:00:00Z",
12
+ schemaVersion: "1",
13
+ });
14
+ expect(result instanceof type.errors).toBe(false);
15
+ });
16
+
17
+ test("accepts an explicit null observedModelVersion", () => {
18
+ const result = FixtureManifest({
19
+ provider: "opencode-zen",
20
+ model: "kimi-k2.6",
21
+ capability: "reasoning-content",
22
+ capturedAt: "2026-05-20T15:00:00Z",
23
+ observedModelVersion: null,
24
+ schemaVersion: "1",
25
+ });
26
+ expect(result instanceof type.errors).toBe(false);
27
+ });
28
+
29
+ test("accepts a populated observedModelVersion string", () => {
30
+ const result = FixtureManifest({
31
+ provider: "opencode-zen",
32
+ model: "kimi-k2.6",
33
+ capability: "plain-text",
34
+ capturedAt: "2026-05-20T15:00:00Z",
35
+ observedModelVersion: "moonshotai/kimi-k2.6-20260420",
36
+ schemaVersion: "1",
37
+ });
38
+ expect(result instanceof type.errors).toBe(false);
39
+ });
40
+
41
+ test("rejects a manifest with an unknown capability", () => {
42
+ const result = FixtureManifest({
43
+ provider: "google-genai",
44
+ model: "gemini-2.5-flash",
45
+ capability: "not-a-real-capability",
46
+ capturedAt: "2026-05-20T15:00:00Z",
47
+ schemaVersion: "1",
48
+ });
49
+ expect(result instanceof type.errors).toBe(true);
50
+ });
51
+
52
+ test("rejects a manifest with the wrong schemaVersion literal", () => {
53
+ const result = FixtureManifest({
54
+ provider: "google-genai",
55
+ model: "gemini-2.5-flash",
56
+ capability: "plain-text",
57
+ capturedAt: "2026-05-20T15:00:00Z",
58
+ schemaVersion: "2",
59
+ });
60
+ expect(result instanceof type.errors).toBe(true);
61
+ });
62
+
63
+ test("rejects a manifest missing required fields", () => {
64
+ const result = FixtureManifest({
65
+ provider: "google-genai",
66
+ capability: "plain-text",
67
+ capturedAt: "2026-05-20T15:00:00Z",
68
+ schemaVersion: "1",
69
+ });
70
+ expect(result instanceof type.errors).toBe(true);
71
+ });
72
+ });
@@ -0,0 +1,12 @@
1
+ import { type } from "arktype";
2
+ import { Capability } from "./capability";
3
+
4
+ export const FixtureManifest = type({
5
+ provider: "string",
6
+ model: "string",
7
+ capability: Capability,
8
+ capturedAt: "string",
9
+ "observedModelVersion?": "string | null",
10
+ schemaVersion: "'1'",
11
+ });
12
+ export type FixtureManifest = typeof FixtureManifest.infer;
@@ -0,0 +1,79 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { type } from "arktype";
3
+ import { SUPPORT_MATRIX, SupportEntry, getFixtureDir } from "./support-matrix";
4
+
5
+ describe("SUPPORT_MATRIX validation", () => {
6
+ test("every entry parses as a SupportEntry", () => {
7
+ for (const entry of SUPPORT_MATRIX) {
8
+ const result = SupportEntry(entry);
9
+ expect(result instanceof type.errors).toBe(false);
10
+ }
11
+ });
12
+
13
+ test("contains at least 22 google-genai captured entries", () => {
14
+ const count = SUPPORT_MATRIX.filter(
15
+ (entry) =>
16
+ entry.provider === "google-genai" && entry.outcome === "captured",
17
+ ).length;
18
+ expect(count).toBeGreaterThanOrEqual(22);
19
+ });
20
+
21
+ test("contains at least 33 opencode-zen captured entries", () => {
22
+ const count = SUPPORT_MATRIX.filter(
23
+ (entry) =>
24
+ entry.provider === "opencode-zen" && entry.outcome === "captured",
25
+ ).length;
26
+ expect(count).toBeGreaterThanOrEqual(33);
27
+ });
28
+
29
+ test("contains at least one non-captured opencode-zen entry with notes", () => {
30
+ const nonCaptured = SUPPORT_MATRIX.filter(
31
+ (entry) =>
32
+ entry.provider === "opencode-zen" && entry.outcome !== "captured",
33
+ );
34
+ expect(nonCaptured.length).toBeGreaterThanOrEqual(1);
35
+ for (const entry of nonCaptured) {
36
+ expect(typeof entry.notes).toBe("string");
37
+ expect((entry.notes ?? "").length).toBeGreaterThan(0);
38
+ }
39
+ });
40
+
41
+ test("no duplicate (provider, model, capability) triples", () => {
42
+ const seen = new Set<string>();
43
+ for (const entry of SUPPORT_MATRIX) {
44
+ const key = `${entry.provider}|${entry.model}|${entry.capability}`;
45
+ expect(seen.has(key)).toBe(false);
46
+ seen.add(key);
47
+ }
48
+ });
49
+ });
50
+
51
+ describe("getFixtureDir", () => {
52
+ test("returns a wire-relative path for a captured entry", () => {
53
+ const captured = SUPPORT_MATRIX.find((e) => e.outcome === "captured");
54
+ expect(captured).toBeDefined();
55
+ if (captured === undefined) return;
56
+ const dir = getFixtureDir(captured);
57
+ expect(dir).toBe(
58
+ `packages/inference-testing/wire/${captured.provider}/${captured.model}/${captured.capability}`,
59
+ );
60
+ });
61
+
62
+ test("returns a wire-relative path for a misled entry", () => {
63
+ const misled = SUPPORT_MATRIX.find((e) => e.outcome === "misled");
64
+ if (misled === undefined) return;
65
+ const dir = getFixtureDir(misled);
66
+ expect(dir).toBe(
67
+ `packages/inference-testing/wire/${misled.provider}/${misled.model}/${misled.capability}`,
68
+ );
69
+ });
70
+
71
+ test("returns null for an entry without a fixture", () => {
72
+ const noFixture = SUPPORT_MATRIX.find(
73
+ (e) => e.outcome !== "captured" && e.outcome !== "misled",
74
+ );
75
+ expect(noFixture).toBeDefined();
76
+ if (noFixture === undefined) return;
77
+ expect(getFixtureDir(noFixture)).toBeNull();
78
+ });
79
+ });
@@ -0,0 +1,344 @@
1
+ import { type } from "arktype";
2
+ import { Capability } from "./capability";
3
+
4
+ // Outcome vocabulary:
5
+ //
6
+ // - captured: HTTP succeeded and the response contains the wire shape
7
+ // the capability's name implies. Fixture on disk; smoke test validates.
8
+ //
9
+ // - misled: HTTP succeeded and the model responded normally, but the
10
+ // provider's documented contract for the input did not materialize.
11
+ // The model did not refuse — there is no statement of inability in
12
+ // the response — the documented behavior just did not fire. Used when
13
+ // the wire shape is conditional on external state we do not control
14
+ // (e.g. Anthropic's safety classifier not engaging on the documented
15
+ // redacted-thinking canary). The fixture on disk documents what was
16
+ // actually returned; smoke test validates file presence. A future
17
+ // re-capture may flip the row to captured.
18
+ //
19
+ // - refused: the provider's response contains an explicit refusal of
20
+ // the requested task. The model told us it would not do the thing —
21
+ // sometimes via HTTP non-2xx, sometimes via a successful HTTP body
22
+ // carrying a textual refusal. No fixture by convention; the refusal
23
+ // detail goes in notes.
24
+ //
25
+ // - http-error: the provider returned a non-2xx HTTP status. No fixture.
26
+ //
27
+ // - unsupported: the provider does not support this capability. No
28
+ // fixture, no attempt made.
29
+ export const SupportEntry = type({
30
+ provider: "string",
31
+ model: "string",
32
+ capability: Capability,
33
+ outcome: "'captured' | 'misled' | 'refused' | 'http-error' | 'unsupported'",
34
+ "notes?": "string",
35
+ });
36
+ export type SupportEntry = typeof SupportEntry.infer;
37
+
38
+ const ANTHROPIC_PROVIDER = "anthropic";
39
+ const ANTHROPIC_MODELS = [
40
+ "claude-sonnet-4-5-20250929",
41
+ "claude-opus-4-1-20250805",
42
+ "claude-haiku-4-5-20251001",
43
+ ] as const;
44
+
45
+ const GEMINI_PROVIDER = "google-genai";
46
+ const GEMINI_TEXT_MODEL = "gemini-2.5-flash";
47
+ const GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image";
48
+
49
+ const OPENCODE_PROVIDER = "opencode-zen";
50
+
51
+ const GEMINI_TEXT_CAPABILITIES = [
52
+ "plain-text",
53
+ "plain-text-streaming",
54
+ "function-calling-multi-turn",
55
+ "function-calling-multi-turn-streaming",
56
+ "function-calling-with-thinking",
57
+ "function-calling-with-thinking-streaming",
58
+ "vision-input",
59
+ "vision-input-streaming",
60
+ "audio-input",
61
+ "audio-input-streaming",
62
+ "video-input",
63
+ "video-input-streaming",
64
+ "document-input",
65
+ "document-input-streaming",
66
+ "code-execution",
67
+ "code-execution-streaming",
68
+ "grounding",
69
+ "grounding-streaming",
70
+ "files-api-reference",
71
+ "files-api-reference-streaming",
72
+ "structured-output",
73
+ "structured-output-streaming",
74
+ ] as const satisfies readonly SupportEntry["capability"][];
75
+
76
+ const GEMINI_TEXT_MISLED_CAPABILITIES = [
77
+ "safety-classification",
78
+ "safety-classification-streaming",
79
+ ] as const satisfies readonly SupportEntry["capability"][];
80
+
81
+ const GEMINI_IMAGE_CAPABILITIES = [
82
+ "image-output",
83
+ "image-output-streaming",
84
+ ] as const satisfies readonly SupportEntry["capability"][];
85
+
86
+ const OPENCODE_FULL_CAPABILITIES = [
87
+ "plain-text",
88
+ "plain-text-streaming",
89
+ "function-calling",
90
+ "function-calling-multi-turn",
91
+ "reasoning-content",
92
+ "reasoning-content-streaming",
93
+ "vision-input",
94
+ ] as const satisfies readonly SupportEntry["capability"][];
95
+
96
+ const OPENCODE_NON_VISION_CAPABILITIES = [
97
+ "plain-text",
98
+ "plain-text-streaming",
99
+ "function-calling",
100
+ "function-calling-multi-turn",
101
+ "reasoning-content",
102
+ "reasoning-content-streaming",
103
+ ] as const satisfies readonly SupportEntry["capability"][];
104
+
105
+ function gemini(
106
+ model: string,
107
+ capabilities: readonly SupportEntry["capability"][],
108
+ ): SupportEntry[] {
109
+ return capabilities.map((capability) => ({
110
+ provider: GEMINI_PROVIDER,
111
+ model,
112
+ capability,
113
+ outcome: "captured",
114
+ }));
115
+ }
116
+
117
+ function geminiMisled(
118
+ model: string,
119
+ capabilities: readonly SupportEntry["capability"][],
120
+ notes: string,
121
+ ): SupportEntry[] {
122
+ return capabilities.map((capability) => ({
123
+ provider: GEMINI_PROVIDER,
124
+ model,
125
+ capability,
126
+ outcome: "misled",
127
+ notes,
128
+ }));
129
+ }
130
+
131
+ function opencode(
132
+ model: string,
133
+ capabilities: readonly SupportEntry["capability"][],
134
+ ): SupportEntry[] {
135
+ return capabilities.map((capability) => ({
136
+ provider: OPENCODE_PROVIDER,
137
+ model,
138
+ capability,
139
+ outcome: "captured",
140
+ }));
141
+ }
142
+
143
+ const ANTHROPIC_CAPTURED_CAPABILITIES = [
144
+ "plain-text",
145
+ "plain-text-streaming",
146
+ "function-calling",
147
+ "function-calling-multi-turn",
148
+ "function-calling-multi-turn-streaming",
149
+ "function-calling-with-thinking",
150
+ "function-calling-with-thinking-streaming",
151
+ "vision-input",
152
+ "vision-input-streaming",
153
+ "document-input",
154
+ "document-input-streaming",
155
+ "code-execution",
156
+ "code-execution-streaming",
157
+ "reasoning-content",
158
+ "reasoning-content-streaming",
159
+ "files-api-reference",
160
+ "files-api-reference-streaming",
161
+ "grounding",
162
+ "grounding-streaming",
163
+ ] as const satisfies readonly SupportEntry["capability"][];
164
+
165
+ const ANTHROPIC_MISLED_CAPABILITIES = [
166
+ "redacted-thinking",
167
+ "redacted-thinking-streaming",
168
+ ] as const satisfies readonly SupportEntry["capability"][];
169
+
170
+ const ANTHROPIC_UNSUPPORTED_INPUT_MODALITIES = [
171
+ "audio-input",
172
+ "audio-input-streaming",
173
+ "video-input",
174
+ "video-input-streaming",
175
+ ] as const satisfies readonly SupportEntry["capability"][];
176
+
177
+ const ANTHROPIC_UNSUPPORTED_OUTPUT_MODALITIES = [
178
+ "image-output",
179
+ "image-output-streaming",
180
+ ] as const satisfies readonly SupportEntry["capability"][];
181
+
182
+ const ANTHROPIC_UNSUPPORTED_STRUCTURED_OUTPUTS = [
183
+ "structured-output",
184
+ "structured-output-streaming",
185
+ ] as const satisfies readonly SupportEntry["capability"][];
186
+
187
+ function anthropic(
188
+ model: string,
189
+ capabilities: readonly SupportEntry["capability"][],
190
+ ): SupportEntry[] {
191
+ return capabilities.map((capability) => ({
192
+ provider: ANTHROPIC_PROVIDER,
193
+ model,
194
+ capability,
195
+ outcome: "captured",
196
+ }));
197
+ }
198
+
199
+ function anthropicUnsupported(
200
+ model: string,
201
+ capabilities: readonly SupportEntry["capability"][],
202
+ notes: string,
203
+ ): SupportEntry[] {
204
+ return capabilities.map((capability) => ({
205
+ provider: ANTHROPIC_PROVIDER,
206
+ model,
207
+ capability,
208
+ outcome: "unsupported",
209
+ notes,
210
+ }));
211
+ }
212
+
213
+ function anthropicMisled(
214
+ model: string,
215
+ capabilities: readonly SupportEntry["capability"][],
216
+ notes: string,
217
+ ): SupportEntry[] {
218
+ return capabilities.map((capability) => ({
219
+ provider: ANTHROPIC_PROVIDER,
220
+ model,
221
+ capability,
222
+ outcome: "misled",
223
+ notes,
224
+ }));
225
+ }
226
+
227
+ const MATRIX: SupportEntry[] = [
228
+ ...ANTHROPIC_MODELS.flatMap((model) =>
229
+ anthropic(model, ANTHROPIC_CAPTURED_CAPABILITIES),
230
+ ),
231
+ ...ANTHROPIC_MODELS.flatMap((model) =>
232
+ anthropicMisled(
233
+ model,
234
+ ANTHROPIC_MISLED_CAPABILITIES,
235
+ "Anthropic's documentation describes the canary prompt as a deterministic trigger for a redacted_thinking content block. On capture day the safety classifier did not fire on any first-party model; the assistant response carries a regular thinking block instead. The fixture on disk documents what the wire actually returned for the documented input. The plug-in and SSE parser already accept redacted_thinking blocks, so a future re-capture on a day the classifier does fire will flip this row to captured without code changes.",
236
+ ),
237
+ ),
238
+ ...ANTHROPIC_MODELS.flatMap((model) =>
239
+ anthropicUnsupported(
240
+ model,
241
+ ANTHROPIC_UNSUPPORTED_INPUT_MODALITIES,
242
+ "Anthropic's first-party Claude models do not accept audio or video inputs; the Messages API content array only permits text, image, document, tool_use, and tool_result blocks. No equivalent server-side ingestion path exists today.",
243
+ ),
244
+ ),
245
+ ...ANTHROPIC_MODELS.flatMap((model) =>
246
+ anthropicUnsupported(
247
+ model,
248
+ ANTHROPIC_UNSUPPORTED_OUTPUT_MODALITIES,
249
+ "Anthropic's first-party Claude models do not emit images; the Messages API surface is text-only on the output side and has no responseModalities-style toggle.",
250
+ ),
251
+ ),
252
+ ...ANTHROPIC_MODELS.flatMap((model) =>
253
+ anthropicUnsupported(
254
+ model,
255
+ ANTHROPIC_UNSUPPORTED_STRUCTURED_OUTPUTS,
256
+ "Anthropic's Messages API has no native structured-outputs surface. The internal adapter rejects responseFormat values of json and json-schema at the marshaling boundary rather than synthesizing a hidden tool-input wrapper; callers needing structured output route through a provider with native support.",
257
+ ),
258
+ ),
259
+ ...gemini(GEMINI_TEXT_MODEL, GEMINI_TEXT_CAPABILITIES),
260
+ ...gemini(GEMINI_IMAGE_MODEL, GEMINI_IMAGE_CAPABILITIES),
261
+ ...geminiMisled(
262
+ GEMINI_TEXT_MODEL,
263
+ GEMINI_TEXT_MISLED_CAPABILITIES,
264
+ 'Probe prompt did not engage Gemini\'s structured safety classifier on capture day. The model self-refused via response text content but `safetyRatings`, `promptFeedback`, and `finishReason: "SAFETY"` are all absent from the response. The fixture on disk documents what the wire actually returned for the documented probe input. A future re-capture (different prompt, different classifier thresholds, or different model behavior) may flip this row to captured without code changes once a structured safety signal materializes.',
265
+ ),
266
+ ...opencode("kimi-k2.6", OPENCODE_FULL_CAPABILITIES),
267
+ ...opencode("mimo-v2-omni", OPENCODE_FULL_CAPABILITIES),
268
+ ...opencode("qwen3.6-plus", OPENCODE_FULL_CAPABILITIES),
269
+ ...opencode("glm-5.1", OPENCODE_NON_VISION_CAPABILITIES),
270
+ ...opencode("deepseek-v4-pro", OPENCODE_NON_VISION_CAPABILITIES),
271
+ ...opencode("gpt-5.4-mini", [
272
+ "structured-output",
273
+ "structured-output-streaming",
274
+ ]),
275
+ ...opencode("kimi-k2.6", [
276
+ "structured-output",
277
+ "structured-output-streaming",
278
+ ]),
279
+ ...opencode("glm-5.1", ["structured-output", "structured-output-streaming"]),
280
+ ...opencode("qwen3.6-plus", [
281
+ "structured-output",
282
+ "structured-output-streaming",
283
+ ]),
284
+ {
285
+ provider: OPENCODE_PROVIDER,
286
+ model: "deepseek-v4-pro",
287
+ capability: "structured-output",
288
+ outcome: "http-error",
289
+ notes:
290
+ "Probe against /zen/v1 returned HTTP 401 with body {type:'error',error:{type:'ModelError',message:'Model deepseek-v4-pro is not supported'}}. The HTTP status is the relay's chosen code for the routing miss, not an auth failure; deepseek-v4-pro's reasoning-content captures live on the older /zen/go/v1 tier and the v1 tier does not route the model.",
291
+ },
292
+ {
293
+ provider: OPENCODE_PROVIDER,
294
+ model: "deepseek-v4-pro",
295
+ capability: "structured-output-streaming",
296
+ outcome: "http-error",
297
+ notes:
298
+ "Probe against /zen/v1 returned HTTP 401 with body {type:'error',error:{type:'ModelError',message:'Model deepseek-v4-pro is not supported'}}. The HTTP status is the relay's chosen code for the routing miss, not an auth failure; deepseek-v4-pro's reasoning-content captures live on the older /zen/go/v1 tier and the v1 tier does not route the model.",
299
+ },
300
+ {
301
+ provider: OPENCODE_PROVIDER,
302
+ model: "mimo-v2-omni",
303
+ capability: "structured-output",
304
+ outcome: "http-error",
305
+ notes:
306
+ "Probe against /zen/v1 returned HTTP 401 with body {type:'error',error:{type:'ModelError',message:'Model mimo-v2-omni is not supported'}}. The HTTP status is the relay's chosen code for the routing miss, not an auth failure; mimo-v2-omni's other captures live on the older /zen/go/v1 tier and the v1 tier does not route the model.",
307
+ },
308
+ {
309
+ provider: OPENCODE_PROVIDER,
310
+ model: "mimo-v2-omni",
311
+ capability: "structured-output-streaming",
312
+ outcome: "http-error",
313
+ notes:
314
+ "Probe against /zen/v1 returned HTTP 401 with body {type:'error',error:{type:'ModelError',message:'Model mimo-v2-omni is not supported'}}. The HTTP status is the relay's chosen code for the routing miss, not an auth failure; mimo-v2-omni's other captures live on the older /zen/go/v1 tier and the v1 tier does not route the model.",
315
+ },
316
+ {
317
+ provider: OPENCODE_PROVIDER,
318
+ model: "glm-5.1",
319
+ capability: "vision-input",
320
+ outcome: "refused",
321
+ notes:
322
+ "Probe returned HTTP 200 with the textual refusal 'Please provide an image so I can describe it for you' rather than a real image description; recorded as 'refused' here so no capture is attempted.",
323
+ },
324
+ {
325
+ provider: OPENCODE_PROVIDER,
326
+ model: "deepseek-v4-pro",
327
+ capability: "vision-input",
328
+ outcome: "http-error",
329
+ notes:
330
+ "OpenAI-style multimodal messages[].content elicits HTTP 400 invalid_request_error \"unknown variant 'image_url', expected 'text'\"; recorded as 'http-error' here so no capture is attempted.",
331
+ },
332
+ ];
333
+
334
+ export const SUPPORT_MATRIX: readonly SupportEntry[] = MATRIX;
335
+
336
+ const FIXTURE_ROOT = "packages/inference-testing/wire";
337
+
338
+ export function getFixtureDir(entry: SupportEntry): string | null {
339
+ // captured and misled rows both carry fixtures on disk; the smoke
340
+ // test validates either flavor for file presence. refused / http-
341
+ // error / unsupported rows do not.
342
+ if (entry.outcome !== "captured" && entry.outcome !== "misled") return null;
343
+ return `${FIXTURE_ROOT}/${entry.provider}/${entry.model}/${entry.capability}`;
344
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { assertNotCI } from "./ci-guard";
3
+
4
+ describe("assertNotCI", () => {
5
+ test("does nothing when CI is unset", () => {
6
+ expect(() => assertNotCI({})).not.toThrow();
7
+ });
8
+
9
+ test("does nothing when CI is an empty string", () => {
10
+ expect(() => assertNotCI({ CI: "" })).not.toThrow();
11
+ });
12
+
13
+ test("throws when CI is 'true'", () => {
14
+ expect(() => assertNotCI({ CI: "true" })).toThrow(/must not run in CI/);
15
+ });
16
+
17
+ test("throws when CI is '1'", () => {
18
+ expect(() => assertNotCI({ CI: "1" })).toThrow(/must not run in CI/);
19
+ });
20
+
21
+ test("reads process.env by default", () => {
22
+ const original = process.env.CI;
23
+ try {
24
+ delete process.env.CI;
25
+ expect(() => assertNotCI()).not.toThrow();
26
+ process.env.CI = "yes";
27
+ expect(() => assertNotCI()).toThrow(/must not run in CI/);
28
+ } finally {
29
+ if (original === undefined) {
30
+ delete process.env.CI;
31
+ } else {
32
+ process.env.CI = original;
33
+ }
34
+ }
35
+ });
36
+ });
@@ -0,0 +1,9 @@
1
+ export function assertNotCI(env: NodeJS.ProcessEnv = process.env): void {
2
+ const value = env.CI;
3
+ if (value !== undefined && value !== "") {
4
+ throw new Error(
5
+ "Discovery runs make live network calls and must not run in CI. " +
6
+ "Unset the CI environment variable to proceed.",
7
+ );
8
+ }
9
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { parseCLI } from "./cli";
3
+
4
+ describe("parseCLI", () => {
5
+ test("returns help for --help", () => {
6
+ const result = parseCLI(["--help"]);
7
+ expect(result.kind).toBe("help");
8
+ if (result.kind === "help") {
9
+ expect(result.message).toMatch(/Usage: discover/);
10
+ }
11
+ });
12
+
13
+ test("returns help for -h", () => {
14
+ expect(parseCLI(["-h"]).kind).toBe("help");
15
+ });
16
+
17
+ test("returns error when --provider missing", () => {
18
+ const result = parseCLI(["--all"]);
19
+ expect(result.kind).toBe("error");
20
+ if (result.kind === "error") {
21
+ expect(result.message).toMatch(/--provider/);
22
+ }
23
+ });
24
+
25
+ test("returns run for --provider X --all", () => {
26
+ const result = parseCLI(["--provider", "google-genai", "--all"]);
27
+ expect(result.kind).toBe("run");
28
+ if (result.kind === "run") {
29
+ expect(result.provider).toBe("google-genai");
30
+ expect(result.all).toBe(true);
31
+ expect(result.models).toEqual([]);
32
+ expect(result.capabilities).toEqual([]);
33
+ }
34
+ });
35
+
36
+ test("collects repeated --model flags", () => {
37
+ const result = parseCLI([
38
+ "--provider",
39
+ "p",
40
+ "--model",
41
+ "m1",
42
+ "--model",
43
+ "m2",
44
+ ]);
45
+ expect(result.kind).toBe("run");
46
+ if (result.kind === "run") {
47
+ expect(result.models).toEqual(["m1", "m2"]);
48
+ }
49
+ });
50
+
51
+ test("collects repeated --only flags", () => {
52
+ const result = parseCLI([
53
+ "--provider",
54
+ "p",
55
+ "--only",
56
+ "plain-text",
57
+ "--only",
58
+ "plain-text-streaming",
59
+ ]);
60
+ expect(result.kind).toBe("run");
61
+ if (result.kind === "run") {
62
+ expect(result.capabilities).toEqual([
63
+ "plain-text",
64
+ "plain-text-streaming",
65
+ ]);
66
+ }
67
+ });
68
+
69
+ test("rejects --all combined with --model", () => {
70
+ const result = parseCLI(["--provider", "p", "--all", "--model", "m"]);
71
+ expect(result.kind).toBe("error");
72
+ if (result.kind === "error") {
73
+ expect(result.message).toMatch(/mutually exclusive/);
74
+ }
75
+ });
76
+
77
+ test("rejects --all combined with --only", () => {
78
+ const result = parseCLI([
79
+ "--provider",
80
+ "p",
81
+ "--all",
82
+ "--only",
83
+ "plain-text",
84
+ ]);
85
+ expect(result.kind).toBe("error");
86
+ });
87
+
88
+ test("rejects no scope flags without --all", () => {
89
+ const result = parseCLI(["--provider", "p"]);
90
+ expect(result.kind).toBe("error");
91
+ if (result.kind === "error") {
92
+ expect(result.message).toMatch(/--all|--model|--only/);
93
+ }
94
+ });
95
+
96
+ test("rejects unknown flag", () => {
97
+ const result = parseCLI(["--provider", "p", "--bogus"]);
98
+ expect(result.kind).toBe("error");
99
+ if (result.kind === "error") {
100
+ expect(result.message).toMatch(/Unknown argument: --bogus/);
101
+ }
102
+ });
103
+
104
+ test("rejects --provider without value", () => {
105
+ const result = parseCLI(["--provider"]);
106
+ expect(result.kind).toBe("error");
107
+ if (result.kind === "error") {
108
+ expect(result.message).toMatch(/requires a value/);
109
+ }
110
+ });
111
+
112
+ test("rejects duplicate --provider", () => {
113
+ const result = parseCLI(["--provider", "a", "--provider", "b", "--all"]);
114
+ expect(result.kind).toBe("error");
115
+ if (result.kind === "error") {
116
+ expect(result.message).toMatch(/only be specified once/);
117
+ }
118
+ });
119
+
120
+ test("rejects --model without value", () => {
121
+ const result = parseCLI(["--provider", "p", "--model"]);
122
+ expect(result.kind).toBe("error");
123
+ });
124
+
125
+ test("rejects --model followed by another flag as value", () => {
126
+ const result = parseCLI(["--provider", "p", "--model", "--all"]);
127
+ expect(result.kind).toBe("error");
128
+ });
129
+ });