Added new json output support
This commit is contained in:
@@ -3,6 +3,7 @@ import {findByProp, objectMap, JSONSanitize, JSONAttemptParse} from '@ztimson/ut
|
||||
import {AbortablePromise, Ai} from './ai.ts';
|
||||
import {LLMMessage, LLMRequest} from './llm.ts';
|
||||
import {LLMProvider} from './provider.ts';
|
||||
import {convertSchema} from './tools.ts';
|
||||
|
||||
export class Anthropic extends LLMProvider {
|
||||
client!: anthropic;
|
||||
@@ -48,7 +49,7 @@ export class Anthropic extends LLMProvider {
|
||||
return history.map(({timestamp, ...h}) => h);
|
||||
}
|
||||
|
||||
ask(message: string, options: LLMRequest = {}): AbortablePromise<string> {
|
||||
ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> {
|
||||
const controller = new AbortController();
|
||||
return Object.assign(new Promise<any>(async (res) => {
|
||||
let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]);
|
||||
@@ -57,7 +58,7 @@ export class Anthropic extends LLMProvider {
|
||||
model: options.model || this.model,
|
||||
max_tokens: options.max_tokens || this.ai.options.llm?.max_tokens || 4096,
|
||||
system: options.system || this.ai.options.llm?.system || '',
|
||||
temperature: options.temperature || this.ai.options.llm?.temperature || 0.7,
|
||||
temperature: options.temperature || this.ai.options.llm?.temperature || undefined,
|
||||
tools: tools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
@@ -72,6 +73,16 @@ export class Anthropic extends LLMProvider {
|
||||
stream: !!options.stream,
|
||||
};
|
||||
|
||||
// Add structured output support
|
||||
if(options.schema) {
|
||||
requestParams.output_config = {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
schema: convertSchema(options.schema)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let resp: any, isFirstMessage = true;
|
||||
do {
|
||||
resp = await this.client.messages.create(requestParams).catch(err => {
|
||||
@@ -128,12 +139,17 @@ export class Anthropic extends LLMProvider {
|
||||
requestParams.messages = history;
|
||||
}
|
||||
} while (!controller.signal.aborted && resp.content.some((c: any) => c.type === 'tool_use'));
|
||||
history.push({role: 'assistant', content: resp.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n')});
|
||||
|
||||
const textContent = resp.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n');
|
||||
history.push({role: 'assistant', content: textContent});
|
||||
history = this.toStandard(history);
|
||||
|
||||
if(options.stream) options.stream({done: true});
|
||||
if(options.history) options.history.splice(0, options.history.length, ...history);
|
||||
res(history.at(-1)?.content);
|
||||
|
||||
// Return parsed JSON if schema provided
|
||||
const finalContent = history.at(-1)?.content;
|
||||
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
|
||||
}), {abort: () => controller.abort()});
|
||||
}
|
||||
}
|
||||
|
||||
47
src/llm.ts
47
src/llm.ts
@@ -2,14 +2,14 @@ import {AbortablePromise, Ai} from './ai.ts';
|
||||
import {Anthropic} from './antrhopic.ts';
|
||||
import {OpenAi} from './open-ai.ts';
|
||||
import {LLMProvider} from './provider.ts';
|
||||
import {AiTool} from './tools.ts';
|
||||
import {AiTool, AiToolArg} from './tools.ts';
|
||||
import {fileURLToPath} from 'url';
|
||||
import {dirname, join} from 'path';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {Memory, MemoryManager} from './memory.ts';
|
||||
|
||||
export type AnthropicConfig = {proto: 'anthropic', token: string};
|
||||
export type OllamaConfig = {proto: 'ollama', host: string};
|
||||
export type OllamaConfig = {proto: 'llama', host: string};
|
||||
export type OpenAiConfig = {proto: 'openai', host?: string, token: string};
|
||||
|
||||
export type LLMMessage = {
|
||||
@@ -37,6 +37,8 @@ export type LLMMessage = {
|
||||
}
|
||||
|
||||
export type LLMRequest = {
|
||||
/** Return a parsed JSON object that matches the schema */
|
||||
schema?: AiToolArg;
|
||||
/** System prompt */
|
||||
system?: string;
|
||||
/** Message history */
|
||||
@@ -93,7 +95,7 @@ class LLM {
|
||||
Object.entries(ai.options.llm.models).forEach(([model, config]) => {
|
||||
if(!this.defaultModel) this.defaultModel = model;
|
||||
if(config.proto == 'anthropic') this.models[model] = new Anthropic(this.ai, config.token, model);
|
||||
else if(config.proto == 'ollama') this.models[model] = new OpenAi(this.ai, config.host, 'not-needed', model);
|
||||
else if(config.proto == 'llama') this.models[model] = new OpenAi(this.ai, config.host, 'ignored', model, true);
|
||||
else if(config.proto == 'openai') this.models[model] = new OpenAi(this.ai, config.host || null, config.token, model);
|
||||
});
|
||||
this.memoryManager = new MemoryManager(this);
|
||||
@@ -160,7 +162,6 @@ class LLM {
|
||||
ask(message: string, options: LLMRequest = {}): AbortablePromise<string> {
|
||||
options = <any>{
|
||||
system: '',
|
||||
temperature: 0.8,
|
||||
...this.ai.options.llm,
|
||||
models: undefined,
|
||||
history: [],
|
||||
@@ -394,40 +395,6 @@ ${relevant[0].content}
|
||||
return {avg: similarities.reduce((acc, s) => acc + s, 0) / similarities.length, max: Math.max(...similarities), similarities};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a question with JSON response
|
||||
* @param {string} text Text to process
|
||||
* @param {string} schema JSON schema the AI should match
|
||||
* @param {LLMRequest} options Configuration options and chat history
|
||||
* @returns {Promise<{} | {} | RegExpExecArray | null>}
|
||||
*/
|
||||
async json(text: string, schema: string, options?: LLMRequest): Promise<any> {
|
||||
let system = `Your job is to convert input to JSON using tool calls. Call the \`submit\` tool at least once with JSON matching this schema:\n\`\`\`json\n${schema}\n\`\`\`\n\nResponses are ignored`;
|
||||
if(options?.system) system += '\n\n' + options.system;
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let done = false;
|
||||
const resp = await this.ask(text, {
|
||||
temperature: 0.3,
|
||||
...options,
|
||||
system,
|
||||
tools: [{
|
||||
name: 'submit',
|
||||
description: 'Submit JSON',
|
||||
args: {json: {type: 'string', description: 'Javascript parsable JSON string', required: true}},
|
||||
fn: (args) => {
|
||||
try {
|
||||
const json = JSON.parse(args.json);
|
||||
resolve(json);
|
||||
done = true;
|
||||
} catch { return 'Invalid JSON'; }
|
||||
return 'Saved';
|
||||
}
|
||||
}, ...(options?.tools || [])],
|
||||
});
|
||||
if(!done) reject(`AI failed to create JSON:\n${resp}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a summary of some text
|
||||
* @param {string} text Text to summarize
|
||||
@@ -464,7 +431,7 @@ ${relevant[0].content}
|
||||
|
||||
addModel(name: string, config: AnthropicConfig | OllamaConfig | OpenAiConfig, setDefault = false) {
|
||||
if(config.proto == 'anthropic') this.models[name] = new Anthropic(this.ai, config.token, name);
|
||||
else if(config.proto == 'ollama') this.models[name] = new OpenAi(this.ai, config.host, 'not-needed', name);
|
||||
else if(config.proto == 'llama') this.models[name] = new OpenAi(this.ai, config.host, 'not-needed', name, true);
|
||||
else if(config.proto == 'openai') this.models[name] = new OpenAi(this.ai, config.host || null, config.token, name);
|
||||
if(setDefault || !this.defaultModel) this.defaultModel = name;
|
||||
}
|
||||
@@ -481,7 +448,7 @@ ${relevant[0].content}
|
||||
Object.entries(models).forEach(([model, config]) => {
|
||||
if(!this.defaultModel) this.defaultModel = model;
|
||||
if(config.proto == 'anthropic') this.models[model] = new Anthropic(this.ai, config.token, model);
|
||||
else if(config.proto == 'ollama') this.models[model] = new OpenAi(this.ai, config.host, 'not-needed', model);
|
||||
else if(config.proto == 'llama') this.models[model] = new OpenAi(this.ai, config.host, 'not-needed', model, true);
|
||||
else if(config.proto == 'openai') this.models[model] = new OpenAi(this.ai, config.host || null, config.token, model);
|
||||
});
|
||||
this.defaultModel = Object.keys(this.models)[0] ?? '';
|
||||
|
||||
@@ -3,15 +3,16 @@ import {findByProp, objectMap, JSONSanitize, JSONAttemptParse, clean} from '@zti
|
||||
import {AbortablePromise, Ai} from './ai.ts';
|
||||
import {LLMMessage, LLMRequest} from './llm.ts';
|
||||
import {LLMProvider} from './provider.ts';
|
||||
import {convertSchema} from './tools.ts';
|
||||
|
||||
export class OpenAi extends LLMProvider {
|
||||
client!: openAI;
|
||||
|
||||
constructor(public readonly ai: Ai, public readonly host: string | null, public readonly token: string, public model: string) {
|
||||
constructor(public readonly ai: Ai, public readonly host: string | null, public readonly token: string, public model: string, public llama?: boolean) {
|
||||
super();
|
||||
this.client = new openAI(clean({
|
||||
baseURL: host,
|
||||
apiKey: token || host ? 'ignored' : undefined
|
||||
apiKey: token || (host ? 'ignored' : undefined)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ export class OpenAi extends LLMProvider {
|
||||
}, [] as any[]);
|
||||
}
|
||||
|
||||
ask(message: string, options: LLMRequest = {}): AbortablePromise<string> {
|
||||
ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> {
|
||||
const controller = new AbortController();
|
||||
return Object.assign(new Promise<any>(async (res, rej) => {
|
||||
if(options.system) {
|
||||
@@ -77,8 +78,8 @@ export class OpenAi extends LLMProvider {
|
||||
model: options.model || this.model,
|
||||
messages: history,
|
||||
stream: !!options.stream,
|
||||
max_tokens: options.max_tokens || this.ai.options.llm?.max_tokens || 4096,
|
||||
temperature: options.temperature || this.ai.options.llm?.temperature || 0.7,
|
||||
max_completion_tokens: options.max_tokens || this.ai.options.llm?.max_tokens || undefined,
|
||||
temperature: options.temperature || this.ai.options.llm?.temperature || undefined,
|
||||
tools: tools.map(t => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
@@ -93,6 +94,26 @@ export class OpenAi extends LLMProvider {
|
||||
}))
|
||||
};
|
||||
|
||||
if(options.schema) {
|
||||
const schema = convertSchema(options.schema);
|
||||
if(this.llama) {
|
||||
delete requestParams.tools;
|
||||
requestParams.response_format = {
|
||||
type: 'json_schema',
|
||||
json_schema: {name: 'json', schema}
|
||||
}
|
||||
} else {
|
||||
requestParams.response_format = {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'response',
|
||||
strict: true,
|
||||
schema
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let resp: any, isFirstMessage = true;
|
||||
do {
|
||||
resp = await this.client.chat.completions.create(requestParams).catch(err => {
|
||||
@@ -158,12 +179,17 @@ export class OpenAi extends LLMProvider {
|
||||
requestParams.messages = history;
|
||||
}
|
||||
} while (!controller.signal.aborted && resp.choices?.[0]?.message?.tool_calls?.length);
|
||||
history.push({role: 'assistant', content: resp.choices[0].message.content.trim() || ''});
|
||||
|
||||
const textContent = resp.choices[0].message.content?.trim() || '';
|
||||
history.push({role: 'assistant', content: textContent});
|
||||
history = this.toStandard(history);
|
||||
|
||||
if(options.stream) options.stream({done: true});
|
||||
if(options.history) options.history.splice(0, options.history.length, ...history);
|
||||
res(history.at(-1)?.content);
|
||||
|
||||
// Return parsed JSON if schema provided
|
||||
const finalContent = history.at(-1)?.content;
|
||||
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
|
||||
}), {abort: () => controller.abort()});
|
||||
}
|
||||
}
|
||||
|
||||
49
src/tools.ts
49
src/tools.ts
@@ -1,6 +1,6 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
import {$Sync} from '@ztimson/node-utils';
|
||||
import {ASet, consoleInterceptor, Http, fn as Fn, decodeHtml} from '@ztimson/utils';
|
||||
import {ASet, consoleInterceptor, Http, fn as Fn, decodeHtml, objectMap} from '@ztimson/utils';
|
||||
import * as os from 'node:os';
|
||||
import {Ai} from './ai.ts';
|
||||
import {LLMRequest} from './llm.ts';
|
||||
@@ -44,6 +44,53 @@ export type AiTool = {
|
||||
fn: (args: any, stream: LLMRequest['stream'], ai: Ai) => any | Promise<any>,
|
||||
};
|
||||
|
||||
export function convertSchema(schema: any): any {
|
||||
if(!schema) return null;
|
||||
|
||||
const convertProp = (prop: any): any => {
|
||||
const converted: any = {
|
||||
type: prop.type || 'string',
|
||||
};
|
||||
|
||||
if(prop.description) converted.description = prop.description;
|
||||
if(prop.default !== undefined) converted.default = prop.default;
|
||||
if(prop.enum) converted.enum = prop.enum;
|
||||
if(prop.pattern) converted.pattern = prop.pattern;
|
||||
|
||||
// Handle array items
|
||||
if(prop.type === 'array' && prop.items) {
|
||||
converted.items = convertProp(prop.items);
|
||||
}
|
||||
|
||||
// Handle object properties
|
||||
if(prop.type === 'object' && prop.items) {
|
||||
converted.properties = objectMap(prop.items, (key, value) => convertProp(value));
|
||||
const required = Object.entries(prop.items).filter(([_, v]: any) => v.required).map(([k]) => k);
|
||||
if(required.length) converted.required = required;
|
||||
converted.additionalProperties = false;
|
||||
}
|
||||
|
||||
// Handle min/max based on type
|
||||
if(prop.min !== undefined) {
|
||||
if(prop.type === 'string' || prop.type === 'array') converted.minLength = prop.min;
|
||||
else converted.minimum = prop.min;
|
||||
}
|
||||
if(prop.max !== undefined) {
|
||||
if(prop.type === 'string' || prop.type === 'array') converted.maxLength = prop.max;
|
||||
else converted.maximum = prop.max;
|
||||
}
|
||||
|
||||
return converted;
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties: objectMap(schema, (key, value) => convertProp(value)),
|
||||
required: Object.entries(schema).filter(([_, v]: any) => v.required).map(([k]) => k),
|
||||
additionalProperties: false
|
||||
};
|
||||
}
|
||||
|
||||
export const CliTool: AiTool = {
|
||||
name: 'cli',
|
||||
description: 'Use the command line interface, returns any output',
|
||||
|
||||
Reference in New Issue
Block a user