diff --git a/AGENTS.md b/AGENTS.md index 1ed59de..8efc4af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,13 +73,14 @@ Returns: ### `OpenRouterProvider` -`new OpenRouterProvider({ apiKey, apiUrl, siteUrl, siteName })`. +`new OpenRouterProvider({ apiKey, apiUrl, siteUrl, siteName, sessionId })`. - `apiUrl` defaults to `https://openrouter.ai/api/v1/chat/completions`. - `siteUrl` sets `HTTP-Referer` header. - `siteName` sets `X-Title` header. +- `sessionId` sets session ID / cache key (defaults to a process-lifetime UUID created when the app starts; sent via `x-session-id` HTTP header and `session_id` body parameter). Method: -`chatCompletions({ model, messages, tools, tool_choice, signal, extraBody, onStatus, onContent, onReasoning })` +`chatCompletions({ model, messages, tools, tool_choice, signal, extraBody, onStatus, onContent, onReasoning, sessionId })` — streams SSE, assembles `{ content, tool_calls, finish_reason, usage, reasoning_details, reasoning }`. - If reasoning was returned, automatically appends `{ role: 'assistant', content: result.reasoning }` to `messages`. - Delta handling: diff --git a/package.json b/package.json index 10996b0..464eb48 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,6 @@ "node": ">=18.0.0" }, "dependencies": { - "dotenv": "^18.0.4" + "dotenv": "^18.0.5" } } diff --git a/src/LLMOrbiter.js b/src/LLMOrbiter.js index 30610b9..5db657f 100644 --- a/src/LLMOrbiter.js +++ b/src/LLMOrbiter.js @@ -105,9 +105,9 @@ class LLMOrbiter { this._activeController = controller; const runSignal = controller.signal; - const call = (fn, arg) => { + const call = (fn, ...args) => { if (fn) { - try { fn(arg); } catch (_) {} + try { fn(...args); } catch (_) {} } }; @@ -187,7 +187,7 @@ class LLMOrbiter { signal: runSignal, extraBody, onStatus: (s) => call(onStatus, s), - onContent: (c) => call(onContent, c), + onContent: (...args) => call(onContent, ...args), onReasoning: (r) => call(onReasoning, r), }); @@ -213,6 +213,8 @@ class LLMOrbiter { role: 'assistant', content: content || null, ...(hasToolCalls ? { tool_calls: toolCalls } : {}), + ...(completion.reasoning_details?.length ? { reasoning_details: completion.reasoning_details } : {}), + ...(completion.reasoning ? { reasoning: completion.reasoning } : {}), }); // No tool calls → done @@ -247,7 +249,7 @@ class LLMOrbiter { } if (!isError) { - call(onToolCall, { name: fnName, args }); + call(onToolCall, { name: fnName, args, id: callItem.id, callItem, tool_calls: toolCalls, reasoning_details: completion.reasoning_details, reasoning: completion.reasoning, content: content || null }); try { result = await invokeFn(args, { @@ -267,10 +269,12 @@ class LLMOrbiter { // 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'); + if (err.recordResult !== false) { + 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; } @@ -285,7 +289,7 @@ class LLMOrbiter { errorMsg = 'Tool crashed due to an unspecified internal error'; } result = { error: errorMsg }; - call(onToolError, { name: fnName, error: errorMsg, args, rawError: err }); + call(onToolError, { name: fnName, error: errorMsg, args, rawError: err, id: callItem.id, callItem }); } // Also handle tools that return { error: ... } instead of throwing @@ -310,7 +314,7 @@ class LLMOrbiter { allToolResults[fnName].push(result); if (!isError) { - call(onToolResult, { name: fnName, result }); + call(onToolResult, { name: fnName, result, id: callItem.id, callItem }); } // Feed result back to conversation diff --git a/src/Tool.js b/src/Tool.js index 267ed3a..f1b2e52 100644 --- a/src/Tool.js +++ b/src/Tool.js @@ -50,9 +50,10 @@ export class ToolError extends Error { * with { directReturn: true, data, ... } instead of continuing the loop. */ export class DirectReturnError extends Error { - constructor(data) { + constructor(data, { recordResult = true } = {}) { super('Direct return requested by tool'); this.name = 'DirectReturnError'; this.data = data; + this.recordResult = recordResult; } } diff --git a/src/providers/OpenRouterProvider.js b/src/providers/OpenRouterProvider.js index 3d28926..a04cf6f 100644 --- a/src/providers/OpenRouterProvider.js +++ b/src/providers/OpenRouterProvider.js @@ -1,6 +1,9 @@ 'use strict'; +import { randomUUID } from 'node:crypto'; + const DEFAULT_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +const PROCESS_SESSION_ID = randomUUID(); /** * OpenRouter provider for LLMOrbiter. @@ -13,31 +16,42 @@ const DEFAULT_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; * }); */ class OpenRouterProvider { + static get processSessionId() { + return PROCESS_SESSION_ID; + } + /** * @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 + * @param {string} [opts.sessionId] — Session ID / cache key (default: process-lifetime UUID) */ - constructor({ apiKey, apiUrl, siteUrl, siteName } = {}) { + constructor({ apiKey, apiUrl, siteUrl, siteName, sessionId } = {}) { if (!apiKey) throw new Error('OpenRouterProvider requires an apiKey'); this.apiKey = apiKey; this.apiUrl = apiUrl || DEFAULT_API_URL; this.siteUrl = siteUrl || ''; this.siteName = siteName || ''; + this.sessionId = sessionId || PROCESS_SESSION_ID; } /** * 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 }) { + async chatCompletions({ model, messages, tools, tool_choice, signal, extraBody = {}, onStatus, onContent, onReasoning, sessionId }) { if (!model) throw new Error('model is required'); + const effectiveSessionId = sessionId || extraBody.session_id || this.sessionId; const body = { model, messages, + reasoning: { + effort: 'minimal' + }, stream: true, + ...(effectiveSessionId ? { session_id: effectiveSessionId } : {}), ...extraBody, }; if (tools?.length) body.tools = tools; @@ -49,6 +63,7 @@ class OpenRouterProvider { }; if (this.siteUrl) headers['HTTP-Referer'] = this.siteUrl; if (this.siteName) headers['X-Title'] = this.siteName; + if (effectiveSessionId) headers['x-session-id'] = effectiveSessionId; const response = await fetch(this.apiUrl, { method: 'POST', @@ -102,14 +117,7 @@ class OpenRouterProvider { } } - if (result.reasoning) { - if (Array.isArray(messages)) { - messages.push({ - role: 'assistant', - content: result.reasoning, - }); - } - } + return result; } @@ -132,7 +140,7 @@ class OpenRouterProvider { // Content if (choice.delta?.content) { result.content = (result.content || '') + choice.delta.content; - onContent?.(choice.delta.content); + onContent?.(choice.delta.content, result.content); } // Tool calls diff --git a/test/openrouter.test.js b/test/openrouter.test.js new file mode 100644 index 0000000..18d2823 --- /dev/null +++ b/test/openrouter.test.js @@ -0,0 +1,89 @@ +'use strict'; + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { OpenRouterProvider } from '../src/providers/OpenRouterProvider.js'; + +test('OpenRouterProvider shares process-lifetime session ID by default', () => { + assert.ok(OpenRouterProvider.processSessionId, 'processSessionId should exist'); + assert.match( + OpenRouterProvider.processSessionId, + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + 'processSessionId should be a valid UUID', + ); + + const provider1 = new OpenRouterProvider({ apiKey: 'test-key-1' }); + const provider2 = new OpenRouterProvider({ apiKey: 'test-key-2' }); + + assert.equal(provider1.sessionId, OpenRouterProvider.processSessionId); + assert.equal(provider2.sessionId, OpenRouterProvider.processSessionId); + assert.equal(provider1.sessionId, provider2.sessionId); +}); + +test('OpenRouterProvider allows custom sessionId override in constructor', () => { + const provider = new OpenRouterProvider({ apiKey: 'test-key', sessionId: 'custom-session-123' }); + assert.equal(provider.sessionId, 'custom-session-123'); +}); + +test('chatCompletions sends session_id in JSON body and x-session-id in HTTP header', async () => { + let capturedUrl = null; + let capturedOptions = null; + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, options) => { + capturedUrl = url; + capturedOptions = options; + return { + ok: true, + status: 200, + body: { + getReader() { + let done = false; + return { + async read() { + if (done) return { done: true, value: undefined }; + done = true; + const encoder = new TextEncoder(); + return { + done: false, + value: encoder.encode('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'), + }; + }, + }; + }, + }, + }; + }; + + try { + const provider = new OpenRouterProvider({ apiKey: 'sk-test' }); + const res = await provider.chatCompletions({ + model: 'test/model', + messages: [{ role: 'user', content: 'hello' }], + }); + + assert.equal(res.content, 'ok'); + assert.ok(capturedOptions, 'fetch should have been called'); + + // Check header + assert.equal(capturedOptions.headers['x-session-id'], OpenRouterProvider.processSessionId); + + // Check body + const parsedBody = JSON.parse(capturedOptions.body); + assert.equal(parsedBody.session_id, OpenRouterProvider.processSessionId); + assert.equal(parsedBody.model, 'test/model'); + + // Test custom sessionId override per-call + await provider.chatCompletions({ + model: 'test/model', + messages: [{ role: 'user', content: 'hello again' }], + sessionId: 'call-specific-session', + }); + const parsedBody2 = JSON.parse(capturedOptions.body); + assert.equal(capturedOptions.headers['x-session-id'], 'call-specific-session'); + assert.equal(parsedBody2.session_id, 'call-specific-session'); + } finally { + globalThis.fetch = originalFetch; + } +});