first commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
examples/.env
|
||||
examples/invoice.pdf
|
||||
500
examples/pdf-pages.js
Normal file
500
examples/pdf-pages.js
Normal file
@@ -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 <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 <api-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);
|
||||
});
|
||||
141
examples/tool-error-retry.js
Normal file
141
examples/tool-error-retry.js
Normal file
@@ -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 <api-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);
|
||||
56
examples/tools/weather.js
Normal file
56
examples/tools/weather.js
Normal file
@@ -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 };
|
||||
60
examples/weather.js
Normal file
60
examples/weather.js
Normal file
@@ -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 <api-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);
|
||||
28
package-lock.json
generated
Normal file
28
package-lock.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
package.json
Normal file
11
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
363
src/LLMOrbiter.js
Normal file
363
src/LLMOrbiter.js
Normal file
@@ -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<RunResult>}
|
||||
*/
|
||||
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 };
|
||||
58
src/Tool.js
Normal file
58
src/Tool.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
7
src/index.js
Normal file
7
src/index.js
Normal file
@@ -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 };
|
||||
171
src/providers/OpenRouterProvider.js
Normal file
171
src/providers/OpenRouterProvider.js
Normal file
@@ -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 };
|
||||
Reference in New Issue
Block a user