commit d47acf5481dfb0dff9d87425f1acb8b0cdd56455 Author: seb Date: Mon Sep 21 11:26:37 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a2a435 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +examples/.env +examples/invoice.pdf \ No newline at end of file diff --git a/examples/pdf-pages.js b/examples/pdf-pages.js new file mode 100644 index 0000000..8825e1a --- /dev/null +++ b/examples/pdf-pages.js @@ -0,0 +1,500 @@ +'use strict'; + +/** + * Example: PDF → per-page LLM processing. + * + * Demonstrates reading a PDF with `pdftotext -layout`, splitting the output + * into pages on the form-feed character (\f), and feeding each page through + * LLMOrbiter in a foreach loop with a simple prompt. + * + * Usage: + * OPENROUTER_API_KEY=sk-or-... node examples/pdf-pages.js + * node examples/pdf-pages.js --key sk-or-... --model + */ + +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; + +import { LLMOrbiter, OpenRouterProvider, defineTool, DirectReturnError } from '../src/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +// ─── Prompt logfile ───────────────────────────────────────────────────────────── +// Appends the full prompt of every LLM call so we can debug prompt composition. +const logFile = path.resolve(__dirname, 'prompts.log'); +function logPrompt(prompt) { + fs.appendFileSync(logFile, `\n${'='.repeat(80)}\n${new Date().toISOString()}\n${'='.repeat(80)}\n${prompt}\n`); +} + +function logEntry(label, data) { + fs.appendFileSync(logFile, `\n--- ${label} @ ${new Date().toISOString()} ---\n${typeof data === 'string' ? data : JSON.stringify(data, null, 2)}\n`); +} + +// ─── CLI args ─────────────────────────────────────────────────────────────────── +const args = process.argv.slice(2); +function getArg(flag) { + const idx = args.indexOf(flag); + return idx !== -1 && args[idx + 1] ? args[idx + 1] : null; +} + +const apiKey = getArg('--key') || process.env.OPENROUTER_API_KEY; +//const model = getArg('--model') || '~deepseek/deepseek-flash-latest'; +//const model = getArg('--model') || 'moonshotai/kimi-k3'; +//const model = getArg('--model') || '~openai/gpt-terra-latest:floor'; +//const model = getArg('--model') || '~openai/gpt-sol-latest:floor'; +//const model = getArg('--model') || '~anthropic/claude-sonnet-latest'; +//const model = getArg('--model') || 'google/gemini-3.8-flash'; +const model = getArg('--model') || '~openai/gpt-luna-latest:floor'; + +const pdfPath = getArg('--pdf') || path.resolve(__dirname, 'invoice.pdf'); + +if (!apiKey) { + console.error('Error: Missing API key. Set OPENROUTER_API_KEY in examples/.env or pass --key '); + process.exit(1); +} + +// ─── Accumulated manifest state ───────────────────────────────────────────────── +// Split into two parts that mirror the two tools, so the prompt sections match +// the tool names and don't confuse the LLM. +const manifestBasedata = { + date: null, + kLieferant: null, + lieferantName: null, + manifestNr: null, + manifestType: null, +}; +const manifestNotes = []; +const manifestItems = { + items: [], + pagesFrom: null, + pagesTo: null, +}; + +// Current page being processed (set in the loop before each run). +let currentPage = 0; + +// ─── Tools ────────────────────────────────────────────────────────────────────── +// A single tool with three strictly optional components. Only include the +// components that have new data for this batch; omit the rest. +const outputManifestTool = defineTool({ + name: 'output_manifest', + description: 'Stores extracted invoice/delivery-note data: header metadata (basedata), notes, and product line items. Each component is strictly optional — only include the ones that carry new data.', + strict: false, + parameters: { + type: 'object', + additionalProperties: false, + required: [], + properties: { + basedata: { + anyOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + date: { + type: ['string', 'null'], + description: 'Document date (ISO 8601)', + }, + kLieferant: { + type: ['number', 'null'], + description: 'Lieferant ID', + }, + lieferantName: { + type: 'string', + description: 'Lieferant name', + }, + manifestNr: { + type: ['string', 'null'], + description: 'Lieferscheinnummer or Rechnungsnummer', + }, + manifestType: { + anyOf: [ + { type: 'string', enum: ['rechnung', 'lieferschein'] }, + { type: 'null' }, + ], + description: 'Document type', + }, + }, + }, + { type: 'null' }, + ], + description: 'Header/metadata. Only include fields that are currently missing or changed.', + }, + notes: { + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'null' }, + ], + description: 'New noteworthy details (in German), for a human to double-check the result. Only include notes not already present.', + }, + items: { + anyOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + numberOfItemsToAdd: { + type: 'number', + description: 'Number of NEW line items being added in this call (not the running total). Helps you keep track of how many items you are adding.', + }, + items: { + type: 'array', + description: 'Invoice/delivery product line items', + items: { + type: 'object', + additionalProperties: false, + required: ['position', 'count', 'vpe', 'unit', 'articleName', 'liefArtNr', 'mwst', 'eknetto'], + properties: { + position: { type: 'number', description: 'Position' }, + count: { type: 'number', description: 'Quantity (Menge)' }, + vpe: { + type: 'number', + description: 'Packaging unit size (VPE). Default: 1. (count * vpe = total individual sellable items). A bulk pack like VE100 means vpe = 100.', + }, + unit: { type: 'string', description: 'Unit of the individual sellable item (e.g. Stk., Beutel, Flasche, Box, Set). NEVER "VE" or "VPE".' }, + articleName: { type: 'string', description: 'Article name' }, + liefArtNr: { type: 'string', description: 'Supplier article number (Art. Nr.)' }, + mwst: { + anyOf: [ + { type: 'string', enum: ['7', '19'] }, + { type: 'null' }, + ], + description: 'VAT rate (no % sign); null if missing', + }, + eknetto: { + type: ['number', 'null'], + description: 'Net price per item or per vpe in euro cents; null if missing; DO NOT USE THE TOTAL, USE THE PER PIECE PRICE', + }, + }, + }, + }, + }, + }, + { type: 'null' }, + ], + description: 'New product line items. Only include items not already present.', + }, + }, + }, + invoke: async (args) => { + // 1. basedata — merge only the fields the LLM actually filled in. + if (args.basedata && typeof args.basedata === 'object') { + for (const key of ['date', 'kLieferant', 'lieferantName', 'manifestNr', 'manifestType']) { + if (args.basedata[key] != null) manifestBasedata[key] = args.basedata[key]; + } + } + + // 2. notes — accept an array of strings; dedupe so re-sent notes don't accumulate. + if (Array.isArray(args.notes)) { + for (const n of args.notes) { + if (n && !manifestNotes.includes(n)) manifestNotes.push(n); + } + } + + // 3. items — add only NEW items (dedupe by position). + if (args.items && typeof args.items === 'object' && Array.isArray(args.items.items)) { + const existing = new Set(manifestItems.items.map((it) => it.position)); + for (const it of args.items.items) { + if (!existing.has(it.position)) { + manifestItems.items.push({ ...it, page: currentPage }); + existing.add(it.position); + } + } + // numberOfItemsToAdd is intentionally ignored — it only helps the LLM + // keep track of how many items it is adding in this call. + // Track the page range the current items come from. + const pages = manifestItems.items.map((it) => it.page).filter((p) => p != null); + if (pages.length) { + manifestItems.pagesFrom = Math.min(...pages); + manifestItems.pagesTo = Math.max(...pages); + } + } + + // Stop the LLM loop and return the accumulated data to the caller. + throw new DirectReturnError({ + basedata: manifestBasedata, + notes: manifestNotes, + items: manifestItems, + }); + }, +}); + +// ─── Base extraction prompt ───────────────────────────────────────────────────── +const BASE_PROMPT = `Extract metadata and product line items from the provided invoice or delivery note. +Call the output_manifest tool with the extracted data. Do not answer with plain text once extraction is complete. + +Rules: +- manifestType: "rechnung" or "lieferschein". For "lieferschein", prices and taxes must be null. +- Exclude non-product lines (shipping, handling, etc.). +- Line items: Extract count, vpe (default: 1), liefArtNr, mwst (7 or 19), and eknetto per piece(euro cents). +- Match supplier against the provided list for kLieferant and lieferantName. For unknown suppliers, extract lieferantName and set kLieferant to null. +- Write notes strictly in German. +- The output_manifest tool has three strictly optional components: basedata, notes, items. Only include a component when it carries genuinely new data for this batch; omit the rest. +- Include basedata ONLY when at least one of its fields is currently null/empty and you can fill it in, or when a value actually changes. If all basedata fields are already set and unchanged, omit basedata. +- Include items ONLY when this batch contains new product line items not already present. If there are no new items, omit items. +- Include notes ONLY when there is a genuinely new noteworthy detail not already in the notes list. These notes are for a human to double-check the extraction result, so record anything ambiguous, unusual, or worth verifying (e.g. packaging interpretations, excluded lines, unavailable articles). + +VPE (Verpackungseinheit) Logic: +VPE represents the number of individually sellable units inside a bulk item. If not explicitly split for individual sale, default to 1. +- "20 Boxen á 10 Stück" -> VPE = 20. +- "10 Gläser á 100 Filter" -> VPE = 10 (we sell the 10 glasses individually). +- "100 Stück" -> VPE = 1 (sold as one bulk pack). +- We don't sell the individual filters or Pieces, but typical VPEs like Beutel, Glässer, Boxen, Hefte +- That menas that in "Y Hefte á X Blatt" the VPE is NOT X but: Y +- a so called "Display" also means VPE. So "20er Display" means VPE = 20 +- eknetto is the price of one full vpe + +Unit (ME) rules: +- The unit (ME) must ALWAYS refer to the individual sellable item (e.g. Stk., Beutel, Flasche, Box, Set). It must NEVER be "VE" or "VPE". +- A packaging size like "VE100" means the bulk pack contains 100 individual items: set vpe = 100 and unit = the individual item (e.g. Stk.). +- Example: "Einhängesiebe Stahl, VE100" -> vpe = 100, unit = "Stk.", count = 1 (one bulk pack of 100 sieves).`; + +// ─── Read PDF and split into pages ────────────────────────────────────────────── +function readPdfPages(file) { + // pdftotext -layout preserves column layout; pages are separated by \f (form feed) + const raw = execFileSync('pdftotext', ['-layout', file, '-'], { encoding: 'utf8' }); + // Split on form feed, drop any leading/trailing empty chunks + return raw + .split('\f') + .map((p) => p.trim()) + .filter((p) => p.length > 0); +} + +// ─── Write output table ───────────────────────────────────────────────────────── +// Meta base data at the top, then a table of the items. +function writeOutputTable() { + const lines = []; + + lines.push('=== Manifest Base Data ==='); + lines.push(`Date: ${manifestBasedata.date ?? ''}`); + lines.push(`Lieferant ID: ${manifestBasedata.kLieferant ?? ''}`); + lines.push(`Lieferant Name: ${manifestBasedata.lieferantName ?? ''}`); + lines.push(`Manifest Nr: ${manifestBasedata.manifestNr ?? ''}`); + lines.push(`Manifest Type: ${manifestBasedata.manifestType ?? ''}`); + lines.push(`Number of Items: ${manifestItems.items.length}`); + if (manifestNotes.length) { + lines.push('Notes:'); + for (const n of manifestNotes) lines.push(` - ${n}`); + } + + lines.push(''); + lines.push('=== Items ==='); + const headers = ['Pos', 'Art. Nr.', 'Article Name', 'Count', 'VPE', 'Unit', 'MwSt', 'EK Netto (ct)']; + const rows = manifestItems.items.map((it) => [ + it.position, + it.liefArtNr, + it.articleName, + it.count, + it.vpe, + it.unit, + it.mwst, + it.eknetto, + ]); + + // Column widths + const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? '').length))); + const fmtRow = (cells) => cells.map((c, i) => String(c ?? '').padEnd(widths[i])).join(' '); + + lines.push(fmtRow(headers)); + lines.push(widths.map((w) => '-'.repeat(w)).join(' ')); + for (const r of rows) lines.push(fmtRow(r)); + + const outFile = path.resolve(__dirname, 'outputTable.txt'); + fs.writeFileSync(outFile, lines.join('\n') + '\n'); + console.log(`\n📄 Wrote ${outFile}`); +} + +// ─── Write verification JSON ──────────────────────────────────────────────────── +// Manifest Nr, Manifest Type, and items (without article name / date) for +// verification. If the file already exists, report whether it is identical. +function writeVerificationJson() { + const payload = { + manifestNr: manifestBasedata.manifestNr, + manifestType: manifestBasedata.manifestType, + items: manifestItems.items.map((it) => ({ + position: it.position, + liefArtNr: it.liefArtNr, + count: it.count, + vpe: it.vpe, + mwst: it.mwst, + eknetto: it.eknetto, + })), + }; + + const outFile = path.resolve(__dirname, 'outputVerification.json'); + const json = JSON.stringify(payload, null, 2) + '\n'; + + if (fs.existsSync(outFile)) { + const existing = fs.readFileSync(outFile, 'utf8'); + if (existing === json) { + console.log(`\n✅ ${outFile} already exists and is identical.`); + } else { + console.log(`\n⚠️ ${outFile} already exists but DIFFERS from the new result.`); + printJsonDiff(existing, json); + } + } else { + fs.writeFileSync(outFile, json); + console.log(`\n📄 Wrote ${outFile}`); + } +} + +// ─── Print a structured diff between two verification JSONs ───────────────────── +// Parses both and compares item-by-item (keyed by position), showing the item's +// article number and exactly which fields differ. +function printJsonDiff(oldStr, newStr) { + let oldObj, newObj; + try { + oldObj = JSON.parse(oldStr); + newObj = JSON.parse(newStr); + } catch { + // Fall back to a plain line diff if JSON parsing fails. + const oldLines = oldStr.split('\n'); + const newLines = newStr.split('\n'); + const max = Math.max(oldLines.length, newLines.length); + console.log(`\n${'─'.repeat(60)}`); + for (let i = 0; i < max; i++) { + const a = oldLines[i]; + const b = newLines[i]; + if (a === b) continue; + if (a === undefined) console.log(` ${' '.repeat(3)}${b}`); + else if (b === undefined) console.log(`- ${a}`); + else { console.log(`- ${a}`); console.log(`+ ${b}`); } + } + console.log(`${'─'.repeat(60)}`); + return; + } + + console.log(`\n${'─'.repeat(60)}`); + + // Top-level scalar fields + for (const key of ['manifestNr', 'manifestType']) { + if (oldObj[key] !== newObj[key]) { + console.log(`${key}: ${JSON.stringify(oldObj[key])} -> ${JSON.stringify(newObj[key])}`); + } + } + + // Items, keyed by position + const oldItems = new Map((oldObj.items || []).map((it) => [it.position, it])); + const newItems = new Map((newObj.items || []).map((it) => [it.position, it])); + + const allPositions = new Set([...oldItems.keys(), ...newItems.keys()]); + for (const pos of [...allPositions].sort((a, b) => a - b)) { + const o = oldItems.get(pos); + const n = newItems.get(pos); + if (!o) { + console.log(`+ item ${pos} (${n.liefArtNr}) ADDED`); + continue; + } + if (!n) { + console.log(`- item ${pos} (${o.liefArtNr}) REMOVED`); + continue; + } + const diffs = []; + for (const key of ['liefArtNr', 'count', 'vpe', 'mwst', 'eknetto']) { + if (o[key] !== n[key]) diffs.push(`${key}: ${JSON.stringify(o[key])} -> ${JSON.stringify(n[key])}`); + } + if (diffs.length) { + console.log(`item ${pos} (${n.liefArtNr}):`); + for (const d of diffs) console.log(` ${d}`); + } + } + + console.log(`${'─'.repeat(60)}`); +} + +// ─── Main ─────────────────────────────────────────────────────────────────────── +async function main() { + console.log(`Reading PDF: ${pdfPath}`); + const pages = readPdfPages(pdfPath); + console.log(`Found ${pages.length} page(s).\n`); + + const provider = new OpenRouterProvider({ apiKey }); + let llmCallStart = null; + const orbiter = new LLMOrbiter(provider, { + onLLMCall: (call) => { + llmCallStart = Date.now(); + console.log(`🤖 LLM call: model="${call.model}" promptSize=${call.promptSize} messages=${call.messages}`); + }, + onStatus: (status) => console.log(`⚡ ${status}`), + onToolCall: (call) => { + console.log(`🔧 Tool called: ${call.name}(${JSON.stringify(call.args)})`); + logEntry(`TOOL CALL ${call.name}`, call.args); + }, + onToolResult: (res) => logEntry(`TOOL RESULT ${res.name}`, res.result), + onToolError: (err) => logEntry(`TOOL ERROR ${err.name}`, err.error), + onUsage: (usage) => { + const elapsed = llmCallStart != null ? ((Date.now() - llmCallStart) / 1000).toFixed(3) : '?'; + console.log(`📊 usage: in=${usage.input} cached=${usage.cached} out=${usage.output} cost=${usage.cost} (${elapsed}s)`); + }, + }); + + // Feed pages through LLMOrbiter. If there are fewer than 5 pages, process all + // of them in a single run; otherwise batch them 3 pages per call. + const batchSize = pages.length;//< 5 ? pages.length : 3; + const numBatches = Math.ceil(pages.length / batchSize); + + for (let b = 0; b < numBatches; b++) { + const start = b * batchSize; + const end = Math.min(start + batchSize, pages.length); + const batchPages = pages.slice(start, end); + const firstPage = start + 1; + const lastPage = end; + + currentPage = firstPage; + console.log(`\n▸ Processing pages ${firstPage}-${lastPage}/${pages.length}...`); + + const itemPages = manifestItems.pagesFrom != null + ? `page ${manifestItems.pagesFrom} to ${manifestItems.pagesTo}` + : 'no items yet'; + + const pageContent = batchPages + .map((p, idx) => `--- Page ${firstPage + idx} of ${pages.length} ---\n${p}`) + .join('\n\n'); + + const prompt = `${BASE_PROMPT} + +You are currently processing pages ${firstPage} to ${lastPage} of ${pages.length}. + +Current manifest_basedata (fill in what is still missing): +${JSON.stringify(manifestBasedata, null, 2)} + +Current notes (add only NEW notes not already present): +${JSON.stringify(manifestNotes, null, 2)} + +Current manifest_items (add only NEW items not already present; current items are from ${itemPages}): +${JSON.stringify(manifestItems, null, 2)} + +Page content (pages ${firstPage} to ${lastPage} of ${pages.length}): +${pageContent}`; + + logPrompt(prompt); + + const result = await orbiter.run({ + prompt, + model, + tools: [outputManifestTool], + }); + logEntry(`LLM OUTPUT pages ${firstPage}-${lastPage}`, result.content); + + // The tool throws DirectReturnError to stop the loop and hand back the + // accumulated data. If that happened, we're done — no more batches. + if (result.directReturn) { + console.log(`\n⏹️ Direct return after pages ${firstPage}-${lastPage}.`); + break; + } + } + + console.log('\n✅ Done.'); + + writeOutputTable(); + writeVerificationJson(); +} + +main().catch((err) => { + console.error('FATAL:', err); + process.exit(1); +}); \ No newline at end of file diff --git a/examples/tool-error-retry.js b/examples/tool-error-retry.js new file mode 100644 index 0000000..0742c06 --- /dev/null +++ b/examples/tool-error-retry.js @@ -0,0 +1,141 @@ +'use strict'; + +/** + * Example & Test: Tool error handling and LLM self-correction + * + * Demonstrates: + * 1. A tool throwing an Error (e.g. invalid parameter format). + * 2. LLMOrbiter catching the error, emitting a 'tool_error' event, and + * formatting the error back to the LLM in the tool message. + * 3. The LLM inspecting the error feedback and retrying the tool call with corrected arguments. + * 4. Successful run completion. + * + * Usage: + * node examples/tool-error-retry.js + * node examples/tool-error-retry.js --model deepseek/deepseek-v4-flash-0731 + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; + +import { LLMOrbiter, OpenRouterProvider, defineTool, ToolError } from '../src/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +const args = process.argv.slice(2); +function getArg(flag) { + const idx = args.indexOf(flag); + return idx !== -1 && args[idx + 1] ? args[idx + 1] : null; +} + +const apiKey = getArg('--key') || process.env.OPENROUTER_API_KEY; +const model = getArg('--model') || 'deepseek/deepseek-v4-flash-0731'; + +if (!apiKey) { + console.error('Error: Missing API key. Set OPENROUTER_API_KEY in examples/.env or pass --key '); + process.exit(1); +} + +// ─── Tool with error throwing ─────────────────────────────────────────────────── + +let callCount = 0; + +const orderLookupTool = defineTool({ + name: 'lookup_order', + description: 'Look up an order by order ID.', + parameters: { + type: 'object', + required: ['orderId'], + additionalProperties: false, + properties: { + orderId: { + type: 'string', + description: 'The order ID to check', + }, + }, + }, + invoke: async ({ orderId }) => { + callCount++; + // Enforce the requirement that order IDs must start with "ORD-" + if (!orderId.startsWith('ORD-')) { + throw new ToolError( + `Invalid order ID "${orderId}". Order IDs must start with the prefix "ORD-", e.g. "ORD-${orderId}". Please correct the ID and retry.` + ); + } + + return { + orderId, + status: 'Delivered', + item: 'Mechanical Keyboard', + trackingNumber: 'TRK-98765432', + }; + }, +}); + +// ─── Main ─────────────────────────────────────────────────────────────────────── + +async function main() { + console.log('🧪 Starting Tool Error & Retry Test...\n'); + + const provider = new OpenRouterProvider({ apiKey }); + const events = []; + + const orbiter = new LLMOrbiter(provider, { + onStatus: (status) => { + events.push({ type: 'status', data: status }); + console.log(`⚡ ${status}`); + }, + onToolCall: (call) => { + events.push({ type: 'tool_call', data: call }); + console.log(`🔧 Tool call: ${call.name}(${JSON.stringify(call.args)})`); + }, + onToolError: (err) => { + events.push({ type: 'tool_error', data: err }); + console.log(`❌ Tool error:`, err.error); + }, + onToolResult: (res) => { + events.push({ type: 'tool_result', data: res }); + console.log(`📦 Tool result:`, res.result); + }, + onUsage: (usage) => { + events.push({ type: 'usage', data: usage }); + console.log(`📊 LLM call usage:`, usage); + }, + }); + + const result = await orbiter.run({ + prompt: 'Please check the status of order 54321. If the tool reports an error, fix the ID according to the error message and call the tool again.', + model, + tools: [orderLookupTool], + }); + + console.log('\n💬 Final Response:\n', result.content); + + // ─── Test Assertions ───────────────────────────────────────────────────────── + console.log('\n─── Test Results ───'); + + const errorEvent = events.find((e) => e.type === 'tool_error'); + const resultEvent = events.find((e) => e.type === 'tool_result'); + const toolCalls = events.filter((e) => e.type === 'tool_call'); + + const hasError = !!errorEvent; + const hasRetry = toolCalls.length >= 2; + const hasResult = !!resultEvent; + const hasContent = !!result.content; + + console.log(hasError ? '✅ PASS: Tool error was thrown and caught' : '❌ FAIL: Tool error was not emitted'); + console.log(hasRetry ? '✅ PASS: LLM called the tool again with correction' : '❌ FAIL: Tool was not called again'); + console.log(hasResult ? '✅ PASS: Corrected tool call succeeded' : '❌ FAIL: Tool never succeeded'); + console.log(hasContent ? '✅ PASS: LLM generated final answer from result' : '❌ FAIL: No final response'); + + if (hasError && hasRetry && hasResult && hasContent) { + console.log('\n🎉 ALL TESTS PASSED: Tool error and retry workflow verified successfully!\n'); + } else { + console.error('\n⚠️ Some assertions failed.\n'); + process.exitCode = 1; + } +} + +main().catch(console.error); diff --git a/examples/tools/weather.js b/examples/tools/weather.js new file mode 100644 index 0000000..23b467d --- /dev/null +++ b/examples/tools/weather.js @@ -0,0 +1,56 @@ +'use strict'; + +import { defineTool } from '../../src/index.js'; + +/** + * Weather tool — returns static weather data for demo/testing. + */ +const weatherTool = defineTool({ + name: 'get_weather', + description: 'Get the current weather for a given city.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['city'], + properties: { + city: { + type: 'string', + description: 'City name, e.g. "Berlin"', + }, + unit: { + type: 'string', + enum: ['celsius', 'fahrenheit'], + description: 'Temperature unit (default: celsius)', + }, + }, + }, + invoke: async ({ city, unit = 'celsius' }) => { + const db = { + berlin: { temp: 18, condition: 'partly cloudy', humidity: 62 }, + munich: { temp: 22, condition: 'sunny', humidity: 45 }, + hamburg: { temp: 15, condition: 'rainy', humidity: 80 }, + london: { temp: 14, condition: 'overcast', humidity: 75 }, + paris: { temp: 20, condition: 'sunny', humidity: 50 }, + tokyo: { temp: 28, condition: 'humid', humidity: 85 }, + 'new york': { temp: 25, condition: 'clear', humidity: 55 }, + }; + + const entry = db[city.toLowerCase()]; + if (!entry) { + const available = Object.keys(db).map((c) => `"${c}"`).join(', '); + throw new Error(`City "${city}" not found in database. Available cities are: ${available}.`); + } + let temp = entry.temp; + if (unit === 'fahrenheit') temp = Math.round(temp * 9 / 5 + 32); + + return { + city, + temperature: temp, + unit, + condition: entry.condition, + humidity: entry.humidity, + }; + }, +}); + +export { weatherTool }; diff --git a/examples/weather.js b/examples/weather.js new file mode 100644 index 0000000..38937a0 --- /dev/null +++ b/examples/weather.js @@ -0,0 +1,60 @@ +'use strict'; + +/** + * Example: Weather lookup + * + * Demonstrates basic tool-calling with llmorbiter. + * The LLM is given a weather tool and asked about the weather in a city. + * + * Usage: + * OPENROUTER_API_KEY=sk-or-... node examples/weather.js + * node examples/weather.js --key sk-or-... --city Tokyo + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; + +import { LLMOrbiter, OpenRouterProvider } from '../src/index.js'; +import { weatherTool } from './tools/weather.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +const args = process.argv.slice(2); +function getArg(flag) { + const idx = args.indexOf(flag); + return idx !== -1 && args[idx + 1] ? args[idx + 1] : null; +} + +const apiKey = getArg('--key') || process.env.OPENROUTER_API_KEY; +const model = getArg('--model') || 'deepseek/deepseek-v4-flash-0731'; +const city = getArg('--city') || 'Berlin'; + +if (!apiKey) { + console.error('Error: Missing API key. Set OPENROUTER_API_KEY in examples/.env or pass --key '); + process.exit(1); +} + +async function main() { + const provider = new OpenRouterProvider({ apiKey }); + + const orbiter = new LLMOrbiter(provider, { + onLLMCall: (call) => console.log(`🤖 LLM call: model="${call.model}" tools=${call.toolCount} promptSize=${call.promptSize} messages=${call.messages}`), + onStatus: (status) => console.log(`⚡ ${status}`), + onToolCall: (call) => console.log(`🔧 Tool called: ${call.name}(${JSON.stringify(call.args)})`), + onToolError: (err) => console.log(`❌ Tool error:`, err.error), + onToolResult: (res) => console.log(`📦 Tool result:`, res.result), + onUsage: (usage) => console.log(`📊 LLM call usage:`, usage), + }); + + const result = await orbiter.run({ + prompt: `What's the weather like in ${city}?`, + model, + tools: [weatherTool], + }); + + console.log('\n💬 Response:', result.content); +} + +main().catch(console.error); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f34a63b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "llmorbiter", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "llmorbiter", + "version": "0.1.0", + "license": "0BSD", + "dependencies": { + "dotenv": "^17.4.2" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9015a00 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "llmorbiter", + "version": "0.1.0", + "description": "LLM tool-calling orchestrator with provider abstraction", + "main": "src/index.js", + "type": "module", + "license": "0BSD", + "dependencies": { + "dotenv": "^17.4.2" + } +} diff --git a/src/LLMOrbiter.js b/src/LLMOrbiter.js new file mode 100644 index 0000000..e840f13 --- /dev/null +++ b/src/LLMOrbiter.js @@ -0,0 +1,363 @@ +'use strict'; + +import { DirectReturnError } from './Tool.js'; + +/** Always produce a proper DOMException('...', 'AbortError') from any abort reason. */ +function ensureAbortError(reason) { + if (reason instanceof DOMException && reason.name === 'AbortError') return reason; + const err = new DOMException( + typeof reason === 'string' ? reason : (reason?.message || 'LLMOrbiter run aborted'), + 'AbortError', + ); + if (reason != null && typeof reason !== 'string') err.cause = reason; + return err; +} + +/** + * LLMOrbiter — main orchestrator. + * + * Usage: + * const orbiter = new LLMOrbiter(provider, { onStatus, onToolCall }); + * const result = await orbiter.run({ prompt, messages, model, tools }); + * orbiter.abort(); // abort the active request/tool loop + * + * `messages` is the conversation transcript (the system prompt is simply its first + * entry, if any) and is returned on the result for the next run to continue from. + * + * The run loop: + * 1. Send messages + tool specs to the provider + * 2. If the LLM returns tool_calls → invoke local implementations, feed results back + * 3. Repeat until the LLM finishes (no more tool_calls) or maxIterations is hit + * 4. Return { content, toolResults, usage } + */ +class LLMOrbiter { + /** + * @param {Object} provider — must implement chatCompletions({ model, messages, tools, tool_choice, signal, onStatus, onContent, onReasoning }) + * @param {Object} [opts] + * @param {Function} [opts.onLLMCall] — called before LLM request ({ model, toolCount, promptSize, messageCount, iteration }) + * @param {Function} [opts.onUsage] — called with usage info ({ cached, input, output, cost }) + * @param {Function} [opts.onToolCall] — called with tool call info ({ name, args }) + * @param {Function} [opts.onToolError] — called with tool error info ({ name, error, args }) + * @param {Function} [opts.onToolResult] — called with tool result info ({ name, result }) + * @param {Function} [opts.onStatus] — called with status string (e.g. 'connected', 'streaming') + * @param {Function} [opts.onContent] — called with streamed content chunks + * @param {Function} [opts.onReasoning] — called with streamed reasoning text + */ + constructor(provider, opts = {}) { + if (!provider) throw new Error('LLMOrbiter requires a provider'); + this.provider = provider; + this._activeController = null; + Object.assign(this, opts); + } + + /** + * Abort the currently active run, if any. + * Note: with concurrent runs, only the most recent run is aborted. + */ + abort(reason) { + this._activeController?.abort(ensureAbortError(reason)); + } + + /** + * Run an LLM conversation with tool-calling loop. + * + * @param {Object} opts + * @param {string} opts.prompt — user message to append (omit/null to continue + * from `messages` without adding one) + * @param {Array} [opts.messages] — conversation transcript to continue from (e.g. a + * previous result.messages). Used as-is; a leading + * { role: 'system', content } entry is the system + * prompt. Omit to start a fresh conversation. + * @param {string} [opts.model] — model identifier (provider-specific default if omitted) + * @param {Array} [opts.tools] — array of Tool objects from defineTool() + * @param {number} [opts.maxIterations=10] — safety limit for tool-call loops + * @param {Object} [opts.tool_choice] — force a specific tool or "auto" + * @param {Object} [opts.extraBody] — extra fields merged into the request body (e.g. reasoning) + * @param {AbortSignal} [opts.signal] — optional external abort signal + * @returns {Promise} + */ + async run(opts) { + const { + prompt, + messages: seedMessages, + model, + tools = [], + maxIterations = 10, + tool_choice, + extraBody = {}, + signal, + } = opts; + + if (seedMessages != null && !Array.isArray(seedMessages)) { + throw new TypeError('run({ messages }): messages must be an array, or omit it for a fresh conversation'); + } + + const { onLLMCall, onUsage, onToolCall, onToolError, onToolResult, onStatus, onContent, onReasoning } = this; + + // Keep an internal controller so callers can cancel a run through orbiter.abort(). + // The external signal remains supported and is forwarded into the same controller. + const controller = new AbortController(); + const abortFromExternalSignal = () => controller.abort(ensureAbortError(signal.reason)); + if (signal) { + if (signal.aborted) abortFromExternalSignal(); + else signal.addEventListener('abort', abortFromExternalSignal, { once: true }); + } + this._activeController = controller; + const runSignal = controller.signal; + + const call = (fn, arg) => { + if (fn) { + try { fn(arg); } catch (_) {} + } + }; + + // Record a tool response into the transcript. Every tool_call in an assistant + // message must have a matching tool message, so this is the single place that + // writes them (including the direct-return/skip cases). + const recordToolResult = (callItem, result) => { + const content = typeof result === 'string' ? result : JSON.stringify(result); + messages.push({ role: 'tool', tool_call_id: callItem.id, content }); + }; + + // Build tool lookup: name -> invoke + const toolMap = new Map(); + const toolSpecs = []; + for (const t of tools) { + toolMap.set(t.name, t.invoke); + toolSpecs.push(t.spec); + } + + // Seed messages: continue from a prior transcript, or start fresh. + // A leading { role: 'system' } entry in the seed is the system prompt. + const messages = seedMessages?.length ? [...seedMessages] : []; + if (prompt != null) { + messages.push({ role: 'user', content: prompt }); + } + + const allToolResults = {}; // toolName -> [result, ...] + const usage = { + input: 0, + output: 0, + cached: 0, + tokenIn: 0, + tokenOut: 0, + tokenCached: 0, + costTotal: 0, + }; + const rawUsages = []; + let finalContent = null; + let iteration = 0; + let directReturn = null; + let hasDirectReturn = false; + + const promptStr = typeof prompt === 'string' ? prompt : JSON.stringify(prompt || ''); + const promptSize = promptStr.length; + const toolCount = toolSpecs.length; + + try { + while (iteration < maxIterations) { + if (runSignal.aborted) throw ensureAbortError(runSignal.reason); + iteration++; + + const messageFlow = messages.flatMap((m) => { + if (m.role === 'assistant' && m.tool_calls?.length) { + return ['assistant', 'tool']; + } + if (m.role === 'tool') { + return ['tool_response']; + } + return [m.role]; + }).join(','); + + call(onLLMCall, { + model, + toolCount, + tools: toolCount, + promptSize, + messageCount: messages.length, + messages: messageFlow, + iteration, + }); + + const completion = await this.provider.chatCompletions({ + model, + messages, + tools: toolSpecs.length ? toolSpecs : undefined, + tool_choice: toolSpecs.length ? (tool_choice || 'auto') : undefined, + signal: runSignal, + extraBody, + onStatus: (s) => call(onStatus, s), + onContent: (c) => call(onContent, c), + onReasoning: (r) => call(onReasoning, r), + }); + + // Accumulate usage & emit event per LLM call completed + const callUsage = this._addUsage(usage, completion.usage); + if (completion.usage) { + rawUsages.push(completion.usage); + } + call(onUsage, callUsage); + + const toolCalls = completion.tool_calls; + const content = completion.content; + + // Push assistant message + messages.push({ + role: 'assistant', + content: content || null, + ...(toolCalls?.length ? { tool_calls: toolCalls } : {}), + }); + + // No tool calls → done + if (!toolCalls?.length) { + finalContent = content; + break; + } + + // Execute each tool call locally + for (let callIdx = 0; callIdx < toolCalls.length; callIdx++) { + const callItem = toolCalls[callIdx]; + const fnName = callItem.function?.name; + const invokeFn = toolMap.get(fnName); + + let result; + let isError = false; + let errorMsg = null; + + if (!invokeFn) { + isError = true; + errorMsg = `Unknown tool: ${fnName}`; + result = { error: errorMsg }; + call(onToolError, { name: fnName, error: errorMsg }); + } else { + let args; + try { + args = JSON.parse(callItem.function.arguments || '{}'); + } catch (e) { + isError = true; + errorMsg = `Invalid JSON arguments: ${e.message}`; + result = { error: errorMsg }; + call(onToolError, { name: fnName, error: errorMsg }); + } + + if (!isError) { + call(onToolCall, { name: fnName, args }); + + try { + result = await invokeFn(args, { signal: runSignal }); + } catch (err) { + // An abort must propagate, not be recorded as a tool error. + if (runSignal.aborted) throw ensureAbortError(runSignal.reason); + if (err instanceof DirectReturnError) { + // Stop the loop and return the data to the caller. + directReturn = err.data; + hasDirectReturn = true; + + // Record this call's response and close out any remaining + // calls in the batch with synthetic tool messages, so the + // returned transcript stays valid to send back to the API + // (every tool_call must have a matching tool response). + recordToolResult(callItem, err.data); + for (let j = callIdx + 1; j < toolCalls.length; j++) { + const pending = toolCalls[j]; + recordToolResult(pending, 'Skipped: run terminated by direct return'); + } + break; + } + isError = true; + if (err instanceof Error) { + errorMsg = err.message + ? `${err.name !== 'Error' && err.name !== 'ToolError' ? `${err.name}: ` : ''}${err.message}` + : `${err.name || 'Error'} (unspecified crash)`; + } else if (err != null && String(err).trim()) { + errorMsg = `Tool crashed: ${String(err)}`; + } else { + errorMsg = 'Tool crashed due to an unspecified internal error'; + } + result = { error: errorMsg }; + call(onToolError, { name: fnName, error: errorMsg, args, rawError: err }); + } + + // Also handle tools that return { error: ... } instead of throwing + if (!isError && result && typeof result === 'object' && 'error' in result && result.error) { + isError = true; + errorMsg = typeof result.error === 'string' ? result.error : JSON.stringify(result.error); + call(onToolError, { name: fnName, error: errorMsg, args }); + } + } + } + + if (hasDirectReturn) break; + + // Record result + if (!allToolResults[fnName]) allToolResults[fnName] = []; + allToolResults[fnName].push(result); + + if (!isError) { + call(onToolResult, { name: fnName, result }); + } + + // Feed result back to conversation + recordToolResult(callItem, result); + } + + if (hasDirectReturn) break; + } + + + usage.raw = rawUsages.length === 1 ? rawUsages[0] : rawUsages; + + return { + content: finalContent, + toolResults: allToolResults, + usage, + rawUsage: rawUsages.length === 1 ? rawUsages[0] : rawUsages, + rawUsages, + iterations: iteration, + messages, + ...(directReturn !== null ? { directReturn: true, data: directReturn } : {}), + }; + } finally { + if (signal) signal.removeEventListener('abort', abortFromExternalSignal); + if (this._activeController === controller) this._activeController = null; + } + } + + /** @private */ + _addUsage(totals, u) { + const cached = u?.prompt_tokens_details?.cached_tokens || 0; + const input = (u?.prompt_tokens || 0) - cached; + const output = u?.completion_tokens || 0; + + totals.tokenCached += cached; + totals.tokenIn += input; + totals.tokenOut += output; + totals.cached = totals.tokenCached; + totals.input = totals.tokenIn; + totals.output = totals.tokenOut; + + let cost = 0; + if (u?.cost != null) { + cost = u.cost; + } else if (u?.cost_details) { + const d = u.cost_details; + cost = (d.upstream_inference_prompt_cost || 0) + (d.upstream_inference_completions_cost || 0); + } + totals.costTotal += cost; + totals.cost = totals.costTotal; + + const callUsage = { cached, input, output }; + if (cost) callUsage.cost = cost; + if (u) { + Object.defineProperty(callUsage, 'raw', { + value: u, + enumerable: false, + writable: true, + configurable: true, + }); + } + return callUsage; + } +} + +export { LLMOrbiter }; diff --git a/src/Tool.js b/src/Tool.js new file mode 100644 index 0000000..31de367 --- /dev/null +++ b/src/Tool.js @@ -0,0 +1,58 @@ +'use strict'; + +/** + * A tool has two parts: + * - spec: the OpenAI-compatible function schema sent to the LLM + * - invoke: the local implementation called when the LLM uses the tool + * + * defineTool({ name, description, parameters, invoke }) -> Tool + */ + +/** + * @typedef {Object} ToolDefinition + * @property {string} name - Function name (must match the schema) + * @property {string} description - Human-readable description for the LLM + * @property {Object} parameters - JSON Schema for the function parameters + * @property {boolean} [strict] - Whether to enforce strict mode (default: true) + * @property {Function} invoke - async (args) => any — local implementation + */ + +/** + * Create a tool from a definition. + * Returns { spec, invoke } where spec is the wire-format object sent to the LLM. + */ +export function defineTool({ name, description, parameters, strict = true, invoke }) { + if (!name) throw new Error('Tool must have a name'); + if (typeof invoke !== 'function') throw new Error(`Tool "${name}" must have an invoke function`); + + const spec = { + type: 'function', + function: { + name, + description: description || '', + parameters: parameters || { type: 'object', properties: {}, additionalProperties: false }, + strict, + }, + }; + return { spec, invoke, name }; +} + +export class ToolError extends Error { + constructor(message) { + super(message); + this.name = 'ToolError'; + } +} + +/** + * Throw this from a tool's invoke to stop the LLM loop immediately and return + * the given data to the caller of orbiter.run(). The run() promise resolves + * with { directReturn: true, data, ... } instead of continuing the loop. + */ +export class DirectReturnError extends Error { + constructor(data) { + super('Direct return requested by tool'); + this.name = 'DirectReturnError'; + this.data = data; + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..f2b502b --- /dev/null +++ b/src/index.js @@ -0,0 +1,7 @@ +'use strict'; + +import { LLMOrbiter } from './LLMOrbiter.js'; +import { OpenRouterProvider } from './providers/OpenRouterProvider.js'; +import { defineTool, ToolError, DirectReturnError } from './Tool.js'; + +export { LLMOrbiter, OpenRouterProvider, defineTool, ToolError, DirectReturnError }; diff --git a/src/providers/OpenRouterProvider.js b/src/providers/OpenRouterProvider.js new file mode 100644 index 0000000..ee47b54 --- /dev/null +++ b/src/providers/OpenRouterProvider.js @@ -0,0 +1,171 @@ +'use strict'; + +const DEFAULT_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; + +/** + * OpenRouter provider for LLMOrbiter. + * + * Usage: + * const provider = new OpenRouterProvider({ + * apiKey: 'sk-or-v1-...', + * siteUrl: 'https://myapp.local', + * siteName: 'MyApp', + * }); + */ +class OpenRouterProvider { + /** + * @param {Object} opts + * @param {string} opts.apiKey — OpenRouter API key + * @param {string} [opts.apiUrl] — API endpoint (default: OpenRouter) + * @param {string} [opts.siteUrl] — HTTP-Referer header + * @param {string} [opts.siteName] — X-Title header + */ + constructor({ apiKey, apiUrl, siteUrl, siteName } = {}) { + if (!apiKey) throw new Error('OpenRouterProvider requires an apiKey'); + this.apiKey = apiKey; + this.apiUrl = apiUrl || DEFAULT_API_URL; + this.siteUrl = siteUrl || ''; + this.siteName = siteName || ''; + } + + /** + * Streaming chat completion against OpenRouter. + * Returns assembled { content, tool_calls, finish_reason, usage, reasoning_details }. + */ + async chatCompletions({ model, messages, tools, tool_choice, signal, extraBody = {}, onStatus, onContent, onReasoning }) { + if (!model) throw new Error('model is required'); + const body = { + model, + messages, + stream: true, + ...extraBody, + }; + if (tools?.length) body.tools = tools; + if (tool_choice) body.tool_choice = tool_choice; + + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }; + if (this.siteUrl) headers['HTTP-Referer'] = this.siteUrl; + if (this.siteName) headers['X-Title'] = this.siteName; + + const response = await fetch(this.apiUrl, { + method: 'POST', + signal, + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + const msg = errBody?.error?.message || JSON.stringify(errBody); + const code = errBody?.error?.code ? ` [code=${errBody.error.code}]` : ''; + const meta = errBody?.error?.metadata ? ` metadata=${JSON.stringify(errBody.error.metadata)}` : ''; + throw new Error(`OpenRouter HTTP ${response.status}: ${msg}${code}${meta}`); + } + + onStatus?.('connected'); + + return this._readStream(response, { onStatus, onContent, onReasoning }); + } + + /** @private — read SSE stream and assemble the final result. */ + async _readStream(response, handlers) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const result = {}; + const state = { workingSent: false }; + let firstPayload = true; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('data:')) continue; + const payload = trimmed.slice(5).trim(); + if (payload === '[DONE]') continue; + + console.log('OpenRouter SSE payload:', payload); + const payloadObj = JSON.parse(payload); + const reasoning = payloadObj.choices?.[0].delta.reasoning; + if (reasoning) + console.log(reasoning); + + if (firstPayload) { + firstPayload = false; + handlers.onStatus?.('streaming'); + } + + this._processChunk(payload, result, handlers, state); + } + } + + return result; + } + + /** @private */ + _processChunk(payload, result, { onContent, onReasoning, onStatus }, state = {}) { + let data; + try { + data = JSON.parse(payload); + } catch { + return; // skip malformed chunks + } + + const choice = data.choices?.[0]; + if (!choice) { + if (data.usage) result.usage = data.usage; + return; + } + + // Content + if (choice.delta?.content) { + result.content = (result.content || '') + choice.delta.content; + onContent?.(choice.delta.content); + } + + // Tool calls + if (choice.delta?.tool_calls) { + if (!state.workingSent) { + state.workingSent = true; + onStatus?.('working'); + } + if (!result.tool_calls) result.tool_calls = []; + for (const tc of choice.delta.tool_calls) { + const dst = result.tool_calls[tc.index] ||= {}; + if (tc.id) dst.id = tc.id; + if (tc.type) dst.type = tc.type; + if (tc.function) { + const fn = dst.function ||= {}; + if (tc.function.name) fn.name = tc.function.name; + fn.arguments = (fn.arguments || '') + (tc.function.arguments || ''); + } + } + } + + // Reasoning + if (choice.delta?.reasoning_details) { + if (!result.reasoning_details) result.reasoning_details = []; + for (const rd of choice.delta.reasoning_details) { + const slot = result.reasoning_details[rd.index] ||= { type: rd.type, format: rd.format, text: '' }; + if (rd.text) { + slot.text += rd.text; + onReasoning?.(slot.text); + } + } + } + + if (choice.finish_reason) result.finish_reason = choice.finish_reason; + if (data.usage) result.usage = data.usage; + } +} + +export { OpenRouterProvider };