|
| 1 | +import type * as Message from "../message/message.ts"; |
| 2 | +import * as Model from "../model/model.ts"; |
| 3 | + |
| 4 | +type JsonSchemaObject = Record<string, unknown> & { |
| 5 | + properties?: Record<string, unknown>; |
| 6 | + required?: unknown; |
| 7 | +}; |
| 8 | + |
| 9 | +class UnsupportedStrictSchemaError extends Error {} |
| 10 | + |
| 11 | +/** True when a provider's strict mode rejects this schema keyword with this value. */ |
| 12 | +type UnsupportedKeyword = (key: string, value: unknown) => boolean; |
| 13 | + |
| 14 | +const UNSUPPORTED_KEYWORDS = [ |
| 15 | + "$ref", |
| 16 | + "$defs", |
| 17 | + "definitions", |
| 18 | + "allOf", |
| 19 | + "oneOf", |
| 20 | + "patternProperties", |
| 21 | + "dependentSchemas", |
| 22 | + "dependencies", |
| 23 | + "unevaluatedProperties", |
| 24 | + "propertyNames", |
| 25 | + "contains", |
| 26 | + "prefixItems", |
| 27 | + "not", |
| 28 | + "if", |
| 29 | + "then", |
| 30 | + "else", |
| 31 | +] as const; |
| 32 | + |
| 33 | +// Anthropic strict tool use rejects these with a 400 for the whole request. |
| 34 | +// https://platform.claude.com/docs/en/build-with-claude/structured-outputs#json-schema-limitations |
| 35 | +const ANTHROPIC_UNSUPPORTED_KEYWORDS = new Set([ |
| 36 | + "minimum", |
| 37 | + "maximum", |
| 38 | + "exclusiveMinimum", |
| 39 | + "exclusiveMaximum", |
| 40 | + "multipleOf", |
| 41 | + "maxItems", |
| 42 | + "uniqueItems", |
| 43 | + "minContains", |
| 44 | + "maxContains", |
| 45 | + "minProperties", |
| 46 | + "maxProperties", |
| 47 | +]); |
| 48 | +const ANTHROPIC_STRING_FORMATS = new Set([ |
| 49 | + "date-time", |
| 50 | + "time", |
| 51 | + "date", |
| 52 | + "duration", |
| 53 | + "email", |
| 54 | + "hostname", |
| 55 | + "uri", |
| 56 | + "ipv4", |
| 57 | + "ipv6", |
| 58 | + "uuid", |
| 59 | +]); |
| 60 | + |
| 61 | +const isAnthropicUnsupportedKeyword: UnsupportedKeyword = (key, value) => { |
| 62 | + if (ANTHROPIC_UNSUPPORTED_KEYWORDS.has(key)) return true; |
| 63 | + if (key === "minItems") return value !== 0 && value !== 1; |
| 64 | + if (key === "format") return typeof value !== "string" || !ANTHROPIC_STRING_FORMATS.has(value); |
| 65 | + return false; |
| 66 | +}; |
| 67 | + |
| 68 | +function isJsonSchemaObject(value: unknown): value is JsonSchemaObject { |
| 69 | + return typeof value === "object" && value !== null && !Array.isArray(value); |
| 70 | +} |
| 71 | + |
| 72 | +function isNullSchema(schema: unknown): boolean { |
| 73 | + return isJsonSchemaObject(schema) && schema.type === "null"; |
| 74 | +} |
| 75 | + |
| 76 | +/** `X` of an `anyOf: [X, null]` schema. */ |
| 77 | +export function nullableVariant(schema: unknown): unknown { |
| 78 | + if (!isJsonSchemaObject(schema) || !Array.isArray(schema.anyOf)) return; |
| 79 | + const variants = schema.anyOf.filter((variant) => !isNullSchema(variant)); |
| 80 | + return variants.length === 1 ? variants[0] : undefined; |
| 81 | +} |
| 82 | + |
| 83 | +function isStructuredSchema(schema: unknown): boolean { |
| 84 | + if (!isJsonSchemaObject(schema)) return false; |
| 85 | + const types = typeof schema.type === "string" ? [schema.type] : Array.isArray(schema.type) ? schema.type : []; |
| 86 | + return ( |
| 87 | + types.includes("object") || |
| 88 | + types.includes("array") || |
| 89 | + schema.properties !== undefined || |
| 90 | + schema.items !== undefined |
| 91 | + ); |
| 92 | +} |
| 93 | + |
| 94 | +const CONSTRAINING_KEYWORDS = [ |
| 95 | + "type", |
| 96 | + "const", |
| 97 | + "enum", |
| 98 | + "anyOf", |
| 99 | + "allOf", |
| 100 | + "oneOf", |
| 101 | + "not", |
| 102 | + "$ref", |
| 103 | + "properties", |
| 104 | + "items", |
| 105 | +]; |
| 106 | + |
| 107 | +export function schemaAllowsNull(schema: unknown): boolean { |
| 108 | + if (schema === true) return true; |
| 109 | + if (!isJsonSchemaObject(schema)) return false; |
| 110 | + if (CONSTRAINING_KEYWORDS.every((key) => schema[key] === undefined)) return true; |
| 111 | + if (schema.type === "null" || (Array.isArray(schema.type) && schema.type.includes("null"))) return true; |
| 112 | + if (schema.const === null || (Array.isArray(schema.enum) && schema.enum.includes(null))) return true; |
| 113 | + return Array.isArray(schema.anyOf) && schema.anyOf.some((variant) => schemaAllowsNull(variant)); |
| 114 | +} |
| 115 | + |
| 116 | +function makeNodeStrict(schema: unknown, isUnsupportedKeyword?: UnsupportedKeyword): void { |
| 117 | + if (!isJsonSchemaObject(schema)) throw new UnsupportedStrictSchemaError("boolean schemas are unsupported"); |
| 118 | + for (const key of UNSUPPORTED_KEYWORDS) { |
| 119 | + if (schema[key] !== undefined) throw new UnsupportedStrictSchemaError(`${key} schemas are unsupported`); |
| 120 | + } |
| 121 | + if (isUnsupportedKeyword) { |
| 122 | + for (const [key, value] of Object.entries(schema)) { |
| 123 | + if (isUnsupportedKeyword(key, value)) { |
| 124 | + throw new UnsupportedStrictSchemaError(`${key}: ${JSON.stringify(value)} is unsupported`); |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + if (schema.anyOf !== undefined) { |
| 130 | + if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) { |
| 131 | + throw new UnsupportedStrictSchemaError("anyOf must contain at least one schema"); |
| 132 | + } |
| 133 | + // `[X, null]` is the nullable shape this transform itself emits; only real unions of structures are rejected. |
| 134 | + const nullable = nullableVariant(schema) !== undefined; |
| 135 | + for (const variant of schema.anyOf) { |
| 136 | + if (!nullable && isStructuredSchema(variant)) { |
| 137 | + throw new UnsupportedStrictSchemaError("object and array unions are unsupported"); |
| 138 | + } |
| 139 | + makeNodeStrict(variant, isUnsupportedKeyword); |
| 140 | + } |
| 141 | + } |
| 142 | + |
| 143 | + if (schema.items !== undefined) { |
| 144 | + if (Array.isArray(schema.items)) throw new UnsupportedStrictSchemaError("tuple schemas are unsupported"); |
| 145 | + makeNodeStrict(schema.items, isUnsupportedKeyword); |
| 146 | + } |
| 147 | + |
| 148 | + const isObjectSchema = schema.type === "object"; |
| 149 | + if (schema.properties !== undefined && !isObjectSchema) { |
| 150 | + throw new UnsupportedStrictSchemaError("properties require type object"); |
| 151 | + } |
| 152 | + if (!isObjectSchema) return; |
| 153 | + // Effect Schema emits `additionalProperties: true` for every struct; excess keys are dropped on decode, so |
| 154 | + // closing the object loses nothing. Only schema-valued additionalProperties carries meaning. |
| 155 | + if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") { |
| 156 | + throw new UnsupportedStrictSchemaError("schema-valued additionalProperties is unsupported"); |
| 157 | + } |
| 158 | + if (schema.properties !== undefined && !isJsonSchemaObject(schema.properties)) { |
| 159 | + throw new UnsupportedStrictSchemaError("object properties must be a schema map"); |
| 160 | + } |
| 161 | + if ( |
| 162 | + schema.required !== undefined && |
| 163 | + (!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== "string")) |
| 164 | + ) { |
| 165 | + throw new UnsupportedStrictSchemaError("object required must be a string array"); |
| 166 | + } |
| 167 | + |
| 168 | + const properties = schema.properties ?? {}; |
| 169 | + const propertyNames = Object.keys(properties); |
| 170 | + const required = new Set(Array.isArray(schema.required) ? schema.required : []); |
| 171 | + if ([...required].some((key) => !propertyNames.includes(key))) { |
| 172 | + throw new UnsupportedStrictSchemaError("required contains an unknown property"); |
| 173 | + } |
| 174 | + for (const [key, property] of Object.entries(properties)) { |
| 175 | + makeNodeStrict(property, isUnsupportedKeyword); |
| 176 | + if (!required.has(key) && !schemaAllowsNull(property)) { |
| 177 | + properties[key] = { anyOf: [property, { type: "null" }] }; |
| 178 | + } |
| 179 | + } |
| 180 | + schema.required = propertyNames; |
| 181 | + schema.additionalProperties = false; |
| 182 | +} |
| 183 | + |
| 184 | +/** |
| 185 | + * Convert a tool's parameter schema to the subset strict constrained sampling accepts: every object closed and |
| 186 | + * every property required, with formerly optional properties made nullable. Callers strip those nulls again |
| 187 | + * before validation (see `normalizeOptionalNulls`). |
| 188 | + */ |
| 189 | +function makeStrictJsonSchema(schema: unknown, isUnsupportedKeyword?: UnsupportedKeyword): Record<string, unknown> { |
| 190 | + const cloned: unknown = structuredClone(schema); |
| 191 | + if (!isJsonSchemaObject(cloned) || cloned.type !== "object") { |
| 192 | + throw new UnsupportedStrictSchemaError("root schema must have type object"); |
| 193 | + } |
| 194 | + makeNodeStrict(cloned, isUnsupportedKeyword); |
| 195 | + return cloned; |
| 196 | +} |
| 197 | + |
| 198 | +/** |
| 199 | + * The strict parameter schema to send for a JSON-schema constrained tool, or `undefined` to send it non-strict. |
| 200 | + * A `"prefer"` tool whose schema the model's strict mode cannot express falls back to non-strict; a `"require"` |
| 201 | + * tool throws instead. |
| 202 | + */ |
| 203 | +export function resolveStrictParameters( |
| 204 | + tool: Message.Tool, |
| 205 | + model?: Pick<Model.Info, "protocol" | "compat">, |
| 206 | +): Record<string, unknown> | undefined { |
| 207 | + const config = tool.constrainedSampling; |
| 208 | + if (!config || config.type !== "json_schema") return; |
| 209 | + |
| 210 | + if (model?.compat?.supportsStrictMode ?? true) { |
| 211 | + const isAnthropic = |
| 212 | + model?.protocol === Model.KnownProviderEnum.anthropic || |
| 213 | + model?.protocol === Model.KnownProviderEnum.googleVertexAnthropic; |
| 214 | + try { |
| 215 | + return makeStrictJsonSchema(tool.parameters, isAnthropic ? isAnthropicUnsupportedKeyword : undefined); |
| 216 | + } catch (error) { |
| 217 | + if (!(error instanceof UnsupportedStrictSchemaError)) throw error; |
| 218 | + if (config.strict !== "require") return; |
| 219 | + throw new Error(`tool "${tool.name}" requires JSON-schema constrained sampling, but ${error.message}.`); |
| 220 | + } |
| 221 | + } |
| 222 | + if (config.strict === "require") { |
| 223 | + throw new Error( |
| 224 | + `tool "${tool.name}" requires JSON-schema constrained sampling, but strict tools are unsupported.`, |
| 225 | + ); |
| 226 | + } |
| 227 | +} |
0 commit comments