@earendil-works/pi-ai
Version:
Unified LLM API with automatic model discovery and provider configuration
253 lines • 11.2 kB
JavaScript
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