u
This commit is contained in:
@@ -39,7 +39,7 @@ Exports: `LLMOrbiter`, `OpenRouterProvider`, `defineTool`, `ToolError`, `DirectR
|
|||||||
- `onToolCall({ name, args })` — called before executing a tool.
|
- `onToolCall({ name, args })` — called before executing a tool.
|
||||||
- `onToolResult({ name, result })` — called on successful tool execution.
|
- `onToolResult({ name, result })` — called on successful tool execution.
|
||||||
- `onToolError({ name, error, args, rawError })` — called when tool execution throws, returns `{ error }`, has invalid JSON args, or references an unknown tool.
|
- `onToolError({ name, error, args, rawError })` — called when tool execution throws, returns `{ error }`, has invalid JSON args, or references an unknown tool.
|
||||||
- `onStatus(status)` — forwarded from provider: `'connected'` (HTTP headers received), `'streaming'` (first SSE chunk received), or `'working'` (tool calls started streaming).
|
- `onStatus(status)` — forwarded from provider: `'connected'` (HTTP headers received), `'firstevent'` (first SSE line/comment/event received), `'reasoning'` (first reasoning delta received), `'content'` (first content delta received), or `'toolcall'` (first tool call delta received).
|
||||||
- `onContent(chunk)` — forwarded from provider: incremental output content string (`choice.delta.content`).
|
- `onContent(chunk)` — forwarded from provider: incremental output content string (`choice.delta.content`).
|
||||||
- `onReasoning(text)` — forwarded from provider: **accumulated** reasoning string so far (aggregated from `delta.reasoning`, `delta.reasoning_content`, and `delta.reasoning_details`).
|
- `onReasoning(text)` — forwarded from provider: **accumulated** reasoning string so far (aggregated from `delta.reasoning`, `delta.reasoning_content`, and `delta.reasoning_details`).
|
||||||
|
|
||||||
@@ -83,6 +83,7 @@ Method:
|
|||||||
— 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:
|
||||||
- `delta.content` -> passes chunk to `onContent(chunk)`.
|
- first SSE event/line (including comments) -> triggers `onStatus('firstevent')`.
|
||||||
- `delta.reasoning` / `delta.reasoning_content` / `delta.reasoning_details` -> passes **accumulated** string to `onReasoning(text)`.
|
- `delta.reasoning` / `delta.reasoning_content` / `delta.reasoning_details` -> triggers `onStatus('reasoning')` on first reasoning delta, and passes **accumulated** string to `onReasoning(text)`.
|
||||||
- `delta.tool_calls` -> triggers `onStatus('working')` once when tool calls begin streaming (tool calls are assembled into `result.tool_calls` without per-chunk streaming callbacks).
|
- `delta.content` -> triggers `onStatus('content')` on first content delta, and passes chunk to `onContent(chunk)`.
|
||||||
|
- `delta.tool_calls` -> triggers `onStatus('toolcall')` once when tool calls begin streaming (tool calls are assembled into `result.tool_calls` without per-chunk streaming callbacks).
|
||||||
|
|||||||
@@ -183,17 +183,16 @@ class LLMOrbiter {
|
|||||||
tools: toolSpecs.length ? toolSpecs : undefined,
|
tools: toolSpecs.length ? toolSpecs : undefined,
|
||||||
signal: runSignal,
|
signal: runSignal,
|
||||||
extraBody,
|
extraBody,
|
||||||
onStatus: (s) => call(onStatus, s),
|
onStatus: (...args) => call(onStatus, ...args),
|
||||||
onContent: (...args) => call(onContent, ...args),
|
onContent: (...args) => call(onContent, ...args),
|
||||||
onReasoning: (r) => call(onReasoning, r),
|
onReasoning: (r) => call(onReasoning, r),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Accumulate usage & emit event per LLM call completed
|
// Accumulate usage per LLM call completed
|
||||||
const callUsage = this._addUsage(usage, completion.usage);
|
const callUsage = this._addUsage(usage, completion.usage);
|
||||||
if (completion.usage) {
|
if (completion.usage) {
|
||||||
rawUsages.push(completion.usage);
|
rawUsages.push(completion.usage);
|
||||||
}
|
}
|
||||||
await call(onUsage, callUsage);
|
|
||||||
|
|
||||||
const toolCalls = completion.tool_calls;
|
const toolCalls = completion.tool_calls;
|
||||||
const content = completion.content;
|
const content = completion.content;
|
||||||
@@ -202,6 +201,7 @@ class LLMOrbiter {
|
|||||||
|
|
||||||
// When there is no response (neither tool calls nor content), the loop shall continue.
|
// When there is no response (neither tool calls nor content), the loop shall continue.
|
||||||
if (!hasToolCalls && !hasContent) {
|
if (!hasToolCalls && !hasContent) {
|
||||||
|
await call(onUsage, callUsage);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +217,7 @@ class LLMOrbiter {
|
|||||||
// No tool calls → done
|
// No tool calls → done
|
||||||
if (!hasToolCalls) {
|
if (!hasToolCalls) {
|
||||||
finalContent = content;
|
finalContent = content;
|
||||||
|
await call(onUsage, callUsage);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +319,8 @@ class LLMOrbiter {
|
|||||||
recordToolResult(callItem, result);
|
recordToolResult(callItem, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await call(onUsage, callUsage);
|
||||||
|
|
||||||
if (hasDirectReturn) break;
|
if (hasDirectReturn) break;
|
||||||
|
|
||||||
if (pendingAppendedMessages.length > 0) {
|
if (pendingAppendedMessages.length > 0) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class OpenRouterProvider {
|
|||||||
constructor({ apiKey, apiUrl, siteUrl, siteName, sessionId } = {}) {
|
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 || process.env.OPENROUTER_API_URL || DEFAULT_API_URL;
|
||||||
this.siteUrl = siteUrl || '';
|
this.siteUrl = siteUrl || '';
|
||||||
this.siteName = siteName || '';
|
this.siteName = siteName || '';
|
||||||
this.sessionId = sessionId || PROCESS_SESSION_ID;
|
this.sessionId = sessionId || PROCESS_SESSION_ID;
|
||||||
@@ -48,8 +48,9 @@ class OpenRouterProvider {
|
|||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
reasoning: {
|
reasoning: {
|
||||||
effort: 'minimal'
|
effort: 'max'
|
||||||
},
|
},
|
||||||
|
include: ["reasoning.encrypted_content"],
|
||||||
stream: true,
|
stream: true,
|
||||||
...(effectiveSessionId ? { session_id: effectiveSessionId } : {}),
|
...(effectiveSessionId ? { session_id: effectiveSessionId } : {}),
|
||||||
...extraBody,
|
...extraBody,
|
||||||
@@ -90,8 +91,12 @@ class OpenRouterProvider {
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
const result = {};
|
const result = {};
|
||||||
const state = { workingSent: false };
|
const state = {
|
||||||
let firstPayload = true;
|
firstEventSent: false,
|
||||||
|
reasoningSent: false,
|
||||||
|
contentSent: false,
|
||||||
|
workingSent: false,
|
||||||
|
};
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -103,20 +108,34 @@ class OpenRouterProvider {
|
|||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed || !trimmed.startsWith('data:')) continue;
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
if (!state.firstEventSent) {
|
||||||
|
state.firstEventSent = true;
|
||||||
|
handlers.onStatus?.('firstevent');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed.startsWith('data:')) continue;
|
||||||
const payload = trimmed.slice(5).trim();
|
const payload = trimmed.slice(5).trim();
|
||||||
if (payload === '[DONE]') continue;
|
if (payload === '[DONE]') continue;
|
||||||
|
|
||||||
if (firstPayload) {
|
|
||||||
firstPayload = false;
|
|
||||||
handlers.onStatus?.('streaming');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._processChunk(payload, result, handlers, state);
|
this._processChunk(payload, result, handlers, state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remaining = buffer.trim();
|
||||||
|
if (remaining) {
|
||||||
|
if (!state.firstEventSent) {
|
||||||
|
state.firstEventSent = true;
|
||||||
|
handlers.onStatus?.('firstevent');
|
||||||
|
}
|
||||||
|
if (remaining.startsWith('data:')) {
|
||||||
|
const payload = remaining.slice(5).trim();
|
||||||
|
if (payload !== '[DONE]') {
|
||||||
|
this._processChunk(payload, result, handlers, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -138,6 +157,10 @@ class OpenRouterProvider {
|
|||||||
|
|
||||||
// Content
|
// Content
|
||||||
if (choice.delta?.content) {
|
if (choice.delta?.content) {
|
||||||
|
if (!state.contentSent) {
|
||||||
|
state.contentSent = true;
|
||||||
|
onStatus?.('content');
|
||||||
|
}
|
||||||
result.content = (result.content || '') + choice.delta.content;
|
result.content = (result.content || '') + choice.delta.content;
|
||||||
onContent?.(choice.delta.content, result.content);
|
onContent?.(choice.delta.content, result.content);
|
||||||
}
|
}
|
||||||
@@ -181,6 +204,18 @@ class OpenRouterProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasReasoningDelta = Boolean(
|
||||||
|
reasoningText ||
|
||||||
|
choice.delta?.reasoning ||
|
||||||
|
choice.delta?.reasoning_content ||
|
||||||
|
(Array.isArray(choice.delta?.reasoning_details) && choice.delta.reasoning_details.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasReasoningDelta && !state.reasoningSent) {
|
||||||
|
state.reasoningSent = true;
|
||||||
|
onStatus?.('reasoning');
|
||||||
|
}
|
||||||
|
|
||||||
if (reasoningText) {
|
if (reasoningText) {
|
||||||
result.reasoning = (result.reasoning || '') + reasoningText;
|
result.reasoning = (result.reasoning || '') + reasoningText;
|
||||||
onReasoning?.(result.reasoning);
|
onReasoning?.(result.reasoning);
|
||||||
|
|||||||
@@ -87,3 +87,124 @@ test('chatCompletions sends session_id in JSON body and x-session-id in HTTP hea
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('OpenRouterProvider resolves apiUrl from constructor option, OPENROUTER_API_URL env, or default', () => {
|
||||||
|
const originalEnv = process.env.OPENROUTER_API_URL;
|
||||||
|
try {
|
||||||
|
delete process.env.OPENROUTER_API_URL;
|
||||||
|
const defaultProvider = new OpenRouterProvider({ apiKey: 'test-key' });
|
||||||
|
assert.equal(defaultProvider.apiUrl, 'https://openrouter.ai/api/v1/chat/completions');
|
||||||
|
|
||||||
|
process.env.OPENROUTER_API_URL = 'http://127.0.0.1:8787/api/v1/chat/completions';
|
||||||
|
const envProvider = new OpenRouterProvider({ apiKey: 'test-key' });
|
||||||
|
assert.equal(envProvider.apiUrl, 'http://127.0.0.1:8787/api/v1/chat/completions');
|
||||||
|
|
||||||
|
const explicitProvider = new OpenRouterProvider({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
apiUrl: 'http://127.0.0.1:9999/custom',
|
||||||
|
});
|
||||||
|
assert.equal(explicitProvider.apiUrl, 'http://127.0.0.1:9999/custom');
|
||||||
|
} finally {
|
||||||
|
if (originalEnv !== undefined) {
|
||||||
|
process.env.OPENROUTER_API_URL = originalEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env.OPENROUTER_API_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OpenRouterProvider emits connected, firstevent, reasoning, and content status in order', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const statuses = [];
|
||||||
|
const chunks = [
|
||||||
|
': OPENROUTER PROCESSING\n\n',
|
||||||
|
'data: {"choices":[{"delta":{"content":"","role":"assistant","reasoning":"Thinking..."}}]}\n\n',
|
||||||
|
'data: {"choices":[{"delta":{"content":"","role":"assistant","reasoning":" more"}}]}\n\n',
|
||||||
|
'data: {"choices":[{"delta":{"content":"Hello","role":"assistant"}}]}\n\n',
|
||||||
|
'data: {"choices":[{"delta":{"content":" world","role":"assistant"}}]}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
];
|
||||||
|
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
getReader() {
|
||||||
|
let idx = 0;
|
||||||
|
return {
|
||||||
|
async read() {
|
||||||
|
if (idx >= chunks.length) return { done: true, value: undefined };
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const val = encoder.encode(chunks[idx++]);
|
||||||
|
return { done: false, value: val };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const provider = new OpenRouterProvider({ apiKey: 'sk-test' });
|
||||||
|
const res = await provider.chatCompletions({
|
||||||
|
model: 'test/model',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
onStatus: (s) => statuses.push(s),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.content, 'Hello world');
|
||||||
|
assert.equal(res.reasoning, 'Thinking... more');
|
||||||
|
assert.deepEqual(statuses, ['connected', 'firstevent', 'reasoning', 'content']);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OpenRouterProvider emits working status on first tool call delta', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const statuses = [];
|
||||||
|
const chunks = [
|
||||||
|
': OPENROUTER PROCESSING\n\n',
|
||||||
|
'data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"foo","arguments":""}}]}}]}\n\n',
|
||||||
|
'data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
];
|
||||||
|
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
getReader() {
|
||||||
|
let idx = 0;
|
||||||
|
return {
|
||||||
|
async read() {
|
||||||
|
if (idx >= chunks.length) return { done: true, value: undefined };
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const val = encoder.encode(chunks[idx++]);
|
||||||
|
return { done: false, value: val };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const provider = new OpenRouterProvider({ apiKey: 'sk-test' });
|
||||||
|
const res = await provider.chatCompletions({
|
||||||
|
model: 'test/model',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
onStatus: (s) => statuses.push(s),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.tool_calls?.length, 1);
|
||||||
|
assert.equal(res.tool_calls[0].function.name, 'foo');
|
||||||
|
assert.deepEqual(statuses, ['connected', 'firstevent', 'working']);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,3 +102,54 @@ test('LLMOrbiter finishes immediately in 1 iteration when content is returned wi
|
|||||||
assert.equal(result.iterations, 1);
|
assert.equal(result.iterations, 1);
|
||||||
assert.equal(result.content, 'Hello there!');
|
assert.equal(result.content, 'Hello there!');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('LLMOrbiter calls onToolResult before onUsage in a tool-calling iteration', async () => {
|
||||||
|
const events = [];
|
||||||
|
const testTool = defineTool({
|
||||||
|
name: 'test_tool',
|
||||||
|
description: 'Test tool',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
invoke: async () => {
|
||||||
|
return { ok: true, data: 42 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mock = new MockProvider([
|
||||||
|
{
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: 'call_1',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'test_tool', arguments: '{}' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
content: null,
|
||||||
|
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tool_calls: undefined,
|
||||||
|
content: 'All done.',
|
||||||
|
usage: { prompt_tokens: 20, completion_tokens: 8 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const orbiter = new LLMOrbiter(mock, {
|
||||||
|
onToolCall: ({ name }) => events.push(`tool_call:${name}`),
|
||||||
|
onToolResult: ({ name }) => events.push(`tool_result:${name}`),
|
||||||
|
onUsage: (u) => events.push(`usage:${u.input}:${u.output}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await orbiter.run({
|
||||||
|
prompt: 'Run tool',
|
||||||
|
tools: [testTool],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.content, 'All done.');
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
'tool_call:test_tool',
|
||||||
|
'tool_result:test_tool',
|
||||||
|
'usage:10:5',
|
||||||
|
'usage:20:8',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user