@creativebuilds/json-generator
Version:
Generate structured JSON objects from text input using OpenRouter
298 lines (271 loc) • 10.2 kB
JavaScript
/**
* A class for generating structured JSON objects from text input using AI
*/
export class JsonGenerator {
/**
* @param {Object} config Configuration object
* @param {string} config.apiKey OpenRouter API key
* @param {string} [config.model='mistralai/mistral-nemo'] Model to use for generation
* @param {string} [config.referer] Optional HTTP referer for OpenRouter analytics
* @param {string} [config.appName] Optional app name for OpenRouter analytics
* @param {number} [config.maxRetries=3] Maximum number of retries for generation
* @param {string} [config.delimiter='###'] Delimiter for key wrapping
*/
constructor({
apiKey,
model = 'mistralai/mistral-nemo',
referer = undefined,
appName = undefined,
maxRetries = 3,
delimiter = '###'
}) {
if (!apiKey) throw new Error('OpenRouter API key is required');
this.apiKey = apiKey;
this.model = model;
this.referer = referer;
this.appName = appName;
this.maxRetries = maxRetries;
this.delimiter = delimiter;
}
/**
* Wraps keys with delimiters and values with angle brackets
* @private
*/
_wrapFormat(format, level = 1) {
if (typeof format === 'object' && format !== null) {
if (Array.isArray(format)) {
return format.map(item => this._wrapFormat(item, level + 1));
}
const wrapped = {};
for (const [key, value] of Object.entries(format)) {
const wrappedKey = `${this.delimiter.repeat(level)}${key}${this.delimiter.repeat(level)}`;
wrapped[wrappedKey] = this._wrapFormat(value, level + 1);
}
return wrapped;
}
return `<${format}>`;
}
/**
* Validates and converts types according to schema
* @private
*/
_validateType(value, type, key) {
if (!type.startsWith('type:')) return value;
const typeStr = type.split('type:')[1].trim();
// Basic type validations
switch (typeStr.toLowerCase()) {
case 'string':
case 'str':
return String(value);
case 'number':
case 'float':
const num = parseFloat(value);
if (isNaN(num)) throw new Error(`Field "${key}" must be a number`);
return num;
case 'integer':
case 'int':
const int = parseInt(value);
if (isNaN(int)) throw new Error(`Field "${key}" must be an integer`);
return int;
case 'boolean':
case 'bool':
if (typeof value === 'string') {
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
}
if (typeof value === 'boolean') return value;
throw new Error(`Field "${key}" must be a boolean`);
case 'array':
case 'list':
if (!Array.isArray(value)) {
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) throw new Error();
return parsed;
} catch {
throw new Error(`Field "${key}" must be an array`);
}
}
return value;
}
// Handle enums if type is like "type:enum[value1,value2]"
if (typeStr.startsWith('enum[')) {
const enumValues = typeStr
.slice(5, -1)
.split(',')
.map(v => v.trim());
if (!enumValues.includes(value)) {
throw new Error(`Field "${key}" must be one of: ${enumValues.join(', ')}`);
}
return value;
}
return value;
}
/**
* Cleans up JSON string by removing markdown formatting and code blocks
* @private
*/
_cleanJsonString(str) {
// Remove markdown code blocks
str = str.replace(/```(?:json)?\n?/g, '');
// Remove any trailing backticks
str = str.replace(/`+$/, '');
// Trim whitespace
str = str.trim();
return str;
}
/**
* Formats a value for prompt generation
* @private
*/
_formatValue(value) {
if (typeof value === 'string') return `"${value}"`;
if (Array.isArray(value)) return `[${value.map(v => this._formatValue(v)).join(', ')}]`;
return String(value);
}
/**
* Creates a structured prompt from the schema
* @private
*/
_createStructuredPrompt(userPrompt, schema, example = {
string: "example_text",
integer: 42,
float: 42.5,
boolean: true,
array: ["item1", "item2"]
}) {
const formatField = (key, type, path = '') => {
const currentPath = path ? `${path}.${key}` : key;
if (typeof type === 'string' && type.startsWith('type:')) {
const baseType = type.split('type:')[1].trim();
if (baseType.startsWith('enum[')) {
const options = baseType.slice(5, -1).split(',').map(v => v.trim());
return `${currentPath}=${this._formatValue(options[0])}`;
}
return `${currentPath}=${this._formatValue(example[baseType.toLowerCase()] || example.string)}`;
}
if (typeof type === 'object' && !Array.isArray(type)) {
return Object.entries(type)
.map(([k, v]) => formatField(k, v, currentPath))
.join(', ');
}
return `${currentPath}=${this._formatValue(example.array)}`;
};
const fieldExamples = Object.entries(schema)
.map(([key, type]) => formatField(key, type))
.join(', ');
return `${userPrompt}\nReturn a raw JSON object (no markdown, no code blocks) with exactly these fields and values as a template: ${fieldExamples}`;
}
/**
* Generate a JSON object from text input with schema validation
* @param {Object} params Generation parameters
* @param {string} params.prompt Text prompt to generate JSON from
* @param {Object} params.schema JSON schema to validate against
* @param {string} [params.systemPrompt] Optional system prompt override
* @param {Function[]} [params.customValidators] Optional array of custom validation functions
* @returns {Promise<Object>} Generated JSON object
*/
async generate({ prompt, schema = {}, systemPrompt = undefined, customValidators = [] }) {
if (Object.keys(schema).length === 0) {
// If no schema provided, attempt to infer it from the prompt
console.warn('No schema provided. Attempting to generate JSON without schema validation.');
const response = await this._makeRequest(prompt, systemPrompt || 'Generate a JSON object based on the prompt. Return only the raw JSON.');
return JSON.parse(response);
}
const wrappedSchema = this._wrapFormat(schema);
const structuredPrompt = this._createStructuredPrompt(prompt, schema);
const baseSystemPrompt = `You are a JSON generator. Generate a raw JSON object (no markdown, no code blocks) that conforms to this schema: ${JSON.stringify(schema)}.
Important:
- Return ONLY the raw JSON object, no markdown formatting or code blocks
- Ensure all fields are present and match their types exactly
- Do not include any explanation or additional text
- Use the field names and types exactly as specified`;
const finalSystemPrompt = systemPrompt || baseSystemPrompt;
let lastError = null;
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
try {
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
...(this.referer && { 'HTTP-Referer': this.referer }),
...(this.appName && { 'X-Title': this.appName })
},
body: JSON.stringify({
model: this.model,
messages: [
{
role: 'system',
content: finalSystemPrompt + (lastError ? `\nPrevious error: ${lastError}` : '')
},
{ role: 'user', content: structuredPrompt }
],
response_format: { type: 'json_object' },
provider: {
require_parameters: true
}
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(`OpenRouter API error: ${error.message || response.statusText}`);
}
const data = await response.json();
const cleanedJson = this._cleanJsonString(data.choices[0].message.content);
let result = JSON.parse(cleanedJson);
// Validate types according to schema
result = this._validateSchema(result, schema);
// Run custom validators
for (const validator of customValidators) {
const { valid, error } = await validator(result);
if (!valid) throw new Error(error);
}
return result;
} catch (error) {
lastError = error.message;
console.error(`Attempt ${attempt + 1} failed:`, error.message);
if (attempt === this.maxRetries - 1) throw error;
}
}
}
/**
* Recursively validates and converts types according to schema
* @private
*/
_validateSchema(data, schema) {
// Handle primitive type definitions
if (typeof schema === 'string' && schema.startsWith('type:')) {
return this._validateType(data, schema, 'field');
}
// Handle objects
if (typeof schema === 'object' && schema !== null) {
if (Array.isArray(schema)) {
if (!Array.isArray(data)) {
throw new Error('Expected an array');
}
return data.map((item, i) => this._validateSchema(item, schema[0]));
}
if (typeof data !== 'object' || data === null) {
throw new Error('Expected an object');
}
const validated = {};
for (const [key, schemaValue] of Object.entries(schema)) {
if (!(key in data)) {
throw new Error(`Missing required field: ${key}`);
}
validated[key] = this._validateSchema(data[key], schemaValue);
}
return validated;
}
return data;
}
/**
* Generate multiple JSON objects in parallel with schema validation
* @param {Object[]} prompts Array of generation parameters
* @returns {Promise<Object[]>} Array of generated JSON objects
*/
async generateBatch(prompts) {
return Promise.all(prompts.map(p => this.generate(p)));
}
}