first commit

This commit is contained in:
seb
2026-09-21 11:26:37 +02:00
commit d47acf5481
11 changed files with 1398 additions and 0 deletions

363
src/LLMOrbiter.js Normal file
View 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
View 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
View 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 };

View 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 };