Skip to content

Commit 1e14a82

Browse files
feat(gateway): use native --json-schema for structured output (#68)
* refactor(gateway): remove legacy CLI output format Remove backwards compatibility for pre-1.0.17 Claude CLI output format. The project has always used modern Claude CLI versions, so the legacy `total_tokens_in/out` fields were never needed. - Remove `total_tokens_in/out` from ClaudeCliOutput interface - Update cli.ts to only use `usage.input_tokens/output_tokens` - Update stream.ts to use `usage` object format - Update all test fixtures to use new format * feat(gateway): use native --json-schema for structured output Replace prompt injection approach with Claude CLI's native --json-schema flag for constrained JSON decoding. This provides model-level guarantees of valid JSON output. Changes: - Add jsonSchema option to ClaudeCliOptions, pass as --json-schema flag - Handle structured_output field in CLI response (used by --json-schema) - Add warning log when jsonSchema provided but structured_output missing - Simplify /generate-object route by removing prompt injection - Soften "guarantee" language in comments (external to our code) Type design improvements: - Extract CliUsageInfo interface for shared CLI usage type - Add createUsageInfo() for consistent CLI→API usage conversion - Extract baseRequestSchema to reduce request schema duplication - Add int().nonnegative() refinements to UsageInfo schema - Remove unused outputFormat field from ClaudeCliOptions - Make rawOutput non-nullable in ClaudeCliResult (we throw on failure) - Document unused maxTokens field (CLI doesn't support it) Closes #66 * docs: regenerate OpenAPI spec
1 parent d3c0259 commit 1e14a82

File tree

11 files changed

+422
-217
lines changed

11 files changed

+422
-217
lines changed

docs/openapi.yaml

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,14 @@ components:
3737
type: string
3838
sessionId:
3939
type: string
40-
maxTokens:
41-
type: number
4240
model:
4341
type: string
4442
userEmail:
4543
type: string
4644
format: email
45+
maxTokens:
46+
type: integer
47+
exclusiveMinimum: 0
4748
required:
4849
- prompt
4950
GenerateObjectRequest:
@@ -53,18 +54,19 @@ components:
5354
type: string
5455
prompt:
5556
type: string
56-
schema:
57-
type: object
58-
additionalProperties: {}
5957
sessionId:
6058
type: string
61-
maxTokens:
62-
type: number
6359
model:
6460
type: string
6561
userEmail:
6662
type: string
6763
format: email
64+
schema:
65+
type: object
66+
additionalProperties: {}
67+
maxTokens:
68+
type: integer
69+
exclusiveMinimum: 0
6870
required:
6971
- prompt
7072
- schema
@@ -88,11 +90,14 @@ components:
8890
type: object
8991
properties:
9092
inputTokens:
91-
type: number
93+
type: integer
94+
minimum: 0
9295
outputTokens:
93-
type: number
96+
type: integer
97+
minimum: 0
9498
totalTokens:
95-
type: number
99+
type: integer
100+
minimum: 0
96101
required:
97102
- inputTokens
98103
- outputTokens
@@ -106,11 +111,14 @@ components:
106111
type: object
107112
properties:
108113
inputTokens:
109-
type: number
114+
type: integer
115+
minimum: 0
110116
outputTokens:
111-
type: number
117+
type: integer
118+
minimum: 0
112119
totalTokens:
113-
type: number
120+
type: integer
121+
minimum: 0
114122
required:
115123
- inputTokens
116124
- outputTokens
@@ -131,11 +139,14 @@ components:
131139
type: object
132140
properties:
133141
inputTokens:
134-
type: number
142+
type: integer
143+
minimum: 0
135144
outputTokens:
136-
type: number
145+
type: integer
146+
minimum: 0
137147
totalTokens:
138-
type: number
148+
type: integer
149+
minimum: 0
139150
required:
140151
- inputTokens
141152
- outputTokens
@@ -375,13 +386,14 @@ paths:
375386
type: string
376387
sessionId:
377388
type: string
378-
maxTokens:
379-
type: number
380389
model:
381390
type: string
382391
userEmail:
383392
type: string
384393
format: email
394+
maxTokens:
395+
type: integer
396+
exclusiveMinimum: 0
385397
required:
386398
- prompt
387399
responses:
@@ -398,11 +410,14 @@ paths:
398410
type: object
399411
properties:
400412
inputTokens:
401-
type: number
413+
type: integer
414+
minimum: 0
402415
outputTokens:
403-
type: number
416+
type: integer
417+
minimum: 0
404418
totalTokens:
405-
type: number
419+
type: integer
420+
minimum: 0
406421
required:
407422
- inputTokens
408423
- outputTokens
@@ -487,18 +502,19 @@ paths:
487502
type: string
488503
prompt:
489504
type: string
490-
schema:
491-
type: object
492-
additionalProperties: {}
493505
sessionId:
494506
type: string
495-
maxTokens:
496-
type: number
497507
model:
498508
type: string
499509
userEmail:
500510
type: string
501511
format: email
512+
schema:
513+
type: object
514+
additionalProperties: {}
515+
maxTokens:
516+
type: integer
517+
exclusiveMinimum: 0
502518
required:
503519
- prompt
504520
- schema
@@ -517,11 +533,14 @@ paths:
517533
type: object
518534
properties:
519535
inputTokens:
520-
type: number
536+
type: integer
537+
minimum: 0
521538
outputTokens:
522-
type: number
539+
type: integer
540+
minimum: 0
523541
totalTokens:
524-
type: number
542+
type: integer
543+
minimum: 0
525544
required:
526545
- inputTokens
527546
- outputTokens

packages/gateway/__tests__/cli.test.ts

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,24 @@ describe("CLI Module", () => {
140140
);
141141
});
142142

143+
it("includes --json-schema when jsonSchema option is provided", async () => {
144+
const mockProc = createMockChildProcess();
145+
mockSpawn.mockReturnValue(mockProc as never);
146+
147+
const schema = {
148+
type: "object",
149+
properties: { name: { type: "string" } },
150+
};
151+
152+
executeClaudeCli({ prompt: "Test", jsonSchema: schema });
153+
154+
expect(mockSpawn).toHaveBeenCalledWith(
155+
"claude",
156+
expect.arrayContaining(["--json-schema", JSON.stringify(schema)]),
157+
expect.any(Object),
158+
);
159+
});
160+
143161
it("throws ClaudeCliError on non-zero exit code", async () => {
144162
const mockProc = createMockChildProcess();
145163
mockSpawn.mockReturnValue(mockProc as never);
@@ -252,8 +270,7 @@ describe("CLI Module", () => {
252270
JSON.stringify({
253271
type: "result",
254272
result: "Hello",
255-
total_tokens_in: 5,
256-
total_tokens_out: 5,
273+
usage: { input_tokens: 5, output_tokens: 5 },
257274
}),
258275
);
259276

@@ -264,6 +281,115 @@ describe("CLI Module", () => {
264281
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
265282
);
266283
});
284+
285+
it("uses structured_output when present (--json-schema response)", async () => {
286+
const mockProc = createMockChildProcess();
287+
mockSpawn.mockReturnValue(mockProc as never);
288+
289+
const resultPromise = executeClaudeCli({
290+
prompt: "Generate a person",
291+
jsonSchema: {
292+
type: "object",
293+
properties: { name: { type: "string" } },
294+
},
295+
});
296+
297+
// CLI returns structured_output when --json-schema is used
298+
simulateCliSuccess(
299+
mockProc,
300+
JSON.stringify({
301+
type: "result",
302+
structured_output: { name: "Alice", age: 30 },
303+
usage: { input_tokens: 15, output_tokens: 20 },
304+
session_id: "structured-session-123",
305+
}),
306+
);
307+
308+
const result = await resultPromise;
309+
310+
// structured_output should be stringified
311+
expect(result.text).toBe('{"name":"Alice","age":30}');
312+
expect(result.usage.inputTokens).toBe(15);
313+
expect(result.usage.outputTokens).toBe(20);
314+
expect(result.sessionId).toBe("structured-session-123");
315+
});
316+
317+
it("falls back to result when structured_output is not present", async () => {
318+
const mockProc = createMockChildProcess();
319+
mockSpawn.mockReturnValue(mockProc as never);
320+
321+
const resultPromise = executeClaudeCli({ prompt: "Test" });
322+
323+
// Standard result without structured_output
324+
simulateCliSuccess(
325+
mockProc,
326+
JSON.stringify({
327+
type: "result",
328+
result: "Plain text response",
329+
usage: { input_tokens: 5, output_tokens: 10 },
330+
}),
331+
);
332+
333+
const result = await resultPromise;
334+
335+
expect(result.text).toBe("Plain text response");
336+
});
337+
338+
it("handles structured_output with complex nested objects", async () => {
339+
const mockProc = createMockChildProcess();
340+
mockSpawn.mockReturnValue(mockProc as never);
341+
342+
const resultPromise = executeClaudeCli({
343+
prompt: "Generate user",
344+
jsonSchema: { type: "object" },
345+
});
346+
347+
const complexObject = {
348+
user: {
349+
name: "Bob",
350+
addresses: [
351+
{ street: "123 Main St", city: "Springfield" },
352+
{ street: "456 Oak Ave", city: "Shelbyville" },
353+
],
354+
},
355+
};
356+
357+
simulateCliSuccess(
358+
mockProc,
359+
JSON.stringify({
360+
type: "result",
361+
structured_output: complexObject,
362+
usage: { input_tokens: 10, output_tokens: 25 },
363+
}),
364+
);
365+
366+
const result = await resultPromise;
367+
368+
expect(JSON.parse(result.text)).toEqual(complexObject);
369+
});
370+
371+
it("handles structured_output with array values", async () => {
372+
const mockProc = createMockChildProcess();
373+
mockSpawn.mockReturnValue(mockProc as never);
374+
375+
const resultPromise = executeClaudeCli({
376+
prompt: "Generate list",
377+
jsonSchema: { type: "array" },
378+
});
379+
380+
simulateCliSuccess(
381+
mockProc,
382+
JSON.stringify({
383+
type: "result",
384+
structured_output: ["one", "two", "three"],
385+
usage: { input_tokens: 5, output_tokens: 5 },
386+
}),
387+
);
388+
389+
const result = await resultPromise;
390+
391+
expect(JSON.parse(result.text)).toEqual(["one", "two", "three"]);
392+
});
267393
});
268394

269395
describe("ClaudeCliError", () => {

packages/gateway/__tests__/helpers.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,11 @@ export function createCliResultOutput(
8383
type: "result",
8484
result: "Hello! How can I help you today?",
8585
session_id: "test-session-123",
86-
total_tokens_in: 10,
87-
total_tokens_out: 15,
88-
cost_usd: 0.001,
86+
usage: {
87+
input_tokens: 10,
88+
output_tokens: 15,
89+
},
90+
total_cost_usd: 0.001,
8991
duration_ms: 500,
9092
...overrides,
9193
};
@@ -118,15 +120,16 @@ export function createStreamAssistantMessage(text: string): string {
118120
export function createStreamResultMessage(
119121
overrides: Partial<{
120122
session_id: string;
121-
total_tokens_in: number;
122-
total_tokens_out: number;
123+
usage: { input_tokens: number; output_tokens: number };
123124
}> = {},
124125
): string {
125126
return JSON.stringify({
126127
type: "result",
127128
session_id: "test-session-123",
128-
total_tokens_in: 10,
129-
total_tokens_out: 15,
129+
usage: {
130+
input_tokens: 10,
131+
output_tokens: 15,
132+
},
130133
...overrides,
131134
});
132135
}

packages/gateway/__tests__/integration/sdk.integration.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,7 @@ describe("SDK Integration Tests", () => {
109109
Buffer.from(
110110
createCliResultJson({
111111
result: "Hello from Claude!",
112-
total_tokens_in: 10,
113-
total_tokens_out: 5,
112+
usage: { input_tokens: 10, output_tokens: 5 },
114113
}),
115114
),
116115
);

0 commit comments

Comments
 (0)