u
This commit is contained in:
@@ -73,13 +73,14 @@ Returns:
|
|||||||
|
|
||||||
### `OpenRouterProvider`
|
### `OpenRouterProvider`
|
||||||
|
|
||||||
`new OpenRouterProvider({ apiKey, apiUrl, siteUrl, siteName })`.
|
`new OpenRouterProvider({ apiKey, apiUrl, siteUrl, siteName, sessionId })`.
|
||||||
- `apiUrl` defaults to `https://openrouter.ai/api/v1/chat/completions`.
|
- `apiUrl` defaults to `https://openrouter.ai/api/v1/chat/completions`.
|
||||||
- `siteUrl` sets `HTTP-Referer` header.
|
- `siteUrl` sets `HTTP-Referer` header.
|
||||||
- `siteName` sets `X-Title` 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:
|
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 }`.
|
— 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`.
|
- If reasoning was returned, automatically appends `{ role: 'assistant', content: result.reasoning }` to `messages`.
|
||||||
- Delta handling:
|
- Delta handling:
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^18.0.4"
|
"dotenv": "^18.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,9 +105,9 @@ class LLMOrbiter {
|
|||||||
this._activeController = controller;
|
this._activeController = controller;
|
||||||
const runSignal = controller.signal;
|
const runSignal = controller.signal;
|
||||||
|
|
||||||
const call = (fn, arg) => {
|
const call = (fn, ...args) => {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
try { fn(arg); } catch (_) {}
|
try { fn(...args); } catch (_) {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ class LLMOrbiter {
|
|||||||
signal: runSignal,
|
signal: runSignal,
|
||||||
extraBody,
|
extraBody,
|
||||||
onStatus: (s) => call(onStatus, s),
|
onStatus: (s) => call(onStatus, s),
|
||||||
onContent: (c) => call(onContent, c),
|
onContent: (...args) => call(onContent, ...args),
|
||||||
onReasoning: (r) => call(onReasoning, r),
|
onReasoning: (r) => call(onReasoning, r),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,6 +213,8 @@ class LLMOrbiter {
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: content || null,
|
content: content || null,
|
||||||
...(hasToolCalls ? { tool_calls: toolCalls } : {}),
|
...(hasToolCalls ? { tool_calls: toolCalls } : {}),
|
||||||
|
...(completion.reasoning_details?.length ? { reasoning_details: completion.reasoning_details } : {}),
|
||||||
|
...(completion.reasoning ? { reasoning: completion.reasoning } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// No tool calls → done
|
// No tool calls → done
|
||||||
@@ -247,7 +249,7 @@ class LLMOrbiter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isError) {
|
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 {
|
try {
|
||||||
result = await invokeFn(args, {
|
result = await invokeFn(args, {
|
||||||
@@ -267,10 +269,12 @@ class LLMOrbiter {
|
|||||||
// calls in the batch with synthetic tool messages, so the
|
// calls in the batch with synthetic tool messages, so the
|
||||||
// returned transcript stays valid to send back to the API
|
// returned transcript stays valid to send back to the API
|
||||||
// (every tool_call must have a matching tool response).
|
// (every tool_call must have a matching tool response).
|
||||||
recordToolResult(callItem, err.data);
|
if (err.recordResult !== false) {
|
||||||
for (let j = callIdx + 1; j < toolCalls.length; j++) {
|
recordToolResult(callItem, err.data);
|
||||||
const pending = toolCalls[j];
|
for (let j = callIdx + 1; j < toolCalls.length; j++) {
|
||||||
recordToolResult(pending, 'Skipped: run terminated by direct return');
|
const pending = toolCalls[j];
|
||||||
|
recordToolResult(pending, 'Skipped: run terminated by direct return');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -285,7 +289,7 @@ class LLMOrbiter {
|
|||||||
errorMsg = 'Tool crashed due to an unspecified internal error';
|
errorMsg = 'Tool crashed due to an unspecified internal error';
|
||||||
}
|
}
|
||||||
result = { error: errorMsg };
|
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
|
// Also handle tools that return { error: ... } instead of throwing
|
||||||
@@ -310,7 +314,7 @@ class LLMOrbiter {
|
|||||||
allToolResults[fnName].push(result);
|
allToolResults[fnName].push(result);
|
||||||
|
|
||||||
if (!isError) {
|
if (!isError) {
|
||||||
call(onToolResult, { name: fnName, result });
|
call(onToolResult, { name: fnName, result, id: callItem.id, callItem });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feed result back to conversation
|
// Feed result back to conversation
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ export class ToolError extends Error {
|
|||||||
* with { directReturn: true, data, ... } instead of continuing the loop.
|
* with { directReturn: true, data, ... } instead of continuing the loop.
|
||||||
*/
|
*/
|
||||||
export class DirectReturnError extends Error {
|
export class DirectReturnError extends Error {
|
||||||
constructor(data) {
|
constructor(data, { recordResult = true } = {}) {
|
||||||
super('Direct return requested by tool');
|
super('Direct return requested by tool');
|
||||||
this.name = 'DirectReturnError';
|
this.name = 'DirectReturnError';
|
||||||
this.data = data;
|
this.data = data;
|
||||||
|
this.recordResult = recordResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
const DEFAULT_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
const DEFAULT_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||||
|
const PROCESS_SESSION_ID = randomUUID();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenRouter provider for LLMOrbiter.
|
* OpenRouter provider for LLMOrbiter.
|
||||||
@@ -13,31 +16,42 @@ const DEFAULT_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
class OpenRouterProvider {
|
class OpenRouterProvider {
|
||||||
|
static get processSessionId() {
|
||||||
|
return PROCESS_SESSION_ID;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
* @param {string} opts.apiKey — OpenRouter API key
|
* @param {string} opts.apiKey — OpenRouter API key
|
||||||
* @param {string} [opts.apiUrl] — API endpoint (default: OpenRouter)
|
* @param {string} [opts.apiUrl] — API endpoint (default: OpenRouter)
|
||||||
* @param {string} [opts.siteUrl] — HTTP-Referer header
|
* @param {string} [opts.siteUrl] — HTTP-Referer header
|
||||||
* @param {string} [opts.siteName] — X-Title 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');
|
if (!apiKey) throw new Error('OpenRouterProvider requires an apiKey');
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
this.apiUrl = apiUrl || DEFAULT_API_URL;
|
this.apiUrl = apiUrl || DEFAULT_API_URL;
|
||||||
this.siteUrl = siteUrl || '';
|
this.siteUrl = siteUrl || '';
|
||||||
this.siteName = siteName || '';
|
this.siteName = siteName || '';
|
||||||
|
this.sessionId = sessionId || PROCESS_SESSION_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streaming chat completion against OpenRouter.
|
* Streaming chat completion against OpenRouter.
|
||||||
* Returns assembled { content, tool_calls, finish_reason, usage, reasoning_details }.
|
* 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');
|
if (!model) throw new Error('model is required');
|
||||||
|
const effectiveSessionId = sessionId || extraBody.session_id || this.sessionId;
|
||||||
const body = {
|
const body = {
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
|
reasoning: {
|
||||||
|
effort: 'minimal'
|
||||||
|
},
|
||||||
stream: true,
|
stream: true,
|
||||||
|
...(effectiveSessionId ? { session_id: effectiveSessionId } : {}),
|
||||||
...extraBody,
|
...extraBody,
|
||||||
};
|
};
|
||||||
if (tools?.length) body.tools = tools;
|
if (tools?.length) body.tools = tools;
|
||||||
@@ -49,6 +63,7 @@ class OpenRouterProvider {
|
|||||||
};
|
};
|
||||||
if (this.siteUrl) headers['HTTP-Referer'] = this.siteUrl;
|
if (this.siteUrl) headers['HTTP-Referer'] = this.siteUrl;
|
||||||
if (this.siteName) headers['X-Title'] = this.siteName;
|
if (this.siteName) headers['X-Title'] = this.siteName;
|
||||||
|
if (effectiveSessionId) headers['x-session-id'] = effectiveSessionId;
|
||||||
|
|
||||||
const response = await fetch(this.apiUrl, {
|
const response = await fetch(this.apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -102,14 +117,7 @@ class OpenRouterProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.reasoning) {
|
|
||||||
if (Array.isArray(messages)) {
|
|
||||||
messages.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: result.reasoning,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -132,7 +140,7 @@ class OpenRouterProvider {
|
|||||||
// Content
|
// Content
|
||||||
if (choice.delta?.content) {
|
if (choice.delta?.content) {
|
||||||
result.content = (result.content || '') + choice.delta.content;
|
result.content = (result.content || '') + choice.delta.content;
|
||||||
onContent?.(choice.delta.content);
|
onContent?.(choice.delta.content, result.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool calls
|
// Tool calls
|
||||||
|
|||||||
89
test/openrouter.test.js
Normal file
89
test/openrouter.test.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user