UNPKG

@earendil-works/pi-ai

Version:

Unified LLM API with automatic model discovery and provider configuration

253 lines • 11.2 kB
class UnsupportedStrictJsonSchemaError extends Error { } const UNSUPPORTED_STRICT_SCHEMA_KEYS = [ "$ref", "$defs", "definitions", "allOf", "oneOf", "patternProperties", "dependentSchemas", "dependencies", "unevaluatedProperties", "propertyNames", "contains", "prefixItems", "not", "if", "then", "else", ]; function isJsonSchemaObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function isStructuredSchema(schema) { if (!isJsonSchemaObject(schema)) return false; const types = typeof schema.type === "string" ? [schema.type] : Array.isArray(schema.type) ? schema.type : []; return (types.includes("object") || types.includes("array") || schema.properties !== undefined || schema.items !== undefined); } function schemaAllowsNull(schema) { if (!isJsonSchemaObject(schema)) return false; if (schema.type === "null" || (Array.isArray(schema.type) && schema.type.includes("null"))) return true; if (schema.const === null || (Array.isArray(schema.enum) && schema.enum.includes(null))) return true; return Array.isArray(schema.anyOf) && schema.anyOf.some((variant) => schemaAllowsNull(variant)); } function makeJsonSchemaNodeStrict(schema, isUnsupportedKeyword) { if (!isJsonSchemaObject(schema)) { throw new UnsupportedStrictJsonSchemaError("boolean schemas are unsupported"); } for (const key of UNSUPPORTED_STRICT_SCHEMA_KEYS) { if (schema[key] !== undefined) { throw new UnsupportedStrictJsonSchemaError(`${key} schemas are unsupported`); } } if (isUnsupportedKeyword) { for (const [key, value] of Object.entries(schema)) { if (isUnsupportedKeyword(key, value)) { throw new UnsupportedStrictJsonSchemaError(`${key}: ${JSON.stringify(value)} is unsupported`); } } } if (schema.anyOf !== undefined) { if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) { throw new UnsupportedStrictJsonSchemaError("anyOf must contain at least one schema"); } for (const variant of schema.anyOf) { if (isStructuredSchema(variant)) { throw new UnsupportedStrictJsonSchemaError("object and array unions are unsupported"); } makeJsonSchemaNodeStrict(variant, isUnsupportedKeyword); } } if (schema.items !== undefined) { if (Array.isArray(schema.items)) { throw new UnsupportedStrictJsonSchemaError("tuple schemas are unsupported"); } makeJsonSchemaNodeStrict(schema.items, isUnsupportedKeyword); } const isObjectSchema = schema.type === "object"; if (schema.properties !== undefined && !isObjectSchema) { throw new UnsupportedStrictJsonSchemaError("properties require type object"); } if (!isObjectSchema) return; if (schema.additionalProperties !== undefined && schema.additionalProperties !== false) { throw new UnsupportedStrictJsonSchemaError("schema-valued or true additionalProperties is unsupported"); } if (schema.properties !== undefined && !isJsonSchemaObject(schema.properties)) { throw new UnsupportedStrictJsonSchemaError("object properties must be a schema map"); } if (schema.required !== undefined && (!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== "string"))) { throw new UnsupportedStrictJsonSchemaError("object required must be a string array"); } const properties = schema.properties ?? {}; const propertyNames = Object.keys(properties); const required = new Set(Array.isArray(schema.required) ? schema.required : []); if ([...required].some((key) => !propertyNames.includes(key))) { throw new UnsupportedStrictJsonSchemaError("required contains an unknown property"); } for (const [key, property] of Object.entries(properties)) { makeJsonSchemaNodeStrict(property, isUnsupportedKeyword); if (!required.has(key) && !schemaAllowsNull(property)) { properties[key] = { anyOf: [property, { type: "null" }] }; } } schema.required = propertyNames; schema.additionalProperties = false; } /** Convert a tool schema to the strict subset expected by provider constrained sampling. */ export function makeStrictJsonSchema(schema, isUnsupportedKeyword) { const cloned = structuredClone(schema); if (!isJsonSchemaObject(cloned)) { throw new UnsupportedStrictJsonSchemaError("root schema must have type object"); } makeJsonSchemaNodeStrict(cloned, isUnsupportedKeyword); if (cloned.type !== "object") { throw new UnsupportedStrictJsonSchemaError("root schema must have type object"); } return cloned; } /** Returns true for a tool that asks for strict JSON-schema sampling only when possible (`strict: "prefer"`). */ export function prefersJsonSchemaStrictSampling(tool) { const config = tool.constrainedSampling; return !!config && config.type === "json_schema" && config.strict === "prefer"; } export function getJsonSchemaToolParameters(tool, strict) { return (strict === true ? makeStrictJsonSchema(tool.parameters) : tool.parameters); } export function getGrammarToolInput(toolName, arguments_, inputProperty) { const input = arguments_[inputProperty]; if (typeof input !== "string") { throw new Error(`Grammar tool call "${toolName}" requires argument "${inputProperty}" to be a string.`); } return input; } /** * Append `inputDelta` to a streaming grammar tool input and return the matching piece of its JSON arguments text, or * `undefined` for an empty delta. Costs time linear in the delta, never in the input so far. */ export function appendGrammarToolInputJsonDelta(buffer, inputProperty, inputDelta) { if (buffer.closed) { throw new Error(`grammar tool input for property "${inputProperty}" changed after it was closed`); } if (inputDelta.length === 0) return undefined; buffer.input += inputDelta; return grammarToolInputJsonStart(buffer, inputProperty) + JSON.stringify(inputDelta).slice(1, -1); } /** * Close a streaming grammar tool input and return the rest of its JSON arguments text. `finalInput` is the complete * input when the provider sends one; it must continue the input streamed so far. Closing again with the same input * returns `undefined`. */ export function closeGrammarToolInputJson(buffer, inputProperty, finalInput) { if (buffer.closed) { if (finalInput === undefined || finalInput === buffer.input) return undefined; throw new Error(`grammar tool input for property "${inputProperty}" changed after it was closed`); } if (finalInput !== undefined && !finalInput.startsWith(buffer.input)) { throw new Error(`grammar tool input for property "${inputProperty}" changed non-monotonically`); } const rest = finalInput === undefined ? "" : finalInput.slice(buffer.input.length); if (finalInput !== undefined) buffer.input = finalInput; buffer.closed = true; return `${grammarToolInputJsonStart(buffer, inputProperty)}${JSON.stringify(rest).slice(1, -1)}"}`; } /** The opening of the JSON arguments text, once. */ function grammarToolInputJsonStart(buffer, inputProperty) { if (buffer.started) return ""; buffer.started = true; return `{${JSON.stringify(inputProperty)}:"`; } function inferGrammarInputProperty(tool) { const schema = tool.parameters; if (schema.type !== "object") { throw new Error("grammar constrained sampling requires an object parameter schema"); } if (!Array.isArray(schema.required) || schema.required.length !== 1 || typeof schema.required[0] !== "string") { throw new Error("grammar constrained sampling requires exactly one required string property"); } const inputProperty = schema.required[0]; if (!schema.properties?.[inputProperty]) { throw new Error(`grammar constrained sampling requires a properties entry for ${inputProperty}`); } if (schema.properties[inputProperty]?.type !== "string") { throw new Error(`grammar constrained sampling property ${inputProperty} must have type string`); } return inputProperty; } /** * Decide whether a JSON-schema tool is sent in strict mode. `isUnsupportedKeyword` lets a provider * reject extra keywords its strict mode does not accept, so "prefer" tools fall back to non-strict. */ export function resolveJsonSchemaStrictSampling(tool, supportsStrictMode, isUnsupportedKeyword) { const config = tool.constrainedSampling; if (!config || config.type !== "json_schema") return undefined; if (supportsStrictMode) { try { makeStrictJsonSchema(tool.parameters, isUnsupportedKeyword); return true; } catch (error) { if (!(error instanceof UnsupportedStrictJsonSchemaError)) throw error; if (config.strict !== "require") return undefined; throw new Error(`Tool "${tool.name}" requires JSON-schema constrained sampling, but ${error.message}.`); } } if (config.strict === "require") { throw new Error(`Tool "${tool.name}" requires JSON-schema constrained sampling, but strict tools are unsupported.`); } return undefined; } export function resolveGrammarConstrainedSampling(tool, supportsOpenAIGrammarTools) { const config = tool.constrainedSampling; if (!config || config.type !== "grammar") { return undefined; } if (!supportsOpenAIGrammarTools) { return undefined; } const larkDefinition = config.variants.openai_lark; const regexDefinition = config.variants.openai_regex; const hasLarkDefinition = typeof larkDefinition === "string" && larkDefinition.trim().length > 0; const hasRegexDefinition = typeof regexDefinition === "string" && regexDefinition.trim().length > 0; if (!hasLarkDefinition && !hasRegexDefinition) { throw new Error(`Tool "${tool.name}" cannot use grammar constrained sampling: no supported grammar variant was provided.`); } try { return { format: hasLarkDefinition ? "lark" : "regex", definition: hasLarkDefinition ? larkDefinition : regexDefinition, inputProperty: inferGrammarInputProperty(tool), }; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Tool "${tool.name}" cannot use grammar constrained sampling: ${message}.`); } } export function createGrammarToolInputProperties(tools, supportsOpenAIGrammarTools) { const properties = new Map(); for (const tool of tools ?? []) { const grammar = resolveGrammarConstrainedSampling(tool, supportsOpenAIGrammarTools); if (grammar) { properties.set(tool.name, grammar.inputProperty); } } return properties; } //# sourceMappingURL=constrained-sampling.js.map