ai-pipe 1.0.0

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,100 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { generateCompletions } from "../completions.ts";
3
+ import { SUPPORTED_PROVIDERS } from "../provider.ts";
4
+
5
+ describe("generateCompletions", () => {
6
+ // ── bash ─────────────────────────────────────────────────────────
7
+
8
+ describe("bash", () => {
9
+ test("generates valid bash completions", () => {
10
+ const result = generateCompletions("bash");
11
+ expect(result).toContain("_ai_pipe_completions");
12
+ expect(result).toContain("complete -F _ai_pipe_completions ai-pipe");
13
+ });
14
+
15
+ test("includes all flags", () => {
16
+ const result = generateCompletions("bash");
17
+ expect(result).toContain("--model");
18
+ expect(result).toContain("--system");
19
+ expect(result).toContain("--json");
20
+ expect(result).toContain("--no-stream");
21
+ expect(result).toContain("--temperature");
22
+ expect(result).toContain("--max-output-tokens");
23
+ expect(result).toContain("--config");
24
+ expect(result).toContain("--providers");
25
+ expect(result).toContain("--completions");
26
+ expect(result).toContain("--version");
27
+ expect(result).toContain("--help");
28
+ });
29
+
30
+ test("includes all providers", () => {
31
+ const result = generateCompletions("bash");
32
+ for (const p of SUPPORTED_PROVIDERS) {
33
+ expect(result).toContain(p);
34
+ }
35
+ });
36
+
37
+ test("includes install instructions", () => {
38
+ const result = generateCompletions("bash");
39
+ expect(result).toContain('eval "$(ai-pipe --completions bash)"');
40
+ });
41
+ });
42
+
43
+ // ── zsh ──────────────────────────────────────────────────────────
44
+
45
+ describe("zsh", () => {
46
+ test("generates valid zsh completions", () => {
47
+ const result = generateCompletions("zsh");
48
+ expect(result).toContain("compdef _ai_pipe ai-pipe");
49
+ expect(result).toContain("_arguments");
50
+ });
51
+
52
+ test("includes provider suggestions with trailing slash", () => {
53
+ const result = generateCompletions("zsh");
54
+ for (const p of SUPPORTED_PROVIDERS) {
55
+ expect(result).toContain(`'${p}/'`);
56
+ }
57
+ });
58
+
59
+ test("includes install instructions", () => {
60
+ const result = generateCompletions("zsh");
61
+ expect(result).toContain('eval "$(ai-pipe --completions zsh)"');
62
+ });
63
+ });
64
+
65
+ // ── fish ─────────────────────────────────────────────────────────
66
+
67
+ describe("fish", () => {
68
+ test("generates valid fish completions", () => {
69
+ const result = generateCompletions("fish");
70
+ expect(result).toContain("complete -c ai-pipe");
71
+ });
72
+
73
+ test("includes all flags", () => {
74
+ const result = generateCompletions("fish");
75
+ expect(result).toContain("-l model");
76
+ expect(result).toContain("-l system");
77
+ expect(result).toContain("-l json");
78
+ expect(result).toContain("-l no-stream");
79
+ expect(result).toContain("-l temperature");
80
+ expect(result).toContain("-l max-output-tokens");
81
+ expect(result).toContain("-l config");
82
+ expect(result).toContain("-l providers");
83
+ expect(result).toContain("-l completions");
84
+ expect(result).toContain("-l version");
85
+ expect(result).toContain("-l help");
86
+ });
87
+
88
+ test("includes provider suggestions with trailing slash", () => {
89
+ const result = generateCompletions("fish");
90
+ for (const p of SUPPORTED_PROVIDERS) {
91
+ expect(result).toContain(`${p}/`);
92
+ }
93
+ });
94
+
95
+ test("includes install instructions", () => {
96
+ const result = generateCompletions("fish");
97
+ expect(result).toContain("~/.config/fish/completions/ai-pipe.fish");
98
+ });
99
+ });
100
+ });
@@ -0,0 +1,280 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { loadConfig, ConfigSchema } from "../config.ts";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { mkdirSync } from "node:fs";
6
+
7
+ const tmpDir = tmpdir();
8
+ const uid = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
9
+
10
+ function makeTmpDir(): string {
11
+ const dir = join(tmpDir, `ai-cfg-${uid()}`);
12
+ mkdirSync(dir, { recursive: true });
13
+ return dir;
14
+ }
15
+
16
+ describe("ConfigSchema", () => {
17
+ test("accepts empty object", () => {
18
+ expect(ConfigSchema.parse({})).toEqual({});
19
+ });
20
+
21
+ test("accepts full valid config", () => {
22
+ const result = ConfigSchema.parse({
23
+ model: "anthropic/claude-sonnet-4-5",
24
+ system: "Be concise.",
25
+ temperature: 0.7,
26
+ maxOutputTokens: 500,
27
+ });
28
+ expect(result.model).toBe("anthropic/claude-sonnet-4-5");
29
+ expect(result.system).toBe("Be concise.");
30
+ expect(result.temperature).toBe(0.7);
31
+ expect(result.maxOutputTokens).toBe(500);
32
+ });
33
+
34
+ test("accepts temperature at lower bound (0)", () => {
35
+ expect(ConfigSchema.parse({ temperature: 0 }).temperature).toBe(0);
36
+ });
37
+
38
+ test("accepts temperature at upper bound (2)", () => {
39
+ expect(ConfigSchema.parse({ temperature: 2 }).temperature).toBe(2);
40
+ });
41
+
42
+ test("accepts temperature at midpoint (1)", () => {
43
+ expect(ConfigSchema.parse({ temperature: 1 }).temperature).toBe(1);
44
+ });
45
+
46
+ test("rejects temperature below 0", () => {
47
+ expect(() => ConfigSchema.parse({ temperature: -0.1 })).toThrow();
48
+ });
49
+
50
+ test("rejects temperature above 2", () => {
51
+ expect(() => ConfigSchema.parse({ temperature: 2.1 })).toThrow();
52
+ });
53
+
54
+ test("rejects non-number temperature", () => {
55
+ expect(() => ConfigSchema.parse({ temperature: "hot" })).toThrow();
56
+ });
57
+
58
+ test("accepts maxOutputTokens = 1", () => {
59
+ expect(ConfigSchema.parse({ maxOutputTokens: 1 }).maxOutputTokens).toBe(1);
60
+ });
61
+
62
+ test("rejects maxOutputTokens = 0", () => {
63
+ expect(() => ConfigSchema.parse({ maxOutputTokens: 0 })).toThrow();
64
+ });
65
+
66
+ test("rejects negative maxOutputTokens", () => {
67
+ expect(() => ConfigSchema.parse({ maxOutputTokens: -100 })).toThrow();
68
+ });
69
+
70
+ test("rejects float maxOutputTokens", () => {
71
+ expect(() => ConfigSchema.parse({ maxOutputTokens: 99.5 })).toThrow();
72
+ });
73
+
74
+ test("rejects non-number maxOutputTokens", () => {
75
+ expect(() => ConfigSchema.parse({ maxOutputTokens: "many" })).toThrow();
76
+ });
77
+
78
+ test("rejects non-string model", () => {
79
+ expect(() => ConfigSchema.parse({ model: 123 })).toThrow();
80
+ });
81
+
82
+ test("rejects non-string system", () => {
83
+ expect(() => ConfigSchema.parse({ system: true })).toThrow();
84
+ });
85
+
86
+ test("strips unknown properties", () => {
87
+ const result = ConfigSchema.parse({ model: "openai/gpt-4o", unknown: true });
88
+ expect((result as Record<string, unknown>).unknown).toBeUndefined();
89
+ });
90
+
91
+ test("does not accept apiKeys (moved to separate file)", () => {
92
+ const result = ConfigSchema.parse({
93
+ model: "openai/gpt-4o",
94
+ apiKeys: { openai: "sk-test" },
95
+ });
96
+ expect((result as Record<string, unknown>).apiKeys).toBeUndefined();
97
+ });
98
+ });
99
+
100
+ describe("loadConfig", () => {
101
+ test("returns empty object when directory does not exist", async () => {
102
+ const config = await loadConfig("/nonexistent/path/config-dir");
103
+ expect(config).toEqual({});
104
+ });
105
+
106
+ test("returns empty object for empty directory", async () => {
107
+ const dir = makeTmpDir();
108
+ const config = await loadConfig(dir);
109
+ expect(config).toEqual({});
110
+ });
111
+
112
+ test("loads config.json only", async () => {
113
+ const dir = makeTmpDir();
114
+ await Bun.write(
115
+ join(dir, "config.json"),
116
+ JSON.stringify({
117
+ model: "anthropic/claude-sonnet-4-5",
118
+ system: "Be concise.",
119
+ temperature: 0.7,
120
+ maxOutputTokens: 500,
121
+ })
122
+ );
123
+
124
+ const config = await loadConfig(dir);
125
+ expect(config.model).toBe("anthropic/claude-sonnet-4-5");
126
+ expect(config.system).toBe("Be concise.");
127
+ expect(config.temperature).toBe(0.7);
128
+ expect(config.maxOutputTokens).toBe(500);
129
+ expect(config.apiKeys).toBeUndefined();
130
+ });
131
+
132
+ test("loads apiKeys.json only", async () => {
133
+ const dir = makeTmpDir();
134
+ await Bun.write(
135
+ join(dir, "apiKeys.json"),
136
+ JSON.stringify({ anthropic: "sk-ant-test", openai: "sk-test" })
137
+ );
138
+
139
+ const config = await loadConfig(dir);
140
+ expect(config.model).toBeUndefined();
141
+ expect(config.apiKeys).toEqual({ anthropic: "sk-ant-test", openai: "sk-test" });
142
+ });
143
+
144
+ test("loads both config.json and apiKeys.json", async () => {
145
+ const dir = makeTmpDir();
146
+ await Promise.all([
147
+ Bun.write(
148
+ join(dir, "config.json"),
149
+ JSON.stringify({ model: "anthropic/claude-sonnet-4-5" })
150
+ ),
151
+ Bun.write(
152
+ join(dir, "apiKeys.json"),
153
+ JSON.stringify({ anthropic: "sk-ant-test" })
154
+ ),
155
+ ]);
156
+
157
+ const config = await loadConfig(dir);
158
+ expect(config.model).toBe("anthropic/claude-sonnet-4-5");
159
+ expect(config.apiKeys).toEqual({ anthropic: "sk-ant-test" });
160
+ });
161
+
162
+ test("loads partial config.json (only model)", async () => {
163
+ const dir = makeTmpDir();
164
+ await Bun.write(join(dir, "config.json"), JSON.stringify({ model: "google/gemini-2.5-flash" }));
165
+
166
+ const config = await loadConfig(dir);
167
+ expect(config.model).toBe("google/gemini-2.5-flash");
168
+ expect(config.system).toBeUndefined();
169
+ expect(config.temperature).toBeUndefined();
170
+ expect(config.maxOutputTokens).toBeUndefined();
171
+ });
172
+
173
+ test("loads empty JSON object config.json", async () => {
174
+ const dir = makeTmpDir();
175
+ await Bun.write(join(dir, "config.json"), "{}");
176
+
177
+ const config = await loadConfig(dir);
178
+ expect(config).toEqual({});
179
+ });
180
+
181
+ test("ignores invalid JSON in config.json", async () => {
182
+ const dir = makeTmpDir();
183
+ await Bun.write(join(dir, "config.json"), "not valid json {{{");
184
+
185
+ const config = await loadConfig(dir);
186
+ expect(config).toEqual({});
187
+ });
188
+
189
+ test("ignores invalid JSON in apiKeys.json", async () => {
190
+ const dir = makeTmpDir();
191
+ await Bun.write(join(dir, "apiKeys.json"), "not valid json");
192
+
193
+ const config = await loadConfig(dir);
194
+ expect(config).toEqual({});
195
+ });
196
+
197
+ test("ignores zod-invalid config.json (temperature out of range)", async () => {
198
+ const dir = makeTmpDir();
199
+ await Bun.write(join(dir, "config.json"), JSON.stringify({ temperature: 5 }));
200
+
201
+ const config = await loadConfig(dir);
202
+ expect(config).toEqual({});
203
+ });
204
+
205
+ test("ignores zod-invalid config.json (bad type)", async () => {
206
+ const dir = makeTmpDir();
207
+ await Bun.write(join(dir, "config.json"), JSON.stringify({ model: 42 }));
208
+
209
+ const config = await loadConfig(dir);
210
+ expect(config).toEqual({});
211
+ });
212
+
213
+ test("ignores array JSON in config.json", async () => {
214
+ const dir = makeTmpDir();
215
+ await Bun.write(join(dir, "config.json"), "[1, 2, 3]");
216
+
217
+ const config = await loadConfig(dir);
218
+ expect(config).toEqual({});
219
+ });
220
+
221
+ test("ignores apiKeys.json with unknown provider", async () => {
222
+ const dir = makeTmpDir();
223
+ await Bun.write(
224
+ join(dir, "apiKeys.json"),
225
+ JSON.stringify({ fakeprovider: "sk-test" })
226
+ );
227
+
228
+ const config = await loadConfig(dir);
229
+ expect(config).toEqual({});
230
+ });
231
+
232
+ test("valid apiKeys.json with all providers", async () => {
233
+ const dir = makeTmpDir();
234
+ const keys = {
235
+ openai: "sk-1",
236
+ anthropic: "sk-2",
237
+ google: "sk-3",
238
+ perplexity: "sk-4",
239
+ xai: "sk-5",
240
+ mistral: "sk-6",
241
+ groq: "sk-7",
242
+ deepseek: "sk-8",
243
+ cohere: "sk-9",
244
+ openrouter: "sk-10",
245
+ };
246
+ await Bun.write(join(dir, "apiKeys.json"), JSON.stringify(keys));
247
+
248
+ const config = await loadConfig(dir);
249
+ expect(config.apiKeys).toEqual(keys);
250
+ });
251
+
252
+ test("invalid config.json does not affect valid apiKeys.json", async () => {
253
+ const dir = makeTmpDir();
254
+ await Promise.all([
255
+ Bun.write(join(dir, "config.json"), "bad json"),
256
+ Bun.write(join(dir, "apiKeys.json"), JSON.stringify({ openai: "sk-test" })),
257
+ ]);
258
+
259
+ const config = await loadConfig(dir);
260
+ expect(config.model).toBeUndefined();
261
+ expect(config.apiKeys).toEqual({ openai: "sk-test" });
262
+ });
263
+
264
+ test("invalid apiKeys.json does not affect valid config.json", async () => {
265
+ const dir = makeTmpDir();
266
+ await Promise.all([
267
+ Bun.write(join(dir, "config.json"), JSON.stringify({ model: "openai/gpt-4o" })),
268
+ Bun.write(join(dir, "apiKeys.json"), "bad json"),
269
+ ]);
270
+
271
+ const config = await loadConfig(dir);
272
+ expect(config.model).toBe("openai/gpt-4o");
273
+ expect(config.apiKeys).toBeUndefined();
274
+ });
275
+
276
+ test("uses default directory when none specified", async () => {
277
+ const config = await loadConfig(undefined);
278
+ expect(config).toBeDefined();
279
+ });
280
+ });
@@ -0,0 +1,67 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { APP, AppSchema, ShellSchema, type Shell, type AppConfig } from "../constants.ts";
3
+
4
+ describe("APP", () => {
5
+ test("validates against AppSchema", () => {
6
+ expect(AppSchema.parse(APP)).toEqual(APP);
7
+ });
8
+
9
+ test("has expected name", () => {
10
+ expect(APP.name).toBe("ai-pipe");
11
+ });
12
+
13
+ test("has expected default model", () => {
14
+ expect(APP.defaultModel).toBe("openai/gpt-4o");
15
+ });
16
+
17
+ test("has expected default provider", () => {
18
+ expect(APP.defaultProvider).toBe("openai");
19
+ });
20
+
21
+ test("temperature has valid range", () => {
22
+ expect(APP.temperature.min).toBeLessThan(APP.temperature.max);
23
+ expect(APP.temperature.min).toBe(0);
24
+ expect(APP.temperature.max).toBe(2);
25
+ });
26
+
27
+ test("has config directory and file names", () => {
28
+ expect(APP.configDirName).toBe(".ai-pipe");
29
+ expect(APP.configFile).toBe("config.json");
30
+ expect(APP.apiKeysFile).toBe("apiKeys.json");
31
+ });
32
+
33
+ test("has supported shells", () => {
34
+ expect(APP.supportedShells).toContain("bash");
35
+ expect(APP.supportedShells).toContain("zsh");
36
+ expect(APP.supportedShells).toContain("fish");
37
+ expect(APP.supportedShells).toHaveLength(3);
38
+ });
39
+ });
40
+
41
+ describe("ShellSchema", () => {
42
+ for (const shell of APP.supportedShells) {
43
+ test(`accepts "${shell}"`, () => {
44
+ expect(ShellSchema.parse(shell)).toBe(shell);
45
+ });
46
+ }
47
+
48
+ test("rejects unknown shell", () => {
49
+ expect(ShellSchema.safeParse("powershell").success).toBe(false);
50
+ });
51
+
52
+ test("rejects empty string", () => {
53
+ expect(ShellSchema.safeParse("").success).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe("AppSchema", () => {
58
+ test("rejects invalid config", () => {
59
+ const result = AppSchema.safeParse({ name: 123 });
60
+ expect(result.success).toBe(false);
61
+ });
62
+
63
+ test("rejects missing fields", () => {
64
+ const result = AppSchema.safeParse({});
65
+ expect(result.success).toBe(false);
66
+ });
67
+ });
@@ -0,0 +1,241 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { buildPrompt, resolveOptions, CLIOptionsSchema, JsonOutputSchema, type CLIOptions } from "../index.ts";
3
+ import type { Config } from "../config.ts";
4
+
5
+ // ── buildPrompt ────────────────────────────────────────────────────────
6
+
7
+ describe("buildPrompt", () => {
8
+ test("returns arg prompt when only args provided", () => {
9
+ expect(buildPrompt("explain monads", null)).toBe("explain monads");
10
+ });
11
+
12
+ test("returns stdin when only stdin provided", () => {
13
+ expect(buildPrompt(null, "hello world")).toBe("hello world");
14
+ });
15
+
16
+ test("combines arg prompt and stdin with double newline", () => {
17
+ const result = buildPrompt("review this code", "const x = 1;");
18
+ expect(result).toBe("review this code\n\nconst x = 1;");
19
+ });
20
+
21
+ test("returns null when neither provided", () => {
22
+ expect(buildPrompt(null, null)).toBeNull();
23
+ });
24
+
25
+ test("arg prompt comes first in combined output", () => {
26
+ const result = buildPrompt("summarize", "long document text")!;
27
+ expect(result.startsWith("summarize")).toBe(true);
28
+ expect(result.endsWith("long document text")).toBe(true);
29
+ });
30
+
31
+ test("preserves multiline stdin content", () => {
32
+ const result = buildPrompt("review", "line1\nline2\nline3");
33
+ expect(result).toBe("review\n\nline1\nline2\nline3");
34
+ });
35
+
36
+ test("preserves multiline arg prompt", () => {
37
+ const result = buildPrompt("do this\nand that", null);
38
+ expect(result).toBe("do this\nand that");
39
+ });
40
+ });
41
+
42
+ // ── resolveOptions ─────────────────────────────────────────────────────
43
+
44
+ describe("resolveOptions", () => {
45
+ const defaultOpts: CLIOptions = { json: false, stream: true };
46
+ const emptyConfig: Config = {};
47
+
48
+ test("uses built-in defaults when no flags or config", () => {
49
+ const result = resolveOptions(defaultOpts, emptyConfig);
50
+ expect(result.modelString).toBe("openai/gpt-4o");
51
+ expect(result.system).toBeUndefined();
52
+ expect(result.temperature).toBeUndefined();
53
+ expect(result.maxOutputTokens).toBeUndefined();
54
+ });
55
+
56
+ test("config overrides built-in defaults", () => {
57
+ const config: Config = {
58
+ model: "anthropic/claude-sonnet-4-5",
59
+ system: "Be concise.",
60
+ temperature: 0.5,
61
+ maxOutputTokens: 200,
62
+ };
63
+ const result = resolveOptions(defaultOpts, config);
64
+ expect(result.modelString).toBe("anthropic/claude-sonnet-4-5");
65
+ expect(result.system).toBe("Be concise.");
66
+ expect(result.temperature).toBe(0.5);
67
+ expect(result.maxOutputTokens).toBe(200);
68
+ });
69
+
70
+ test("CLI flags override config", () => {
71
+ const config: Config = {
72
+ model: "anthropic/claude-sonnet-4-5",
73
+ system: "config system",
74
+ temperature: 0.5,
75
+ };
76
+ const opts: CLIOptions = {
77
+ ...defaultOpts,
78
+ model: "google/gemini-2.5-flash",
79
+ system: "cli system",
80
+ temperature: 0.9,
81
+ maxOutputTokens: 1000,
82
+ };
83
+ const result = resolveOptions(opts, config);
84
+ expect(result.modelString).toBe("google/gemini-2.5-flash");
85
+ expect(result.system).toBe("cli system");
86
+ expect(result.temperature).toBe(0.9);
87
+ expect(result.maxOutputTokens).toBe(1000);
88
+ });
89
+
90
+ test("partial CLI flags merge with config", () => {
91
+ const config: Config = {
92
+ model: "anthropic/claude-sonnet-4-5",
93
+ system: "config system",
94
+ temperature: 0.5,
95
+ };
96
+ const opts: CLIOptions = { ...defaultOpts, temperature: 0.9 };
97
+ const result = resolveOptions(opts, config);
98
+ expect(result.modelString).toBe("anthropic/claude-sonnet-4-5");
99
+ expect(result.system).toBe("config system");
100
+ expect(result.temperature).toBe(0.9);
101
+ });
102
+
103
+ test("all undefined opts fall through to config", () => {
104
+ const config: Config = { model: "xai/grok-3", system: "sys", temperature: 1.5, maxOutputTokens: 300 };
105
+ const result = resolveOptions(defaultOpts, config);
106
+ expect(result.modelString).toBe("xai/grok-3");
107
+ expect(result.system).toBe("sys");
108
+ expect(result.temperature).toBe(1.5);
109
+ expect(result.maxOutputTokens).toBe(300);
110
+ });
111
+ });
112
+
113
+ // ── CLIOptionsSchema ───────────────────────────────────────────────────
114
+
115
+ describe("CLIOptionsSchema", () => {
116
+ test("accepts minimal valid options", () => {
117
+ const result = CLIOptionsSchema.parse({ json: false, stream: true });
118
+ expect(result.json).toBe(false);
119
+ expect(result.stream).toBe(true);
120
+ });
121
+
122
+ test("accepts full valid options", () => {
123
+ const result = CLIOptionsSchema.parse({
124
+ model: "anthropic/claude-sonnet-4-5",
125
+ system: "be concise",
126
+ json: true,
127
+ stream: false,
128
+ temperature: 1.0,
129
+ maxOutputTokens: 500,
130
+ config: "/path/to/config.json",
131
+ providers: true,
132
+ completions: "bash",
133
+ });
134
+ expect(result.model).toBe("anthropic/claude-sonnet-4-5");
135
+ expect(result.maxOutputTokens).toBe(500);
136
+ });
137
+
138
+ test("rejects temperature below 0", () => {
139
+ expect(
140
+ CLIOptionsSchema.safeParse({ json: false, stream: true, temperature: -1 }).success
141
+ ).toBe(false);
142
+ });
143
+
144
+ test("rejects temperature above 2", () => {
145
+ expect(
146
+ CLIOptionsSchema.safeParse({ json: false, stream: true, temperature: 3 }).success
147
+ ).toBe(false);
148
+ });
149
+
150
+ test("rejects negative maxOutputTokens", () => {
151
+ expect(
152
+ CLIOptionsSchema.safeParse({ json: false, stream: true, maxOutputTokens: -5 }).success
153
+ ).toBe(false);
154
+ });
155
+
156
+ test("rejects float maxOutputTokens", () => {
157
+ expect(
158
+ CLIOptionsSchema.safeParse({ json: false, stream: true, maxOutputTokens: 10.5 }).success
159
+ ).toBe(false);
160
+ });
161
+
162
+ test("rejects non-boolean json", () => {
163
+ expect(
164
+ CLIOptionsSchema.safeParse({ json: "yes", stream: true }).success
165
+ ).toBe(false);
166
+ });
167
+ });
168
+
169
+ // ── JsonOutputSchema ───────────────────────────────────────────────────
170
+
171
+ describe("JsonOutputSchema", () => {
172
+ test("accepts valid output", () => {
173
+ const result = JsonOutputSchema.parse({
174
+ text: "hello",
175
+ model: "openai/gpt-4o",
176
+ usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
177
+ finishReason: "stop",
178
+ });
179
+ expect(result.text).toBe("hello");
180
+ expect(result.finishReason).toBe("stop");
181
+ });
182
+
183
+ test("accepts output with optional token fields", () => {
184
+ const result = JsonOutputSchema.parse({
185
+ text: "",
186
+ model: "openai/gpt-4o",
187
+ usage: {},
188
+ finishReason: "length",
189
+ });
190
+ expect(result.usage.inputTokens).toBeUndefined();
191
+ expect(result.usage.inputTokenDetails).toBeUndefined();
192
+ expect(result.usage.outputTokenDetails).toBeUndefined();
193
+ });
194
+
195
+ test("accepts output with full token details", () => {
196
+ const result = JsonOutputSchema.parse({
197
+ text: "hi",
198
+ model: "openai/gpt-4o",
199
+ usage: {
200
+ inputTokens: 5,
201
+ outputTokens: 10,
202
+ totalTokens: 15,
203
+ inputTokenDetails: { noCacheTokens: 3, cacheReadTokens: 2, cacheWriteTokens: 0 },
204
+ outputTokenDetails: { textTokens: 8, reasoningTokens: 2 },
205
+ },
206
+ finishReason: "stop",
207
+ });
208
+ expect(result.usage.inputTokenDetails?.cacheReadTokens).toBe(2);
209
+ expect(result.usage.outputTokenDetails?.reasoningTokens).toBe(2);
210
+ });
211
+
212
+ test("rejects missing text", () => {
213
+ expect(
214
+ JsonOutputSchema.safeParse({
215
+ model: "openai/gpt-4o",
216
+ usage: {},
217
+ finishReason: "stop",
218
+ }).success
219
+ ).toBe(false);
220
+ });
221
+
222
+ test("rejects missing model", () => {
223
+ expect(
224
+ JsonOutputSchema.safeParse({
225
+ text: "hi",
226
+ usage: {},
227
+ finishReason: "stop",
228
+ }).success
229
+ ).toBe(false);
230
+ });
231
+
232
+ test("rejects missing finishReason", () => {
233
+ expect(
234
+ JsonOutputSchema.safeParse({
235
+ text: "hi",
236
+ model: "m",
237
+ usage: {},
238
+ }).success
239
+ ).toBe(false);
240
+ });
241
+ });