From d1f7e579d91401a552d11418e3638ed1581b2e6c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 24 Apr 2026 10:49:54 -0500 Subject: [PATCH 1/7] structured output (parsing only) --- adapters.ts | 3 +- deno.jsonc | 2 + deno.lock | 31 ++++++++ main.ts | 15 ++++ models_test.ts | 2 +- schema.ts | 43 ++++++++++ schema_test.ts | 210 +++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 schema.ts create mode 100644 schema_test.ts diff --git a/adapters.ts b/adapters.ts index 7a72d58..c18fb73 100644 --- a/adapters.ts +++ b/adapters.ts @@ -217,8 +217,7 @@ type ClaudeThinkParams = { } function claudeThinkParams(key: string, think: ThinkLevel): ClaudeThinkParams { - const adaptive = key === "claude-opus-4-7" || key === "claude-sonnet-4-6" || - key === "claude-opus-4-6" + const adaptive = key === "claude-opus-4-7" || key === "claude-sonnet-4-6" // SDK's non-streaming guard throws when max_tokens > ~21_333 (it assumes // 128k tokens/hour and refuses requests estimated to take >10 min). diff --git a/deno.jsonc b/deno.jsonc index 54d9248..b97b68a 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -19,6 +19,8 @@ "markdown-table": "https://esm.sh/markdown-table@3.0.4", "openai": "npm:openai@6.9.1", "@anthropic-ai/sdk": "npm:@anthropic-ai/sdk@0.90.0", + "arktype": "npm:arktype@^2.2.0", + "json5": "npm:json5@^2.2.3", "@google/genai": "npm:@google/genai@1.34.0", "remeda": "npm:remeda@^2.32.0", "string-width": "https://esm.sh/string-width@7.2.0", diff --git a/deno.lock b/deno.lock index d347b0c..ff44cbe 100644 --- a/deno.lock +++ b/deno.lock @@ -29,6 +29,8 @@ "npm:@shikijs/cli@^4.0.2": "4.0.2", "npm:ansis@*": "4.2.0", "npm:ansis@^4.2.0": "4.2.0", + "npm:arktype@^2.2.0": "2.2.0", + "npm:json5@^2.2.3": "2.2.3", "npm:markdown-exit@^1.0.0-beta.9": "1.0.0-beta.9", "npm:openai@6.9.1": "6.9.1", "npm:remeda@^2.32.0": "2.32.0", @@ -155,6 +157,15 @@ ], "bin": true }, + "@ark/schema@0.56.0": { + "integrity": "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==", + "dependencies": [ + "@ark/util" + ] + }, + "@ark/util@0.56.0": { + "integrity": "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==" + }, "@babel/runtime@7.29.2": { "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==" }, @@ -289,6 +300,20 @@ "ansis@4.2.0": { "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==" }, + "arkregex@0.0.5": { + "integrity": "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==", + "dependencies": [ + "@ark/util" + ] + }, + "arktype@2.2.0": { + "integrity": "sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==", + "dependencies": [ + "@ark/schema", + "@ark/util", + "arkregex" + ] + }, "balanced-match@1.0.2": { "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, @@ -513,6 +538,10 @@ "ts-algebra" ] }, + "json5@2.2.3": { + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": true + }, "jwa@2.0.1": { "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "dependencies": [ @@ -883,6 +912,8 @@ "npm:@google/genai@1.34.0", "npm:@shikijs/cli@^4.0.2", "npm:ansis@^4.2.0", + "npm:arktype@^2.2.0", + "npm:json5@^2.2.3", "npm:markdown-exit@^1.0.0-beta.9", "npm:openai@6.9.1", "npm:remeda@^2.32.0", diff --git a/main.ts b/main.ts index cc4d05a..97722a2 100755 --- a/main.ts +++ b/main.ts @@ -33,6 +33,7 @@ import { } from "./adapters.ts" import { History } from "./storage.ts" import { genMissingSummaries } from "./summarize.ts" +import { parseType } from "./schema.ts" const getLastModelId = (chat: Chat) => chat.messages.findLast((m) => m.role === "assistant")?.model @@ -439,6 +440,7 @@ the raw output to stdout.`) .option("-b, --background", "Use background mode (OpenAI only)") .option("-v, --verbose", "Include reasoning in output") .option("--raw", "Print LLM text directly (no metadata or reasoning)") + .option("-o, --output-schema ", "ArkType schema for structured output") .example("1)", "ai 'What is the capital of France?'") .example("2)", "cat main.ts | ai 'what is this?'") .example("3)", "echo 'what are you?' | ai") @@ -446,6 +448,19 @@ the raw output to stdout.`) .example("5)", "ai -m 4o 'What are generic types?'") .example("6)", "ai gist -t 'Generic types'") .action(async (opts, ...args) => { + // Temporary: parse --output-schema and print the resulting JSON Schema, + // then exit. Will be wired to providers in a follow-up. + if (opts.outputSchema) { + try { + const t = parseType(opts.outputSchema) + console.log(JSON.stringify(t.toJsonSchema(), null, 2)) + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)) + Deno.exit(1) + } + return + } + const msg = args.join(" ") const stdin = Deno.stdin.isTerminal() ? null diff --git a/models_test.ts b/models_test.ts index ff373a2..6976992 100644 --- a/models_test.ts +++ b/models_test.ts @@ -86,7 +86,7 @@ Deno.test("resolveModel - substring match on id", () => { Deno.test("resolveModel - substring match on key", () => { const model = resolveModel("claude-opus") - assertEquals(model.id, "opus-4.6") + assertEquals(model.id, "opus-4.7") }) Deno.test("resolveModel - case insensitive", () => { diff --git a/schema.ts b/schema.ts new file mode 100644 index 0000000..33fc719 --- /dev/null +++ b/schema.ts @@ -0,0 +1,43 @@ +import { type Type, type as arkType } from "arktype" +import JSON5 from "json5" + +/** + * Parse a `--output-schema` argument with arktype. + * + * Inputs starting with `{` or `[` are parsed as JSON5 (permits single quotes, + * unquoted keys, trailing commas) and passed to arktype as an object/tuple + * definition. Everything else is passed directly to arktype's string DSL, + * which handles primitives, unions, arrays (`string[]`), refinements + * (`number > 0`), etc. + * + * JSON5 is used instead of `eval` so malformed input produces a readable + * parse error and no arbitrary JS can run. + */ +// arktype's `type` is heavily overloaded on string literals. For our dynamic +// input we route through a loosely-typed alias to avoid deep instantiation. +// deno-lint-ignore no-explicit-any +const ark = arkType as unknown as (def: any) => Type + +export function parseType(input: string): Type { + // The naive order here would be "if input starts with `{` or `[`, parse + // as JSON5, else pass to arktype's string DSL." We invert that: try JSON5 + // first, and only commit to it if the result is an object/array. This is more + // robust to leading whitespace, comments, or any other JSON5-tolerated noise + // without a hand-rolled sniff test. It's safe because arktype's string DSL + // (`boolean`, `'a'|'b'`, `number > 0`) is never valid JSON5. We still sniff + // for `{`/`[` on the error path so malformed object input surfaces the JSON5 + // error rather than a less helpful error from arktype. + let parsed: unknown + try { + parsed = JSON5.parse(input) + } catch (e) { + // JSON5 failed. If the input clearly *meant* to be an object/tuple, + // surface the JSON5 error. Otherwise fall through to arktype's string DSL. + if (/^\s*[{[]/.test(input)) { + const msg = e instanceof Error ? e.message : String(e) + throw new Error(`Failed to parse output schema as JSON5: ${msg}`) + } + return ark(input) + } + return parsed !== null && typeof parsed === "object" ? ark(parsed) : ark(input) +} diff --git a/schema_test.ts b/schema_test.ts new file mode 100644 index 0000000..ef8921b --- /dev/null +++ b/schema_test.ts @@ -0,0 +1,210 @@ +import { assertEquals, assertThrows } from "@std/assert" +import { parseType } from "./schema.ts" + +function schema(input: string) { + const { $schema: _, ...rest } = parseType(input).toJsonSchema() as Record< + string, + unknown + > + return rest +} + +Deno.test("parseType - boolean primitive", () => { + assertEquals(schema("boolean"), { type: "boolean" }) +}) + +Deno.test("parseType - string primitive", () => { + assertEquals(schema("string"), { type: "string" }) +}) + +Deno.test("parseType - number primitive", () => { + assertEquals(schema("number"), { type: "number" }) +}) + +Deno.test("parseType - string literal union", () => { + assertEquals(schema("'yes' | 'no'"), { enum: ["no", "yes"] }) +}) + +Deno.test("parseType - string array", () => { + assertEquals(schema("string[]"), { + type: "array", + items: { type: "string" }, + }) +}) + +Deno.test("parseType - object with optional field", () => { + assertEquals(schema("{ urgent: 'boolean', 'reason?': 'string' }"), { + type: "object", + properties: { + urgent: { type: "boolean" }, + reason: { type: "string" }, + }, + required: ["urgent"], + }) +}) + +Deno.test("parseType - union inside object (nested-quote form)", () => { + assertEquals(schema(`{ answer: "'yes'|'no'" }`), { + type: "object", + properties: { + answer: { enum: ["no", "yes"] }, + }, + required: ["answer"], + }) +}) + +Deno.test("parseType - nested object", () => { + assertEquals( + schema("{ user: { name: 'string', age: 'number' } }"), + { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["age", "name"], + }, + }, + required: ["user"], + }, + ) +}) + +Deno.test("parseType - leading/trailing whitespace is fine", () => { + assertEquals(schema(" boolean "), { type: "boolean" }) + assertEquals((schema(" { x: 'number' }") as { type: string }).type, "object") +}) + +Deno.test("parseType - refinement (number > 0)", () => { + const s = schema("number > 0") as { type: string; exclusiveMinimum: number } + assertEquals(s.type, "number") + assertEquals(s.exclusiveMinimum, 0) +}) + +Deno.test("parseType - unresolvable string", () => { + assertThrows( + () => parseType("blargh"), + Error, + "'blargh' is unresolvable", + ) +}) + +Deno.test("parseType - dangling union operator", () => { + assertThrows( + () => parseType("'yes' |"), + Error, + "Token '|' requires a right operand", + ) +}) + +Deno.test("parseType - unresolvable value inside object", () => { + assertThrows( + () => parseType("{ x: 'nonsense' }"), + Error, + "'nonsense' is unresolvable", + ) +}) + +Deno.test("parseType - truncated object literal", () => { + assertThrows( + () => parseType("{ x: "), + Error, + "Failed to parse output schema as JSON5", + ) +}) + +Deno.test("parseType - expressions in object literals are rejected", () => { + // JSON5 rejects `1 + 2`, so arbitrary JS can't sneak in + assertThrows( + () => parseType("{ x: 1 + 2 }"), + Error, + "Failed to parse output schema as JSON5", + ) +}) + +Deno.test("parseType - trailing garbage after object literal", () => { + assertThrows( + () => parseType("{ x: 'number' } foo"), + Error, + "Failed to parse output schema as JSON5", + ) +}) + +// Red-team: things `new Function` / eval would have accepted must now be +// rejected by JSON5. All of these should fail before arktype ever sees them. + +Deno.test("red-team - function call in value is rejected", () => { + assertThrows( + () => parseType("{ x: Date.now() }"), + Error, + "Failed to parse output schema as JSON5", + ) +}) + +Deno.test("red-team - bare identifier reference is rejected", () => { + assertThrows( + () => parseType("{ x: Deno }"), + Error, + "Failed to parse output schema as JSON5", + ) +}) + +Deno.test("red-team - IIFE is rejected", () => { + assertThrows( + () => parseType("{ x: (() => 'string')() }"), + Error, + "Failed to parse output schema as JSON5", + ) +}) + +Deno.test("red-team - template literal is rejected", () => { + assertThrows( + () => parseType("{ x: `string` }"), + Error, + "Failed to parse output schema as JSON5", + ) +}) + +Deno.test("red-team - regex literal is rejected", () => { + assertThrows( + () => parseType("{ x: /foo/ }"), + Error, + "Failed to parse output schema as JSON5", + ) +}) + +Deno.test("red-team - new expression is rejected", () => { + assertThrows( + () => parseType("{ x: new Date() }"), + Error, + "Failed to parse output schema as JSON5", + ) +}) + +Deno.test("red-team - spread is rejected", () => { + assertThrows( + () => parseType("{ ...{ x: 'string' } }"), + Error, + "Failed to parse output schema as JSON5", + ) +}) + +Deno.test("red-team - method call on string is rejected", () => { + assertThrows( + () => parseType("{ x: 'foo'.repeat(3) }"), + Error, + "Failed to parse output schema as JSON5", + ) +}) + +Deno.test("red-team - sequence operator is rejected", () => { + // with eval: (sideEffect(), 'string') returns 'string' — fully exploitable + assertThrows( + () => parseType("{ x: (0, 'string') }"), + Error, + "Failed to parse output schema as JSON5", + ) +}) From f7ed64bdf5a2358b73475f1a6fca51a32833b3f0 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 24 Apr 2026 12:32:22 -0500 Subject: [PATCH 2/7] hook up structured output for anthropic --- adapters.ts | 43 +++++++++++++++++++++++++++++++++++++++++-- display.ts | 3 +++ main.ts | 16 +++++++++------- types.ts | 2 ++ 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/adapters.ts b/adapters.ts index c18fb73..afa80c9 100644 --- a/adapters.ts +++ b/adapters.ts @@ -3,6 +3,7 @@ import type { ResponseCreateParamsNonStreaming } from "openai/resources/response import Anthropic from "@anthropic-ai/sdk" import { GoogleGenAI, ThinkingLevel } from "@google/genai" import { ValidationError } from "@cliffy/command" +import type { Type } from "arktype" import * as R from "remeda" @@ -29,6 +30,7 @@ export type ChatInput = { model: Model config: ToolConfig signal?: AbortSignal + outputSchema?: Type } function processGptResponse( @@ -250,8 +252,24 @@ function claudeThinkParams(key: string, think: ThinkLevel): ClaudeThinkParams { return { thinking, output_config: { effort }, max_tokens } } +/** + * Anthropic requires `additionalProperties: false` on every object type in an + * output schema. arktype's `toJsonSchema()` doesn't emit it, so we add it + * recursively before sending. + */ +function closeObjects(schema: unknown): unknown { + if (schema === null || typeof schema !== "object") return schema + if (Array.isArray(schema)) return schema.map(closeObjects) + const out: Record = {} + for (const [k, v] of Object.entries(schema)) out[k] = closeObjects(v) + if (out.type === "object" && out.additionalProperties === undefined) { + out.additionalProperties = false + } + return out +} + async function claudeCreateMessage( - { chat, model, config, signal }: ChatInput, + { chat, model, config, signal, outputSchema }: ChatInput, ) { const toolsList: Anthropic.Beta.BetaToolUnion[] = [] if (config.search) { @@ -265,6 +283,13 @@ async function claudeCreateMessage( config.think, ) + const output_format: Anthropic.Beta.BetaJSONOutputFormat | undefined = outputSchema + ? { + type: "json_schema", + schema: closeObjects(outputSchema.toJsonSchema()) as Record, + } + : undefined + const response = await new Anthropic().beta.messages.create({ model: model.key, cache_control: { type: "ephemeral" }, @@ -275,6 +300,7 @@ async function claudeCreateMessage( max_tokens, thinking, output_config, + output_format, tools: toolsList.length > 0 ? toolsList : undefined, betas: ["code-execution-web-tools-2026-02-09"], }, { signal }) @@ -288,12 +314,22 @@ async function claudeCreateMessage( .filter((x): x is string => !!x) // Join blocks, avoiding separators around punctuation/connectors - const content = blocks.reduce((acc, block) => { + let content = blocks.reduce((acc, block) => { if (!acc) return block if (/^[,;.!?]/.test(block)) return acc + block // no space before punctuation if (/^(and|or)\b/i.test(block)) return acc + " " + block // space before connectors return acc + "\n\n" + block }, "") + + // When a structured-output schema is used, Claude returns JSON. Strip the + // outer quotes on bare-string results so shell consumers see `yes` not + // `"yes"`. Leave objects/arrays/numbers/booleans alone. + if (outputSchema) { + try { + const parsed = JSON.parse(content) + if (typeof parsed === "string") content = parsed + } catch { /* not JSON, leave as-is */ } + } const reasoning = response.content .filter((msg) => msg.type === "thinking") .map((msg) => msg.thinking) @@ -412,6 +448,9 @@ export function validateConfig(provider: string, config: ToolConfig) { export function createMessage(input: ChatInput): Promise { const { provider } = input.model + if (input.outputSchema && provider !== "anthropic") { + throw new ValidationError("--output-schema only supported for Anthropic models so far") + } if (provider === "anthropic") return claudeCreateMessage(input) if (provider === "google") return geminiCreateMessage(input) if (provider === "deepseek") return deepseekCreateMessage(input) diff --git a/display.ts b/display.ts index 86d33f0..b99e348 100644 --- a/display.ts +++ b/display.ts @@ -122,6 +122,9 @@ export function messageContentMd(msg: ChatMessage, mode: DisplayMode) { } } + if (msg.role === "user" && msg.outputSchema && mode !== "raw") { + output += `**Schema:** \`${msg.outputSchema}\`\n\n` + } // For long user inputs in gist mode, collapse in a details block if (msg.role === "user" && mode === "gist" && msg.content.length > LONG_INPUT_THRESHOLD) { output += tag( diff --git a/main.ts b/main.ts index 97722a2..71177d1 100755 --- a/main.ts +++ b/main.ts @@ -448,17 +448,14 @@ the raw output to stdout.`) .example("5)", "ai -m 4o 'What are generic types?'") .example("6)", "ai gist -t 'Generic types'") .action(async (opts, ...args) => { - // Temporary: parse --output-schema and print the resulting JSON Schema, - // then exit. Will be wired to providers in a follow-up. + let outputSchema: ReturnType | undefined if (opts.outputSchema) { try { - const t = parseType(opts.outputSchema) - console.log(JSON.stringify(t.toJsonSchema(), null, 2)) + outputSchema = parseType(opts.outputSchema) } catch (e) { console.error(e instanceof Error ? e.message : String(e)) Deno.exit(1) } - return } const msg = args.join(" ") @@ -506,8 +503,13 @@ the raw output to stdout.`) throw new ValidationError("Background mode only works with OpenAI models") } - chat.messages.push({ role: "user", content: input, image_url: opts.image }) - const chatInput: ChatInput = { chat, model, config } + chat.messages.push({ + role: "user", + content: input, + image_url: opts.image, + outputSchema: outputSchema?.expression, + }) + const chatInput: ChatInput = { chat, model, config, outputSchema } // no need to pass --background if using gpt-5-pro -- it always needs it if (opts.background || model.id === "gpt-5.2-pro") { diff --git a/types.ts b/types.ts index 19d1b0e..04cee74 100644 --- a/types.ts +++ b/types.ts @@ -11,6 +11,8 @@ type UserMessage = { content: string image_url?: string cache?: boolean + /** arktype canonical expression of the requested output schema, if any */ + outputSchema?: string } type AssistantMessage = { From 7888f3670683b19964e20d27ae3824142fdb97ad Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 24 Apr 2026 12:52:24 -0500 Subject: [PATCH 3/7] hook up other providers --- adapters.ts | 138 ++++++++++++++++++++++++++++++---------------------- schema.ts | 84 ++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 57 deletions(-) diff --git a/adapters.ts b/adapters.ts index afa80c9..922155a 100644 --- a/adapters.ts +++ b/adapters.ts @@ -5,6 +5,8 @@ import { GoogleGenAI, ThinkingLevel } from "@google/genai" import { ValidationError } from "@cliffy/command" import type { Type } from "arktype" +import { postprocessSchemaContent, prepareSchema } from "./schema.ts" + import * as R from "remeda" import type { BackgroundStatus, Chat, TokenCounts } from "./types.ts" @@ -36,6 +38,7 @@ export type ChatInput = { function processGptResponse( response: OpenAI.Responses.Response, model: Model, + wrapped = false, ): ModelResponse { const tokens = { input: response.usage?.input_tokens || 0, @@ -45,8 +48,12 @@ function processGptResponse( const searches = response.output.filter((item) => item.type === "web_search_call").length + const content = wrapped + ? postprocessSchemaContent(response.output_text, true) + : response.output_text + return { - content: response.output_text, + content, tokens, cost: getCost(model, tokens, searches), stop_reason: response.status || "completed", @@ -54,33 +61,54 @@ function processGptResponse( } } -function gptConfig(chatInput: ChatInput): ResponseCreateParamsNonStreaming { - const { chat, model, config } = chatInput +function gptConfig( + chatInput: ChatInput, +): { params: ResponseCreateParamsNonStreaming; wrapped: boolean } { + const { chat, model, config, outputSchema } = chatInput + const prep = outputSchema + ? prepareSchema(outputSchema, { wrapPrimitives: true, allRequired: true }) + : undefined return { - model: model.key, - input: chat.messages.map((m) => ({ role: m.role, content: m.content })), - tools: config.search ? [{ type: "web_search_preview" as const }] : undefined, - reasoning: { - effort: config.think === "high" ? "high" : config.think === "off" ? "none" : "medium", + params: { + model: model.key, + input: chat.messages.map((m) => ({ role: m.role, content: m.content })), + tools: config.search ? [{ type: "web_search_preview" as const }] : undefined, + reasoning: { + effort: config.think === "high" + ? "high" + : config.think === "off" + ? "none" + : "medium", + }, + instructions: chat.systemPrompt, + text: prep + ? { + format: { + type: "json_schema", + name: "output", + schema: prep.schema, + strict: true, + }, + } + : undefined, }, - instructions: chat.systemPrompt, + wrapped: prep?.wrapped ?? false, } } async function gptCreateMessage(chatInput: ChatInput) { const client = new OpenAI() - const response = await client.responses.create( - gptConfig(chatInput), - { signal: chatInput.signal }, - ) - return processGptResponse(response, chatInput.model) + const { params, wrapped } = gptConfig(chatInput) + const response = await client.responses.create(params, { signal: chatInput.signal }) + return processGptResponse(response, chatInput.model, wrapped) } export const gptBg = { async initiate(chatInput: ChatInput): Promise<{ id: string; status: BackgroundStatus }> { const client = new OpenAI() + const { params } = gptConfig(chatInput) const response = await client.responses.create({ - ...gptConfig(chatInput), + ...params, background: true, store: true, }) @@ -88,7 +116,9 @@ export const gptBg = { }, async retrieve(responseId: string, model: Model): Promise { const response = await new OpenAI().responses.retrieve(responseId) - return processGptResponse(response, model) + // Background retrieve: schema wrap info isn't reconstructed here. Structured + // output via `-b` + `-o` isn't supported yet. + return processGptResponse(response, model, false) }, async status(responseId: string): Promise { const client = new OpenAI() @@ -103,7 +133,8 @@ export const gptBg = { } const makeOpenAIFunc = - (baseURL: string, envVarName: string) => async ({ chat, model, signal }: ChatInput) => { + (baseURL: string, envVarName: string) => + async ({ chat, model, signal, outputSchema }: ChatInput) => { const client = new OpenAI({ baseURL, apiKey: Deno.env.get(envVarName) }) const systemMsg = chat.systemPrompt ? [{ role: "system" as const, content: chat.systemPrompt }] @@ -112,10 +143,22 @@ const makeOpenAIFunc = ...systemMsg, ...chat.messages.map((m) => ({ role: m.role, content: m.content })), ] - const response = await client.chat.completions.create( - { model: model.key, messages }, - { signal }, - ) + // Third-party OpenAI-compatible providers vary in strict-mode support; + // wrap primitives and force-require fields so we're at least consistent + // with the standard OpenAI strict schema. + const prep = outputSchema + ? prepareSchema(outputSchema, { wrapPrimitives: true, allRequired: true }) + : undefined + const response = await client.chat.completions.create({ + model: model.key, + messages, + response_format: prep + ? { + type: "json_schema", + json_schema: { name: "output", schema: prep.schema, strict: true }, + } + : undefined, + }, { signal }) const message = response.choices[0].message if (!message) throw new Error("No response found") @@ -147,7 +190,7 @@ const makeOpenAIFunc = input_cache_hit: response.usage?.prompt_tokens_details?.cached_tokens || 0, } return { - content, + content: prep ? postprocessSchemaContent(content, prep.wrapped) : content, reasoning, tokens, cost: getCost(model, tokens), @@ -252,22 +295,6 @@ function claudeThinkParams(key: string, think: ThinkLevel): ClaudeThinkParams { return { thinking, output_config: { effort }, max_tokens } } -/** - * Anthropic requires `additionalProperties: false` on every object type in an - * output schema. arktype's `toJsonSchema()` doesn't emit it, so we add it - * recursively before sending. - */ -function closeObjects(schema: unknown): unknown { - if (schema === null || typeof schema !== "object") return schema - if (Array.isArray(schema)) return schema.map(closeObjects) - const out: Record = {} - for (const [k, v] of Object.entries(schema)) out[k] = closeObjects(v) - if (out.type === "object" && out.additionalProperties === undefined) { - out.additionalProperties = false - } - return out -} - async function claudeCreateMessage( { chat, model, config, signal, outputSchema }: ChatInput, ) { @@ -283,11 +310,9 @@ async function claudeCreateMessage( config.think, ) - const output_format: Anthropic.Beta.BetaJSONOutputFormat | undefined = outputSchema - ? { - type: "json_schema", - schema: closeObjects(outputSchema.toJsonSchema()) as Record, - } + const prep = outputSchema ? prepareSchema(outputSchema) : undefined + const output_format: Anthropic.Beta.BetaJSONOutputFormat | undefined = prep + ? { type: "json_schema", schema: prep.schema } : undefined const response = await new Anthropic().beta.messages.create({ @@ -321,15 +346,7 @@ async function claudeCreateMessage( return acc + "\n\n" + block }, "") - // When a structured-output schema is used, Claude returns JSON. Strip the - // outer quotes on bare-string results so shell consumers see `yes` not - // `"yes"`. Leave objects/arrays/numbers/booleans alone. - if (outputSchema) { - try { - const parsed = JSON.parse(content) - if (typeof parsed === "string") content = parsed - } catch { /* not JSON, leave as-is */ } - } + if (prep) content = postprocessSchemaContent(content, prep.wrapped) const reasoning = response.content .filter((msg) => msg.type === "thinking") .map((msg) => msg.thinking) @@ -358,12 +375,17 @@ async function claudeCreateMessage( } } -async function geminiCreateMessage({ chat, model, config, signal }: ChatInput) { +async function geminiCreateMessage( + { chat, model, config, signal, outputSchema }: ChatInput, +) { const apiKey = Deno.env.get("GEMINI_API_KEY") if (!apiKey) throw Error("GEMINI_API_KEY missing") const isFlash = model.key.includes("flash") + // Gemini accepts primitive roots; no wrap needed. + const prep = outputSchema ? prepareSchema(outputSchema) : undefined + const result = await new GoogleGenAI({ apiKey }).models.generateContent({ config: { // https://ai.google.dev/gemini-api/docs/thinking @@ -379,11 +401,14 @@ async function geminiCreateMessage({ chat, model, config, signal }: ChatInput) { : undefined, // default to dynamic }, systemInstruction: chat.systemPrompt, - tools: [ + // Schema-constrained output and tools are mutually exclusive on Gemini. + tools: prep ? undefined : [ // always include URL context. it was designed to be used this way { urlContext: {} }, ...(config.search ? [{ googleSearch: {} }] : []), ], + responseMimeType: prep ? "application/json" : undefined, + responseJsonSchema: prep?.schema, abortSignal: signal, }, model: model.key, @@ -412,6 +437,8 @@ async function geminiCreateMessage({ chat, model, config, signal }: ChatInput) { content += searchResultsMd + if (prep) content = postprocessSchemaContent(content, prep.wrapped) + const tokens = { input: result.usageMetadata?.promptTokenCount || 0, output: (result.usageMetadata?.candidatesTokenCount || 0) + @@ -448,9 +475,6 @@ export function validateConfig(provider: string, config: ToolConfig) { export function createMessage(input: ChatInput): Promise { const { provider } = input.model - if (input.outputSchema && provider !== "anthropic") { - throw new ValidationError("--output-schema only supported for Anthropic models so far") - } if (provider === "anthropic") return claudeCreateMessage(input) if (provider === "google") return geminiCreateMessage(input) if (provider === "deepseek") return deepseekCreateMessage(input) diff --git a/schema.ts b/schema.ts index 33fc719..674b9d0 100644 --- a/schema.ts +++ b/schema.ts @@ -41,3 +41,87 @@ export function parseType(input: string): Type { } return parsed !== null && typeof parsed === "object" ? ark(parsed) : ark(input) } + +/** + * Add `additionalProperties: false` to every object type in a JSON schema. + * Required by Anthropic (always) and OpenAI (in strict mode). arktype's + * `toJsonSchema()` doesn't emit it, so we add it recursively. + */ +function closeObjects(schema: unknown): unknown { + if (schema === null || typeof schema !== "object") return schema + if (Array.isArray(schema)) return schema.map(closeObjects) + const out: Record = {} + for (const [k, v] of Object.entries(schema)) out[k] = closeObjects(v) + if (out.type === "object" && out.additionalProperties === undefined) { + out.additionalProperties = false + } + return out +} + +/** OpenAI strict mode requires every object property to be in `required`. */ +function forceAllRequired(s: unknown): unknown { + if (s === null || typeof s !== "object") return s + if (Array.isArray(s)) return s.map(forceAllRequired) + const out: Record = {} + for (const [k, v] of Object.entries(s)) out[k] = forceAllRequired(v) + if ( + out.type === "object" && out.properties && + typeof out.properties === "object" && !Array.isArray(out.properties) + ) { + out.required = Object.keys(out.properties as Record) + } + return out +} + +/** + * Build a JSON schema suitable for a provider's structured-output API. + * + * `wrapPrimitives`: wrap non-object roots in `{ value: }`. Required + * by OpenAI strict mode, which only accepts object roots. + * `allRequired`: mark every object property required. Also required by + * OpenAI strict mode. + * + * Returns `wrapped: true` when wrapping was applied, so callers can unwrap + * the response. + */ +export function prepareSchema( + t: Type, + opts: { wrapPrimitives?: boolean; allRequired?: boolean } = {}, +): { schema: Record; wrapped: boolean } { + let schema = closeObjects(t.toJsonSchema()) as Record + const wrapped = !!opts.wrapPrimitives && schema.type !== "object" + if (wrapped) { + schema = { + type: "object", + properties: { value: schema }, + additionalProperties: false, + required: ["value"], + } + } + if (opts.allRequired) schema = forceAllRequired(schema) as Record + return { schema, wrapped } +} + +/** + * Post-process structured-output content for shell use: + * - if wrapped, extract `.value` + * - if the result is a bare string, drop the JSON quotes + * - otherwise leave as-is (JSON objects/arrays/numbers/booleans already + * serialize to shell-friendly forms) + */ +export function postprocessSchemaContent(content: string, wrapped: boolean): string { + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch { + return content + } + if ( + wrapped && parsed !== null && typeof parsed === "object" && "value" in parsed + ) { + parsed = (parsed as { value: unknown }).value + } + if (typeof parsed === "string") return parsed + // re-stringify only if we unwrapped; otherwise preserve provider's formatting + return wrapped ? JSON.stringify(parsed) : content +} From 478765ebbc25c1a017782e732fb488aceeaccccc Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 24 Apr 2026 13:53:32 -0500 Subject: [PATCH 4/7] codex cleanup --- adapters.ts | 26 +++-- deno.jsonc | 6 +- deno.lock | 302 +++++++++++++++---------------------------------- main.ts | 7 +- schema.ts | 4 +- schema_test.ts | 74 +++++++++++- 6 files changed, 189 insertions(+), 230 deletions(-) diff --git a/adapters.ts b/adapters.ts index 922155a..e713c98 100644 --- a/adapters.ts +++ b/adapters.ts @@ -65,6 +65,13 @@ function gptConfig( chatInput: ChatInput, ): { params: ResponseCreateParamsNonStreaming; wrapped: boolean } { const { chat, model, config, outputSchema } = chatInput + // OpenAI strict mode has two quirks the other providers don't impose: + // 1. Root schema must be `object` — primitives, unions, and arrays at the + // top level are rejected. `wrapPrimitives` wraps non-object roots as + // `{ value: }`; the response is unwrapped in postprocess. + // 2. Every property must appear in `required` — optional fields are not + // allowed. `allRequired` forces all keys into `required`. + // We keep strict mode on because it's what makes structured output reliable. const prep = outputSchema ? prepareSchema(outputSchema, { wrapPrimitives: true, allRequired: true }) : undefined @@ -143,9 +150,10 @@ const makeOpenAIFunc = ...systemMsg, ...chat.messages.map((m) => ({ role: m.role, content: m.content })), ] - // Third-party OpenAI-compatible providers vary in strict-mode support; - // wrap primitives and force-require fields so we're at least consistent - // with the standard OpenAI strict schema. + // Same strict-mode shape as the OpenAI Responses path (see gptConfig): + // object-only roots and all-properties-required. Third-party compatible + // providers vary in how strictly they enforce this, but matching OpenAI's + // shape keeps behavior consistent without a per-provider matrix. const prep = outputSchema ? prepareSchema(outputSchema, { wrapPrimitives: true, allRequired: true }) : undefined @@ -311,7 +319,7 @@ async function claudeCreateMessage( ) const prep = outputSchema ? prepareSchema(outputSchema) : undefined - const output_format: Anthropic.Beta.BetaJSONOutputFormat | undefined = prep + const format: Anthropic.Beta.BetaJSONOutputFormat | undefined = prep ? { type: "json_schema", schema: prep.schema } : undefined @@ -324,8 +332,7 @@ async function claudeCreateMessage( ), max_tokens, thinking, - output_config, - output_format, + output_config: format ? { ...output_config, format } : output_config, tools: toolsList.length > 0 ? toolsList : undefined, betas: ["code-execution-web-tools-2026-02-09"], }, { signal }) @@ -333,7 +340,7 @@ async function claudeCreateMessage( const searches = response.usage.server_tool_use?.web_search_requests ?? 0 const blocks = response.content.filter((msg) => - msg.type === "text" || msg.type === "server_tool_use" + msg.type === "text" || (!prep && msg.type === "server_tool_use") ) .map(renderClaudeContentBlock) .filter((x): x is string => !!x) @@ -401,8 +408,7 @@ async function geminiCreateMessage( : undefined, // default to dynamic }, systemInstruction: chat.systemPrompt, - // Schema-constrained output and tools are mutually exclusive on Gemini. - tools: prep ? undefined : [ + tools: [ // always include URL context. it was designed to be used this way { urlContext: {} }, ...(config.search ? [{ googleSearch: {} }] : []), @@ -435,7 +441,7 @@ async function geminiCreateMessage( .map((chunk) => `- [${chunk.web!.title}](${chunk.web!.uri})`).join("\n") : "" - content += searchResultsMd + if (!prep) content += searchResultsMd if (prep) content = postprocessSchemaContent(content, prep.wrapped) diff --git a/deno.jsonc b/deno.jsonc index b97b68a..cd889c8 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -17,11 +17,11 @@ "ansis": "npm:ansis@^4.2.0", "markdown-exit": "npm:markdown-exit@^1.0.0-beta.9", "markdown-table": "https://esm.sh/markdown-table@3.0.4", - "openai": "npm:openai@6.9.1", - "@anthropic-ai/sdk": "npm:@anthropic-ai/sdk@0.90.0", + "openai": "npm:openai@6.34.0", + "@anthropic-ai/sdk": "npm:@anthropic-ai/sdk@0.91.0", "arktype": "npm:arktype@^2.2.0", "json5": "npm:json5@^2.2.3", - "@google/genai": "npm:@google/genai@1.34.0", + "@google/genai": "npm:@google/genai@1.50.1", "remeda": "npm:remeda@^2.32.0", "string-width": "https://esm.sh/string-width@7.2.0", "supports-hyperlinks": "npm:supports-hyperlinks@^4.4.0", diff --git a/deno.lock b/deno.lock index ff44cbe..6267b74 100644 --- a/deno.lock +++ b/deno.lock @@ -24,15 +24,15 @@ "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/semver@^1.0.8": "1.0.8", "jsr:@std/text@^1.0.17": "1.0.17", - "npm:@anthropic-ai/sdk@0.90.0": "0.90.0", - "npm:@google/genai@1.34.0": "1.34.0", + "npm:@anthropic-ai/sdk@0.91.0": "0.91.0", + "npm:@google/genai@1.50.1": "1.50.1", "npm:@shikijs/cli@^4.0.2": "4.0.2", "npm:ansis@*": "4.2.0", "npm:ansis@^4.2.0": "4.2.0", "npm:arktype@^2.2.0": "2.2.0", "npm:json5@^2.2.3": "2.2.3", "npm:markdown-exit@^1.0.0-beta.9": "1.0.0-beta.9", - "npm:openai@6.9.1": "6.9.1", + "npm:openai@6.34.0": "6.34.0", "npm:remeda@^2.32.0": "2.32.0", "npm:supports-hyperlinks@4.4.0": "4.4.0", "npm:supports-hyperlinks@^4.4.0": "4.4.0" @@ -150,8 +150,8 @@ } }, "npm": { - "@anthropic-ai/sdk@0.90.0": { - "integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==", + "@anthropic-ai/sdk@0.91.0": { + "integrity": "sha512-hybd/DOI3ujG4gZyqqcWnSekYxkdjr1JbZYqP2Lb4AmcsU6HCTHSrTOgqedPSsQAruBVucHNAoD1vTQnpPzedw==", "dependencies": [ "json-schema-to-ts" ], @@ -169,26 +169,48 @@ "@babel/runtime@7.29.2": { "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==" }, - "@google/genai@1.34.0": { - "integrity": "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw==", + "@google/genai@1.50.1": { + "integrity": "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==", "dependencies": [ "google-auth-library", + "p-retry", + "protobufjs", "ws" ] }, - "@isaacs/cliui@8.0.2": { - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "@protobufjs/aspromise@1.1.2": { + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "@protobufjs/base64@1.1.2": { + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen@2.0.4": { + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter@1.1.0": { + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "@protobufjs/fetch@1.1.0": { + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", "dependencies": [ - "string-width@5.1.2", - "string-width-cjs@npm:string-width@4.2.3", - "strip-ansi@7.1.2", - "strip-ansi-cjs@npm:strip-ansi@6.0.1", - "wrap-ansi@8.1.0", - "wrap-ansi-cjs@npm:wrap-ansi@7.0.0" + "@protobufjs/aspromise", + "@protobufjs/inquire" ] }, - "@pkgjs/parseargs@0.11.0": { - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==" + "@protobufjs/float@1.0.2": { + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "@protobufjs/inquire@1.1.0": { + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "@protobufjs/path@1.1.2": { + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "@protobufjs/pool@1.1.0": { + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "@protobufjs/utf8@1.1.0": { + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, "@shikijs/cli@4.0.2": { "integrity": "sha512-1aj4yHpsYQ6ETF3/Pjz6FlejYIknnpzAV4YZJhgBm858b+6OtjmJK2LRJYF4UG/S+ijuHeUe2jyR1orXYfob+w==", @@ -273,6 +295,15 @@ "@types/mdurl@2.0.0": { "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==" }, + "@types/node@25.6.0": { + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dependencies": [ + "undici-types" + ] + }, + "@types/retry@0.12.0": { + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + }, "@types/unist@3.0.3": { "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" }, @@ -282,21 +313,6 @@ "agent-base@7.1.4": { "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" }, - "ansi-regex@5.0.1": { - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "ansi-regex@6.2.2": { - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" - }, - "ansi-styles@4.3.0": { - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": [ - "color-convert" - ] - }, - "ansi-styles@6.2.3": { - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" - }, "ansis@4.2.0": { "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==" }, @@ -314,21 +330,12 @@ "arkregex" ] }, - "balanced-match@1.0.2": { - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, "base64-js@1.5.1": { "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, "bignumber.js@9.3.1": { "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" }, - "brace-expansion@2.0.2": { - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dependencies": [ - "balanced-match" - ] - }, "buffer-equal-constant-time@1.0.1": { "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, @@ -344,26 +351,9 @@ "character-entities-legacy@3.0.0": { "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==" }, - "color-convert@2.0.1": { - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": [ - "color-name" - ] - }, - "color-name@1.1.4": { - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, "comma-separated-tokens@2.0.3": { "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==" }, - "cross-spawn@7.0.6": { - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dependencies": [ - "path-key", - "shebang-command", - "which" - ] - }, "data-uri-to-buffer@4.0.1": { "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" }, @@ -382,21 +372,12 @@ "dequal" ] }, - "eastasianwidth@0.2.0": { - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" - }, "ecdsa-sig-formatter@1.0.11": { "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "dependencies": [ "safe-buffer" ] }, - "emoji-regex@8.0.0": { - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "emoji-regex@9.2.2": { - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" - }, "entities@7.0.1": { "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" }, @@ -410,26 +391,18 @@ "web-streams-polyfill" ] }, - "foreground-child@3.3.1": { - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dependencies": [ - "cross-spawn", - "signal-exit" - ] - }, "formdata-polyfill@4.0.10": { "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "dependencies": [ "fetch-blob" ] }, - "gaxios@7.1.3": { - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "gaxios@7.1.4": { + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", "dependencies": [ "extend", "https-proxy-agent", - "node-fetch", - "rimraf" + "node-fetch" ] }, "gcp-metadata@8.1.2": { @@ -440,41 +413,20 @@ "json-bigint" ] }, - "glob@10.5.0": { - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dependencies": [ - "foreground-child", - "jackspeak", - "minimatch", - "minipass", - "package-json-from-dist", - "path-scurry" - ], - "deprecated": true, - "bin": true - }, - "google-auth-library@10.5.0": { - "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "google-auth-library@10.6.2": { + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", "dependencies": [ "base64-js", "ecdsa-sig-formatter", "gaxios", "gcp-metadata", "google-logging-utils", - "gtoken", "jws" ] }, "google-logging-utils@1.1.3": { "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==" }, - "gtoken@8.0.0": { - "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", - "dependencies": [ - "gaxios", - "jws" - ] - }, "has-flag@5.0.1": { "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==" }, @@ -510,21 +462,6 @@ "debug" ] }, - "is-fullwidth-code-point@3.0.0": { - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" - }, - "isexe@2.0.0": { - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" - }, - "jackspeak@3.4.3": { - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dependencies": [ - "@isaacs/cliui" - ], - "optionalDependencies": [ - "@pkgjs/parseargs" - ] - }, "json-bigint@1.0.0": { "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "dependencies": [ @@ -563,8 +500,8 @@ "uc.micro" ] }, - "lru-cache@10.4.3": { - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "long@5.3.2": { + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" }, "markdown-exit@1.0.0-beta.9": { "integrity": "sha512-5tzrMKMF367amyBly131vm6eGuWRL2DjBqWaFmPzPbLyuxP0XOmyyyroOAIXuBAMF/3kZbbfqOxvW/SotqKqbQ==", @@ -619,15 +556,6 @@ "micromark-util-types@2.0.2": { "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==" }, - "minimatch@9.0.5": { - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dependencies": [ - "brace-expansion" - ] - }, - "minipass@7.1.2": { - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" - }, "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, @@ -654,26 +582,38 @@ "regex-recursion" ] }, - "openai@6.9.1": { - "integrity": "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg==", + "openai@6.34.0": { + "integrity": "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw==", "bin": true }, - "package-json-from-dist@1.0.1": { - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" - }, - "path-key@3.1.1": { - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" - }, - "path-scurry@1.11.1": { - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "p-retry@4.6.2": { + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", "dependencies": [ - "lru-cache", - "minipass" + "@types/retry", + "retry" ] }, "property-information@7.1.0": { "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==" }, + "protobufjs@7.5.5": { + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "dependencies": [ + "@protobufjs/aspromise", + "@protobufjs/base64", + "@protobufjs/codegen", + "@protobufjs/eventemitter", + "@protobufjs/fetch", + "@protobufjs/float", + "@protobufjs/inquire", + "@protobufjs/path", + "@protobufjs/pool", + "@protobufjs/utf8", + "@types/node", + "long" + ], + "scripts": true + }, "punycode.js@2.3.1": { "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==" }, @@ -698,25 +638,12 @@ "type-fest" ] }, - "rimraf@5.0.10": { - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "dependencies": [ - "glob" - ], - "bin": true + "retry@0.13.1": { + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" }, "safe-buffer@5.2.1": { "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" }, - "shebang-command@2.0.0": { - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": [ - "shebang-regex" - ] - }, - "shebang-regex@3.0.0": { - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" - }, "shiki@4.0.2": { "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", "dependencies": [ @@ -730,28 +657,9 @@ "@types/hast" ] }, - "signal-exit@4.1.0": { - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" - }, "space-separated-tokens@2.0.2": { "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==" }, - "string-width@4.2.3": { - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": [ - "emoji-regex@8.0.0", - "is-fullwidth-code-point", - "strip-ansi@6.0.1" - ] - }, - "string-width@5.1.2": { - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dependencies": [ - "eastasianwidth", - "emoji-regex@9.2.2", - "strip-ansi@7.1.2" - ] - }, "stringify-entities@4.0.4": { "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", "dependencies": [ @@ -759,18 +667,6 @@ "character-entities-legacy" ] }, - "strip-ansi@6.0.1": { - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": [ - "ansi-regex@5.0.1" - ] - }, - "strip-ansi@7.1.2": { - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dependencies": [ - "ansi-regex@6.2.2" - ] - }, "supports-color@10.2.2": { "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==" }, @@ -793,6 +689,9 @@ "uc.micro@2.1.0": { "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" }, + "undici-types@7.19.2": { + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==" + }, "unist-util-is@6.0.1": { "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "dependencies": [ @@ -843,31 +742,8 @@ "web-streams-polyfill@3.3.3": { "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" }, - "which@2.0.2": { - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": [ - "isexe" - ], - "bin": true - }, - "wrap-ansi@7.0.0": { - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": [ - "ansi-styles@4.3.0", - "string-width@4.2.3", - "strip-ansi@6.0.1" - ] - }, - "wrap-ansi@8.1.0": { - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dependencies": [ - "ansi-styles@6.2.3", - "string-width@5.1.2", - "strip-ansi@7.1.2" - ] - }, - "ws@8.18.3": { - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==" + "ws@8.20.0": { + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==" }, "zwitch@2.0.4": { "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" @@ -908,14 +784,14 @@ "jsr:@std/assert@^1.0.16", "jsr:@std/io@~0.225.2", "jsr:@std/path@^1.1.3", - "npm:@anthropic-ai/sdk@0.90.0", - "npm:@google/genai@1.34.0", + "npm:@anthropic-ai/sdk@0.91.0", + "npm:@google/genai@1.50.1", "npm:@shikijs/cli@^4.0.2", "npm:ansis@^4.2.0", "npm:arktype@^2.2.0", "npm:json5@^2.2.3", "npm:markdown-exit@^1.0.0-beta.9", - "npm:openai@6.9.1", + "npm:openai@6.34.0", "npm:remeda@^2.32.0", "npm:supports-hyperlinks@^4.4.0" ] diff --git a/main.ts b/main.ts index 71177d1..a000077 100755 --- a/main.ts +++ b/main.ts @@ -448,7 +448,7 @@ the raw output to stdout.`) .example("5)", "ai -m 4o 'What are generic types?'") .example("6)", "ai gist -t 'Generic types'") .action(async (opts, ...args) => { - let outputSchema: ReturnType | undefined + let outputSchema if (opts.outputSchema) { try { outputSchema = parseType(opts.outputSchema) @@ -502,6 +502,9 @@ the raw output to stdout.`) if (opts.background && model.provider !== "openai") { throw new ValidationError("Background mode only works with OpenAI models") } + if (outputSchema && (opts.background || model.id === "gpt-5.4-pro")) { + throw new ValidationError("Structured output is not supported in background mode") + } chat.messages.push({ role: "user", @@ -512,7 +515,7 @@ the raw output to stdout.`) const chatInput: ChatInput = { chat, model, config, outputSchema } // no need to pass --background if using gpt-5-pro -- it always needs it - if (opts.background || model.id === "gpt-5.2-pro") { + if (opts.background || model.id === "gpt-5.4-pro") { try { const { id, status } = await gptBg.initiate(chatInput) chat.background = { diff --git a/schema.ts b/schema.ts index 674b9d0..af8cfc1 100644 --- a/schema.ts +++ b/schema.ts @@ -51,7 +51,9 @@ function closeObjects(schema: unknown): unknown { if (schema === null || typeof schema !== "object") return schema if (Array.isArray(schema)) return schema.map(closeObjects) const out: Record = {} - for (const [k, v] of Object.entries(schema)) out[k] = closeObjects(v) + for (const [k, v] of Object.entries(schema)) { + if (k !== "$schema") out[k] = closeObjects(v) + } if (out.type === "object" && out.additionalProperties === undefined) { out.additionalProperties = false } diff --git a/schema_test.ts b/schema_test.ts index ef8921b..d643072 100644 --- a/schema_test.ts +++ b/schema_test.ts @@ -1,5 +1,5 @@ import { assertEquals, assertThrows } from "@std/assert" -import { parseType } from "./schema.ts" +import { parseType, postprocessSchemaContent, prepareSchema } from "./schema.ts" function schema(input: string) { const { $schema: _, ...rest } = parseType(input).toJsonSchema() as Record< @@ -208,3 +208,75 @@ Deno.test("red-team - sequence operator is rejected", () => { "Failed to parse output schema as JSON5", ) }) + +Deno.test("prepareSchema - closes nested objects", () => { + const { schema, wrapped } = prepareSchema( + parseType("{ user: { name: 'string', age: 'number' }, tags: 'string[]' }"), + ) + assertEquals(wrapped, false) + assertEquals(schema, { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["age", "name"], + additionalProperties: false, + }, + tags: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["tags", "user"], + additionalProperties: false, + }) +}) + +Deno.test("prepareSchema - wraps primitive roots", () => { + const { schema, wrapped } = prepareSchema(parseType("'yes' | 'no'"), { + wrapPrimitives: true, + allRequired: true, + }) + assertEquals(wrapped, true) + assertEquals(schema, { + type: "object", + properties: { + value: { enum: ["no", "yes"] }, + }, + additionalProperties: false, + required: ["value"], + }) +}) + +Deno.test("prepareSchema - can force all properties required", () => { + const { schema } = prepareSchema( + parseType("{ urgent: 'boolean', 'reason?': 'string' }"), + { allRequired: true }, + ) + assertEquals(schema.required, ["urgent", "reason"]) +}) + +Deno.test("postprocessSchemaContent - unwraps primitive wrapper", () => { + assertEquals(postprocessSchemaContent(`{"value":"hello"}`, true), "hello") + assertEquals(postprocessSchemaContent(`{"value":3}`, true), "3") + assertEquals(postprocessSchemaContent(`{"value":["a","b"]}`, true), `["a","b"]`) +}) + +Deno.test("postprocessSchemaContent - removes bare string quotes", () => { + assertEquals(postprocessSchemaContent(`"hello"`, false), "hello") +}) + +Deno.test("postprocessSchemaContent - preserves non-wrapped JSON formatting", () => { + const content = `{ + "answer": "yes" +}` + assertEquals(postprocessSchemaContent(content, false), content) +}) + +Deno.test("postprocessSchemaContent - preserves malformed JSON", () => { + assertEquals(postprocessSchemaContent("not json", true), "not json") +}) From 3c92fdd5f91d13848b473292aef6bdfb32289817 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 24 Apr 2026 16:26:46 -0500 Subject: [PATCH 5/7] add CI, why not --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..23e053b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - run: deno fmt --check + - run: deno lint + - run: deno check + - run: deno test --allow-env From f1471dcb0a0611d5bacc05444decd0a53cc16993 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 24 Apr 2026 16:29:48 -0500 Subject: [PATCH 6/7] add example to help --- main.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/main.ts b/main.ts index a000077..85af9ae 100755 --- a/main.ts +++ b/main.ts @@ -446,7 +446,11 @@ the raw output to stdout.`) .example("3)", "echo 'what are you?' | ai") .example("4)", "ai -r 'elaborate on that'") .example("5)", "ai -m 4o 'What are generic types?'") - .example("6)", "ai gist -t 'Generic types'") + .example( + "6)", + "ai -o '{ urgent: \"boolean\", reason: \"string\" }' 'is this urgent? server is down'", + ) + .example("7)", "ai gist -t 'Generic types'") .action(async (opts, ...args) => { let outputSchema if (opts.outputSchema) { From 607d6aa320330f3f4612540e5ebb41039370c4c2 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 24 Apr 2026 16:45:37 -0500 Subject: [PATCH 7/7] tighten schema tests, simplify schema normalization --- schema.ts | 45 ++++------- schema_test.ts | 213 +++++++++++++++++-------------------------------- 2 files changed, 92 insertions(+), 166 deletions(-) diff --git a/schema.ts b/schema.ts index af8cfc1..d0d6280 100644 --- a/schema.ts +++ b/schema.ts @@ -1,24 +1,12 @@ import { type Type, type as arkType } from "arktype" import JSON5 from "json5" -/** - * Parse a `--output-schema` argument with arktype. - * - * Inputs starting with `{` or `[` are parsed as JSON5 (permits single quotes, - * unquoted keys, trailing commas) and passed to arktype as an object/tuple - * definition. Everything else is passed directly to arktype's string DSL, - * which handles primitives, unions, arrays (`string[]`), refinements - * (`number > 0`), etc. - * - * JSON5 is used instead of `eval` so malformed input produces a readable - * parse error and no arbitrary JS can run. - */ -// arktype's `type` is heavily overloaded on string literals. For our dynamic -// input we route through a loosely-typed alias to avoid deep instantiation. -// deno-lint-ignore no-explicit-any -const ark = arkType as unknown as (def: any) => Type - +/** Parse a `--output-schema` argument with arktype. */ export function parseType(input: string): Type { + // Object/tuple definitions are parsed as JSON5 (single quotes, unquoted keys, + // trailing commas) and passed to arktype as data. Everything else is passed to + // arktype's string DSL for primitives, unions, arrays, and refinements. + // // The naive order here would be "if input starts with `{` or `[`, parse // as JSON5, else pass to arktype's string DSL." We invert that: try JSON5 // first, and only commit to it if the result is an object/array. This is more @@ -37,9 +25,15 @@ export function parseType(input: string): Type { const msg = e instanceof Error ? e.message : String(e) throw new Error(`Failed to parse output schema as JSON5: ${msg}`) } - return ark(input) + return arkType.raw(input) } - return parsed !== null && typeof parsed === "object" ? ark(parsed) : ark(input) + return parsed !== null && typeof parsed === "object" + ? arkType.raw(parsed) + : arkType.raw(input) +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) } /** @@ -66,11 +60,8 @@ function forceAllRequired(s: unknown): unknown { if (Array.isArray(s)) return s.map(forceAllRequired) const out: Record = {} for (const [k, v] of Object.entries(s)) out[k] = forceAllRequired(v) - if ( - out.type === "object" && out.properties && - typeof out.properties === "object" && !Array.isArray(out.properties) - ) { - out.required = Object.keys(out.properties as Record) + if (out.type === "object" && isRecord(out.properties)) { + out.required = Object.keys(out.properties) } return out } @@ -118,10 +109,8 @@ export function postprocessSchemaContent(content: string, wrapped: boolean): str } catch { return content } - if ( - wrapped && parsed !== null && typeof parsed === "object" && "value" in parsed - ) { - parsed = (parsed as { value: unknown }).value + if (wrapped && isRecord(parsed) && "value" in parsed) { + parsed = parsed.value } if (typeof parsed === "string") return parsed // re-stringify only if we unwrapped; otherwise preserve provider's formatting diff --git a/schema_test.ts b/schema_test.ts index d643072..0b7ec4b 100644 --- a/schema_test.ts +++ b/schema_test.ts @@ -1,7 +1,7 @@ import { assertEquals, assertThrows } from "@std/assert" import { parseType, postprocessSchemaContent, prepareSchema } from "./schema.ts" -function schema(input: string) { +function schema(input: string): Record { const { $schema: _, ...rest } = parseType(input).toJsonSchema() as Record< string, unknown @@ -9,27 +9,18 @@ function schema(input: string) { return rest } -Deno.test("parseType - boolean primitive", () => { - assertEquals(schema("boolean"), { type: "boolean" }) -}) - -Deno.test("parseType - string primitive", () => { - assertEquals(schema("string"), { type: "string" }) -}) - -Deno.test("parseType - number primitive", () => { - assertEquals(schema("number"), { type: "number" }) -}) +Deno.test("parseType - string DSL schemas", () => { + const cases: Array<[string, Record]> = [ + ["boolean", { type: "boolean" }], + ["string", { type: "string" }], + ["number", { type: "number" }], + ["'yes' | 'no'", { enum: ["no", "yes"] }], + ["string[]", { type: "array", items: { type: "string" } }], + ] -Deno.test("parseType - string literal union", () => { - assertEquals(schema("'yes' | 'no'"), { enum: ["no", "yes"] }) -}) - -Deno.test("parseType - string array", () => { - assertEquals(schema("string[]"), { - type: "array", - items: { type: "string" }, - }) + for (const [input, expected] of cases) { + assertEquals(schema(input), expected) + } }) Deno.test("parseType - object with optional field", () => { @@ -84,129 +75,44 @@ Deno.test("parseType - refinement (number > 0)", () => { assertEquals(s.exclusiveMinimum, 0) }) -Deno.test("parseType - unresolvable string", () => { - assertThrows( - () => parseType("blargh"), - Error, - "'blargh' is unresolvable", - ) -}) - -Deno.test("parseType - dangling union operator", () => { - assertThrows( - () => parseType("'yes' |"), - Error, - "Token '|' requires a right operand", - ) -}) +Deno.test("parseType - parse errors", () => { + const cases = [ + ["blargh", "'blargh' is unresolvable"], + ["'yes' |", "Token '|' requires a right operand"], + ["{ x: 'nonsense' }", "'nonsense' is unresolvable"], + ["{ x: ", "Failed to parse output schema as JSON5"], + ["{ x: 1 + 2 }", "Failed to parse output schema as JSON5"], + ["{ x: 'number' } foo", "Failed to parse output schema as JSON5"], + ] as const -Deno.test("parseType - unresolvable value inside object", () => { - assertThrows( - () => parseType("{ x: 'nonsense' }"), - Error, - "'nonsense' is unresolvable", - ) -}) - -Deno.test("parseType - truncated object literal", () => { - assertThrows( - () => parseType("{ x: "), - Error, - "Failed to parse output schema as JSON5", - ) -}) - -Deno.test("parseType - expressions in object literals are rejected", () => { - // JSON5 rejects `1 + 2`, so arbitrary JS can't sneak in - assertThrows( - () => parseType("{ x: 1 + 2 }"), - Error, - "Failed to parse output schema as JSON5", - ) -}) - -Deno.test("parseType - trailing garbage after object literal", () => { - assertThrows( - () => parseType("{ x: 'number' } foo"), - Error, - "Failed to parse output schema as JSON5", - ) + for (const [input, expectedError] of cases) { + assertThrows(() => parseType(input), Error, expectedError) + } }) // Red-team: things `new Function` / eval would have accepted must now be // rejected by JSON5. All of these should fail before arktype ever sees them. -Deno.test("red-team - function call in value is rejected", () => { - assertThrows( - () => parseType("{ x: Date.now() }"), - Error, - "Failed to parse output schema as JSON5", - ) -}) - -Deno.test("red-team - bare identifier reference is rejected", () => { - assertThrows( - () => parseType("{ x: Deno }"), - Error, - "Failed to parse output schema as JSON5", - ) -}) - -Deno.test("red-team - IIFE is rejected", () => { - assertThrows( - () => parseType("{ x: (() => 'string')() }"), - Error, - "Failed to parse output schema as JSON5", - ) -}) - -Deno.test("red-team - template literal is rejected", () => { - assertThrows( - () => parseType("{ x: `string` }"), - Error, - "Failed to parse output schema as JSON5", - ) -}) - -Deno.test("red-team - regex literal is rejected", () => { - assertThrows( - () => parseType("{ x: /foo/ }"), - Error, - "Failed to parse output schema as JSON5", - ) -}) - -Deno.test("red-team - new expression is rejected", () => { - assertThrows( - () => parseType("{ x: new Date() }"), - Error, - "Failed to parse output schema as JSON5", - ) -}) - -Deno.test("red-team - spread is rejected", () => { - assertThrows( - () => parseType("{ ...{ x: 'string' } }"), - Error, - "Failed to parse output schema as JSON5", - ) -}) - -Deno.test("red-team - method call on string is rejected", () => { - assertThrows( - () => parseType("{ x: 'foo'.repeat(3) }"), - Error, - "Failed to parse output schema as JSON5", - ) -}) - -Deno.test("red-team - sequence operator is rejected", () => { - // with eval: (sideEffect(), 'string') returns 'string' — fully exploitable - assertThrows( - () => parseType("{ x: (0, 'string') }"), - Error, - "Failed to parse output schema as JSON5", - ) +Deno.test("red-team - object literal expressions are rejected", () => { + const cases = [ + "{ x: Date.now() }", + "{ x: Deno }", + "{ x: (() => 'string')() }", + "{ x: `string` }", + "{ x: /foo/ }", + "{ x: new Date() }", + "{ ...{ x: 'string' } }", + "{ x: 'foo'.repeat(3) }", + "{ x: (0, 'string') }", + ] + + for (const input of cases) { + assertThrows( + () => parseType(input), + Error, + "Failed to parse output schema as JSON5", + ) + } }) Deno.test("prepareSchema - closes nested objects", () => { @@ -260,6 +166,32 @@ Deno.test("prepareSchema - can force all properties required", () => { assertEquals(schema.required, ["urgent", "reason"]) }) +Deno.test("prepareSchema - normalizes objects inside arrays", () => { + const { schema } = prepareSchema( + parseType("{ users: [{ name: 'string', 'age?': 'number' }, '[]'] }"), + { allRequired: true }, + ) + assertEquals(schema, { + type: "object", + properties: { + users: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name", "age"], + additionalProperties: false, + }, + }, + }, + required: ["users"], + additionalProperties: false, + }) +}) + Deno.test("postprocessSchemaContent - unwraps primitive wrapper", () => { assertEquals(postprocessSchemaContent(`{"value":"hello"}`, true), "hello") assertEquals(postprocessSchemaContent(`{"value":3}`, true), "3") @@ -277,6 +209,11 @@ Deno.test("postprocessSchemaContent - preserves non-wrapped JSON formatting", () assertEquals(postprocessSchemaContent(content, false), content) }) +Deno.test("postprocessSchemaContent - preserves non-wrapped value property", () => { + const content = `{"value":"hello"}` + assertEquals(postprocessSchemaContent(content, false), content) +}) + Deno.test("postprocessSchemaContent - preserves malformed JSON", () => { assertEquals(postprocessSchemaContent("not json", true), "not json") })