| 1 | /** |
| 2 | * Adapts Effect Schema codecs to the JSON Schema subset accepted by Anthropic |
| 3 | * structured output. |
| 4 | * |
| 5 | * The main entry point returns the JSON Schema to send to Anthropic and a codec |
| 6 | * for decoding the model response back into the original application type. |
| 7 | * Unsupported constraints can be omitted from the provider schema and remain |
| 8 | * enforced by the returned codec. |
| 9 | * |
| 10 | * @stability unstable |
| 11 | * @since 4.0.0 |
| 12 | */ |
| 13 | import type * as JsonSchema from "../JsonSchema.ts" |
| 14 | import * as Rec from "../Record.ts" |
| 15 | import * as Schema from "../Schema.ts" |
| 16 | import * as InternalStructuredOutput from "./internal/structured-output.ts" |
| 17 | import * as LanguageModel from "./LanguageModel.ts" |
| 18 | import * as OpenAiStructuredOutput from "./OpenAiStructuredOutput.ts" |
| 19 | |
| 20 | /** |
| 21 | * Converts a `Schema.Codec` to Anthropic structured-output JSON Schema and a |
| 22 | * matching codec for model output. |
| 23 | * |
| 24 | * **When to use** |
| 25 | * |
| 26 | * Use when you send Effect Schema-backed structured output requests to |
| 27 | * Anthropic and need provider-compatible JSON Schema without losing the decoded |
| 28 | * application type. |
| 29 | * |
| 30 | * **Details** |
| 31 | * |
| 32 | * Returns the JSON Schema to include in the request and the codec to use when |
| 33 | * decoding the model response. The codec remains authoritative: the provider |
| 34 | * JSON Schema can be a lossy, less restrictive representation when Anthropic |
| 35 | * cannot express an Effect Schema constraint. |
| 36 | * |
| 37 | * **Gotchas** |
| 38 | * |
| 39 | * - Some schemas use a provider-safe encoded shape: tuples become objects with |
| 40 | * numeric string keys, objects with index signatures become arrays of |
| 41 | * `[key, value]` pairs, and optional properties become required nullable |
| 42 | * properties. |
| 43 | * - `oneOf` unions are emitted as `anyOf` unions. |
| 44 | * - Unsupported constraints are removed from the provider schema and are still |
| 45 | * checked while decoding with the returned codec. |
| 46 | * - Recursive schemas throw during conversion because Anthropic structured |
| 47 | * output does not support recursive references. |
| 48 | * |
| 49 | * @see {@link LanguageModel.CodecTransformer} for the structured-output transformer contract |
| 50 | * @see {@link OpenAiStructuredOutput.toCodecOpenAI} for the OpenAI-specific transformer |
| 51 | * |
| 52 | * @stability unstable |
| 53 | * @category transforming |
| 54 | * @since 4.0.0 |
| 55 | */ |
| 56 | export function toCodecAnthropic<T, E, RD, RE>( |
| 57 | schema: Schema.ConstraintCodec<T, E, RD, RE> |
| 58 | ): { |
| 59 | readonly codec: Schema.ConstraintCodec<T, unknown, RD, RE> |
| 60 | readonly jsonSchema: JsonSchema.JsonSchema |
| 61 | } { |
| 62 | const codec = InternalStructuredOutput.toCodec(schema) |
| 63 | const unresolvedDocument = Schema.toJsonSchemaDocument(codec, { |
| 64 | generateDescriptions: true, |
| 65 | onExcessProperty: "error" |
| 66 | }) |
| 67 | if (hasReferenceCycle(unresolvedDocument.schema, unresolvedDocument.definitions)) { |
| 68 | throw new Error("AnthropicStructuredOutput: Recursive schemas are not supported") |
| 69 | } |
| 70 | const document = InternalStructuredOutput.resolveTopLevelReference(unresolvedDocument) |
| 71 | const jsonSchema = rewriteAnthropic(document.schema) |
| 72 | if (Object.keys(document.definitions).length > 0) { |
| 73 | jsonSchema.$defs = Rec.map(document.definitions, rewriteAnthropic) |
| 74 | } |
| 75 | return { codec, jsonSchema } |
| 76 | } |
| 77 | |
| 78 | function hasReferenceCycle( |
| 79 | root: JsonSchema.JsonSchema, |
| 80 | definitions: JsonSchema.Definitions |
| 81 | ): boolean { |
| 82 | const visiting = new Set<JsonSchema.JsonSchema>() |
| 83 | const visited = new Set<JsonSchema.JsonSchema>() |
| 84 | |
| 85 | function visit(schema: JsonSchema.JsonSchema): boolean { |
| 86 | if (visiting.has(schema)) return true |
| 87 | if (visited.has(schema)) return false |
| 88 | |
| 89 | visiting.add(schema) |
| 90 | let cycle = false |
| 91 | InternalStructuredOutput.walkJsonSchema(schema, (node) => { |
| 92 | if (!cycle && typeof node.$ref === "string") { |
| 93 | const target = node.$ref === "#" ? root : InternalStructuredOutput.resolveReference(node.$ref, definitions) |
| 94 | if (visit(target)) cycle = true |
| 95 | } |
| 96 | return node |
| 97 | }) |
| 98 | visiting.delete(schema) |
| 99 | visited.add(schema) |
| 100 | return cycle |
| 101 | } |
| 102 | |
| 103 | return visit(root) |
| 104 | } |
| 105 | |
| 106 | function rewriteAnthropic(schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema { |
| 107 | return InternalStructuredOutput.walkJsonSchema(schema, (schema) => { |
| 108 | const normalized = hoistAllOfDescriptions(schema) |
| 109 | const out: JsonSchema.JsonSchema = {} |
| 110 | let unsupportedFormat: string | undefined |
| 111 | for (const [key, value] of Object.entries(normalized)) { |
| 112 | if (key === "format") { |
| 113 | if ( |
| 114 | typeof value === "string" && formats.has(value) && |
| 115 | (normalized.type === undefined || normalized.type === "string") |
| 116 | ) { |
| 117 | out.format = value |
| 118 | } else if (typeof value === "string") unsupportedFormat = value |
| 119 | } else if (supportedKeywords.has(key)) { |
| 120 | if (key !== "additionalProperties" || value === false) out[key] = value |
| 121 | } |
| 122 | } |
| 123 | if (unsupportedFormat !== undefined) { |
| 124 | InternalStructuredOutput.appendDescription(out, `a value with a format of ${unsupportedFormat}`) |
| 125 | } |
| 126 | return out |
| 127 | }) |
| 128 | } |
| 129 | |
| 130 | function hoistAllOfDescriptions(schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema { |
| 131 | if (!Array.isArray(schema.allOf)) return schema |
| 132 | |
| 133 | const out: JsonSchema.JsonSchema = {} |
| 134 | for (const [key, value] of Object.entries(schema)) { |
| 135 | if (key !== "allOf") out[key] = value |
| 136 | } |
| 137 | const members: Array<JsonSchema.JsonSchema> = [] |
| 138 | for (const member of schema.allOf) { |
| 139 | if (!InternalStructuredOutput.isJsonSchema(member)) continue |
| 140 | const { description, format, title, ...memberRest } = member |
| 141 | const rest = { ...memberRest } |
| 142 | if (schema.type !== undefined && schema.type !== "string") delete rest.pattern |
| 143 | if (typeof description === "string") { |
| 144 | InternalStructuredOutput.appendDescription(out, description) |
| 145 | } |
| 146 | if (out.format === undefined && typeof format === "string") out.format = format |
| 147 | if (out.title === undefined && typeof title === "string") out.title = title |
| 148 | if (Object.keys(rest).length > 0) members.push(rest) |
| 149 | } |
| 150 | if (members.length > 0) out.allOf = members |
| 151 | return out |
| 152 | } |
| 153 | |
| 154 | const supportedKeywords = new Set([ |
| 155 | "$ref", |
| 156 | "type", |
| 157 | "title", |
| 158 | "description", |
| 159 | "enum", |
| 160 | "const", |
| 161 | "anyOf", |
| 162 | "allOf", |
| 163 | "properties", |
| 164 | "required", |
| 165 | "additionalProperties", |
| 166 | "items", |
| 167 | "pattern" |
| 168 | ]) |
| 169 | |
| 170 | const formats = new Set([ |
| 171 | "date-time", |
| 172 | "time", |
| 173 | "date", |
| 174 | "duration", |
| 175 | "email", |
| 176 | "hostname", |
| 177 | "uri", |
| 178 | "ipv4", |
| 179 | "ipv6", |
| 180 | "uuid" |
| 181 | ]) |