UNPKG

openai

Version:

The official TypeScript library for the OpenAI API

571 lines (502 loc) 19.8 kB
import { OpenAIError } from '../error'; import { AutoParseableResponseFormat, AutoParseableTextFormat, AutoParseableTool, makeParseableResponseFormat, makeParseableTextFormat, makeParseableTool, } from '../lib/parser'; import { AutoParseableResponseTool, makeParseableResponseTool } from '../lib/ResponsesParser'; import { type JSONSchema } from '../lib/jsonschema'; import { assertNoNestedSchemaIds, forEachJSONSchemaChild, hasOnlyRefAndAnnotations, normalizeObjectAllOfForExclusivity, resolveLocalRef, rewriteLocalRefsIntoMovedOneOfBranches, toStrictJsonSchema, } from '../lib/transform'; import { ResponseFormatJSONSchema } from '../resources/index'; import { type ResponseFormatTextJSONSchemaConfig } from '../resources/responses/responses'; type StandardSchemaIssue = { readonly message: string; readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }> | undefined; }; type StandardSchemaResult<Output> = | { readonly value: Output; readonly issues?: undefined; } | { readonly issues: ReadonlyArray<StandardSchemaIssue>; }; type StandardJSONSchemaOptions = { readonly target: 'draft-07'; readonly libraryOptions?: Record<string, unknown> | undefined; }; type StandardSchemaLike<Input = unknown, Output = Input> = { readonly '~standard': { readonly version: 1; readonly vendor: string; readonly types?: | { readonly input: Input; readonly output: Output; } | undefined; readonly validate: ( value: unknown, ) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>; readonly jsonSchema?: | { readonly input: (options: StandardJSONSchemaOptions) => Record<string, unknown>; readonly output?: (options: StandardJSONSchemaOptions) => Record<string, unknown>; } | undefined; }; }; type InferStandardOutput<Schema extends StandardSchemaLike> = [NonNullable<Schema['~standard']['types']>] extends [never] ? unknown : NonNullable<Schema['~standard']['types']> extends { readonly output: infer Output } ? Output : unknown; type StandardSchemaJSONSchemaProps = { /** * A JSON Schema override for Standard Schema implementations that do not * expose `~standard.jsonSchema.input()`. */ schema?: JSONSchema | Record<string, unknown> | undefined; }; type StandardResponseFormatProps = Omit<ResponseFormatJSONSchema.JSONSchema, 'schema' | 'strict' | 'name'> & StandardSchemaJSONSchemaProps; type StandardTextFormatProps = Omit< ResponseFormatTextJSONSchemaConfig, 'schema' | 'type' | 'strict' | 'name' > & StandardSchemaJSONSchemaProps; type StandardToolFunction<Parameters extends StandardSchemaLike> = ( args: InferStandardOutput<Parameters>, ) => unknown | Promise<unknown>; type StandardToolOptions<Parameters extends StandardSchemaLike> = { name: string; parameters: Parameters; /** * A JSON Schema override for Standard Schema implementations that do not * expose `~standard.jsonSchema.input()`. */ schema?: JSONSchema | Record<string, unknown> | undefined; function?: StandardToolFunction<Parameters> | undefined; description?: string | undefined; }; type StandardToolReturnOptions< Parameters extends StandardSchemaLike, Function extends StandardToolFunction<Parameters> | undefined, > = { arguments: InferStandardOutput<Parameters>; name: string; function: Function; }; function isPromiseLike(value: unknown): value is PromiseLike<unknown> { return typeof value === 'object' && value !== null && 'then' in value && typeof value.then === 'function'; } function formatStandardSchemaIssues(issues: ReadonlyArray<StandardSchemaIssue>): string { return issues .map((issue) => { const path = issue.path ?.map((segment) => typeof segment === 'object' && segment !== null && 'key' in segment ? segment.key : segment, ) .map(String) .join('.'); return path ? `${path}: ${issue.message}` : issue.message; }) .join('; '); } const JSON_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']); type JSONPrimitive = string | number | boolean | null; function getSchemaTypes(schema: unknown): Set<string> | undefined { if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return undefined; const type = (schema as Record<string, unknown>)['type']; if (type === undefined) { return getLiteralSchemaTypes(schema); } const types = Array.isArray(type) ? type : [type]; if ( types.length === 0 || !types.every((value) => typeof value === 'string' && JSON_SCHEMA_TYPES.has(value)) ) { return undefined; } return new Set(types); } function isJSONPrimitive(value: unknown): value is JSONPrimitive { return ( value === null || typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)) ); } function getLiteralValues(schema: unknown): JSONPrimitive[] | undefined { if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return undefined; const record = schema as Record<string, unknown>; if ('const' in record && isJSONPrimitive(record['const'])) { return [record['const']]; } const enumValues = record['enum']; if (Array.isArray(enumValues) && enumValues.length > 0 && enumValues.every(isJSONPrimitive)) { return enumValues; } return undefined; } function getLiteralSchemaTypes(schema: unknown): Set<string> | undefined { const literalValues = getLiteralValues(schema); if (!literalValues) return undefined; return new Set( literalValues.map((value) => { if (value === null) return 'null'; return typeof value; }), ); } function haveDisjointLiteralValues(left: unknown, right: unknown): boolean { const leftValues = getLiteralValues(left); const rightValues = getLiteralValues(right); if (!leftValues || !rightValues) return false; return leftValues.every((leftValue) => !rightValues.some((rightValue) => leftValue === rightValue)); } function schemaTypesOverlap(left: string, right: string): boolean { return ( left === right || (left === 'integer' && right === 'number') || (left === 'number' && right === 'integer') ); } function isObjectOnlySchema(schema: unknown): boolean { const types = getSchemaTypes(schema); return types?.size === 1 && types.has('object'); } function haveDisjointObjectDiscriminator(left: unknown, right: unknown, root: JSONSchema): boolean { if (!isObjectOnlySchema(left) || !isObjectOnlySchema(right)) return false; const leftRecord = left as Record<string, unknown>; const rightRecord = right as Record<string, unknown>; const leftProperties = leftRecord['properties']; const rightProperties = rightRecord['properties']; const leftRequired = leftRecord['required']; const rightRequired = rightRecord['required']; if ( !leftProperties || typeof leftProperties !== 'object' || Array.isArray(leftProperties) || !rightProperties || typeof rightProperties !== 'object' || Array.isArray(rightProperties) || !Array.isArray(leftRequired) || !Array.isArray(rightRequired) ) { return false; } for (const property of leftRequired) { if ( typeof property === 'string' && rightRequired.includes(property) && haveDisjointLiteralValues( resolveLocalRefForExclusivity((leftProperties as Record<string, unknown>)[property], root), resolveLocalRefForExclusivity((rightProperties as Record<string, unknown>)[property], root), ) ) { return true; } } return false; } function getClosedObjectPropertySet( schema: unknown, ): { properties: Set<string>; required: string[] } | undefined { if (!isObjectOnlySchema(schema)) return undefined; const record = schema as Record<string, unknown>; const properties = record['properties']; const required = record['required']; if ( record['additionalProperties'] !== false || !properties || typeof properties !== 'object' || Array.isArray(properties) || !Array.isArray(required) || required.some((property) => typeof property !== 'string') ) { return undefined; } const propertySet = new Set(Object.keys(properties)); const requiredProperties = required as string[]; // A required undeclared property makes a closed branch unsatisfiable, but // the strictifier rejects that shape rather than representing it. Keep this // exclusivity proof conservative and let the normal validation path fail. if (requiredProperties.some((property) => !propertySet.has(property))) { return undefined; } return { properties: propertySet, required: requiredProperties }; } function haveDisjointClosedObjectPropertySets(left: unknown, right: unknown): boolean { const leftShape = getClosedObjectPropertySet(left); const rightShape = getClosedObjectPropertySet(right); if (!leftShape || !rightShape) return false; // If either closed branch requires a property the other branch does not // declare, every instance satisfying the first is rejected by the second as // an additional property. This proves oneOf exclusivity without widening // overlapping closed shapes. return ( leftShape.required.some((property) => !rightShape.properties.has(property)) || rightShape.required.some((property) => !leftShape.properties.has(property)) ); } function areMutuallyExclusive(left: unknown, right: unknown, root: JSONSchema): boolean { const leftTypes = getSchemaTypes(left); const rightTypes = getSchemaTypes(right); if ( leftTypes && rightTypes && [...leftTypes].every((leftType) => [...rightTypes].every((rightType) => !schemaTypesOverlap(leftType, rightType)), ) ) { return true; } return ( haveDisjointLiteralValues(left, right) || haveDisjointObjectDiscriminator(left, right, root) || haveDisjointClosedObjectPropertySets(left, right) ); } function resolveLocalRefForExclusivity( schema: unknown, root: JSONSchema, seenRefs: Set<string> = new Set(), ): unknown | undefined { if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema; const record = schema as Record<string, unknown>; const ref = record['$ref']; if (ref !== undefined) { // Annotation keywords do not affect Draft 7 validation, so they are safe // to retain while proving the referenced branches are mutually exclusive. // Keep the proof conservative for every other sibling constraint. if (typeof ref !== 'string' || !hasOnlyRefAndAnnotations(record as JSONSchema)) { return undefined; } if (seenRefs.has(ref)) return undefined; const resolved = resolveLocalRef(root, ref); if (resolved === undefined) return undefined; return resolveLocalRefForExclusivity(resolved, root, new Set([...seenRefs, ref])); } if (record['allOf'] !== undefined) { if (!Array.isArray(record['allOf'])) return undefined; const normalized = normalizeObjectAllOfForExclusivity(record as JSONSchema, root); if (normalized === undefined) return undefined; // Flattening a singleton allOf can expose a bare local ref. Feed that // result through this same resolver so URI-fragment decoding and the // existing local-ref cycle guard still apply before exclusivity analysis. return resolveLocalRefForExclusivity(normalized, root, seenRefs); } return schema; } function areOneOfBranchesMutuallyExclusive(branches: unknown[], root: JSONSchema): boolean { for (let index = 0; index < branches.length; index++) { for (let otherIndex = index + 1; otherIndex < branches.length; otherIndex++) { const left = resolveLocalRefForExclusivity(branches[index], root); const right = resolveLocalRefForExclusivity(branches[otherIndex], root); if (left === undefined || right === undefined || !areMutuallyExclusive(left, right, root)) { return false; } } } return true; } function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { assertNoNestedSchemaIds(schema); const normalizedSchema = structuredClone(schema); const oneOfSchemas: Record<string, unknown>[] = []; const visitedSchemas = new Set<Record<string, unknown>>(); const visitSchema = (value: unknown): void => { if (!value || typeof value !== 'object' || Array.isArray(value)) return; const record = value as Record<string, unknown>; if (visitedSchemas.has(record)) return; visitedSchemas.add(record); if (record['oneOf'] !== undefined) { if (!Array.isArray(record['oneOf'])) { throw new OpenAIError( 'Standard JSON Schema generated an invalid `oneOf`, which cannot be represented in an OpenAI strict schema', ); } if (record['anyOf'] !== undefined) { throw new OpenAIError( 'Standard JSON Schema generated both `anyOf` and `oneOf`, which cannot be represented in an OpenAI strict schema', ); } // `false` can never validate, so it cannot overlap another oneOf // branch. Keep it in place until the existing anyOf normalization runs // so local refs into surviving branch indices can be rewritten before // the impossible alternatives are removed. const possibleBranches = record['oneOf'].filter((branch) => branch !== false); if (!areOneOfBranchesMutuallyExclusive(possibleBranches, normalizedSchema)) { throw new OpenAIError( 'Standard JSON Schema generated a `oneOf` whose branches are not provably mutually exclusive. OpenAI strict schemas do not support `oneOf`; use `anyOf` or add a discriminator with distinct literal values.', ); } oneOfSchemas.push(record); } forEachJSONSchemaChild(record, [], (child) => visitSchema(child)); }; visitSchema(normalizedSchema); rewriteLocalRefsIntoMovedOneOfBranches(normalizedSchema); for (const record of oneOfSchemas) { record['anyOf'] = record['oneOf']; delete record['oneOf']; } return normalizedSchema; } function parseStandardSchema<Schema extends StandardSchemaLike>( standardSchema: Schema, content: string, ): InferStandardOutput<Schema> { const result = standardSchema['~standard'].validate(JSON.parse(content)); if (isPromiseLike(result)) { void Promise.resolve(result).catch(() => undefined); throw new OpenAIError( 'Standard Schema helpers only support synchronous validation. Use a schema with a synchronous `~standard.validate()` implementation.', ); } if (result.issues) { throw new OpenAIError(`Standard Schema validation failed: ${formatStandardSchemaIssues(result.issues)}`); } return result.value as InferStandardOutput<Schema>; } function resolveStandardJSONSchema( standardSchema: StandardSchemaLike, schemaOverride?: JSONSchema | Record<string, unknown> | undefined, ): Record<string, unknown> { const schema = (schemaOverride ?? standardSchema['~standard'].jsonSchema?.input({ target: 'draft-07' })) as | JSONSchema | undefined; if (!schema) { throw new OpenAIError( 'Standard Schema helpers require a JSON Schema. Pass `schema` or use a schema that implements `~standard.jsonSchema.input()`.', ); } return toStrictJsonSchema(normalizeStructuredOutputSchema(schema)) as unknown as Record<string, unknown>; } /** * Creates a chat completion `JSONSchema` response format from a Standard * Schema validator. * * The helper uses `~standard.jsonSchema.input()` for the model-facing schema * and `~standard.validate()` for parsed output. Validation must be * synchronous because the SDK's parse helpers are synchronous. */ export function standardResponseFormat<Schema extends StandardSchemaLike>( standardSchema: Schema, name: string, props?: StandardResponseFormatProps, ): AutoParseableResponseFormat<InferStandardOutput<Schema>> { const { schema, ...formatProps } = props ?? {}; return makeParseableResponseFormat<InferStandardOutput<Schema>>( { type: 'json_schema', json_schema: { ...formatProps, name, strict: true, schema: resolveStandardJSONSchema(standardSchema, schema), }, }, (content) => parseStandardSchema(standardSchema, content), ); } /** * Creates a Responses API `json_schema` text format from a Standard Schema * validator. */ export function standardTextFormat<Schema extends StandardSchemaLike>( standardSchema: Schema, name: string, props?: StandardTextFormatProps, ): AutoParseableTextFormat<InferStandardOutput<Schema>> { const { schema, ...formatProps } = props ?? {}; return makeParseableTextFormat<InferStandardOutput<Schema>>( { type: 'json_schema', ...formatProps, name, strict: true, schema: resolveStandardJSONSchema(standardSchema, schema), }, (content) => parseStandardSchema(standardSchema, content), ); } /** * Creates a chat completion `function` tool from a Standard Schema * validator. */ export function standardFunction< Parameters extends StandardSchemaLike, Function extends StandardToolFunction<Parameters>, >( options: StandardToolOptions<Parameters> & { function: Function }, ): AutoParseableTool<StandardToolReturnOptions<Parameters, Function>>; export function standardFunction<Parameters extends StandardSchemaLike>( options: StandardToolOptions<Parameters> & { function?: undefined }, ): AutoParseableTool<StandardToolReturnOptions<Parameters, undefined>>; export function standardFunction<Parameters extends StandardSchemaLike>( options: StandardToolOptions<Parameters>, ): AutoParseableTool<StandardToolReturnOptions<Parameters, StandardToolFunction<Parameters> | undefined>>; export function standardFunction<Parameters extends StandardSchemaLike>( options: StandardToolOptions<Parameters>, ) { return makeParseableTool<any>( { type: 'function', function: { name: options.name, parameters: resolveStandardJSONSchema(options.parameters, options.schema), strict: true, ...(options.description ? { description: options.description } : undefined), }, }, { callback: options.function, parser: (args) => parseStandardSchema(options.parameters, args), }, ); } /** * Creates a Responses API `function` tool from a Standard Schema validator. */ export function standardResponsesFunction< Parameters extends StandardSchemaLike, Function extends StandardToolFunction<Parameters>, >( options: StandardToolOptions<Parameters> & { function: Function }, ): AutoParseableResponseTool<StandardToolReturnOptions<Parameters, Function>>; export function standardResponsesFunction<Parameters extends StandardSchemaLike>( options: StandardToolOptions<Parameters> & { function?: undefined }, ): AutoParseableResponseTool<StandardToolReturnOptions<Parameters, undefined>>; export function standardResponsesFunction<Parameters extends StandardSchemaLike>( options: StandardToolOptions<Parameters>, ): AutoParseableResponseTool< StandardToolReturnOptions<Parameters, StandardToolFunction<Parameters> | undefined> >; export function standardResponsesFunction<Parameters extends StandardSchemaLike>( options: StandardToolOptions<Parameters>, ) { return makeParseableResponseTool<any>( { type: 'function', name: options.name, parameters: resolveStandardJSONSchema(options.parameters, options.schema), strict: true, ...(options.description ? { description: options.description } : undefined), }, { callback: options.function, parser: (args) => parseStandardSchema(options.parameters, args), }, ); }