diff --git a/AGENTS.md b/AGENTS.md index 9a7f8bc..032cb57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ Exports: `LLMOrbiter`, `OpenRouterProvider`, `defineTool`, `ToolError`, `DirectR - `onToolCall({ name, args })` — called before executing a tool. - `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. -- `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`). - `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 }`. - If reasoning was returned, automatically appends `{ role: 'assistant', content: result.reasoning }` to `messages`. - Delta handling: - - `delta.content` -> passes chunk to `onContent(chunk)`. - - `delta.reasoning` / `delta.reasoning_content` / `delta.reasoning_details` -> 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). + - first SSE event/line (including comments) -> triggers `onStatus('firstevent')`. + - `delta.reasoning` / `delta.reasoning_content` / `delta.reasoning_details` -> triggers `onStatus('reasoning')` on first reasoning delta, and passes **accumulated** string to `onReasoning(text)`. + - `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). diff --git a/src/LLMOrbiter.js b/src/LLMOrbiter.js index dc18037..1191e5e 100644 --- a/src/LLMOrbiter.js +++ b/src/LLMOrbiter.js @@ -183,17 +183,16 @@ class LLMOrbiter { tools: toolSpecs.length ? toolSpecs : undefined, signal: runSignal, extraBody, - onStatus: (s) => call(onStatus, s), + onStatus: (...args) => call(onStatus, ...args), onContent: (...args) => call(onContent, ...args), 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); if (completion.usage) { rawUsages.push(completion.usage); } - await call(onUsage, callUsage); const toolCalls = completion.tool_calls; const content = completion.content; @@ -202,6 +201,7 @@ class LLMOrbiter { // When there is no response (neither tool calls nor content), the loop shall continue. if (!hasToolCalls && !hasContent) { + await call(onUsage, callUsage); continue; } @@ -217,6 +217,7 @@ class LLMOrbiter { // No tool calls → done if (!hasToolCalls) { finalContent = content; + await call(onUsage, callUsage); break; } @@ -318,6 +319,8 @@ class LLMOrbiter { recordToolResult(callItem, result); } + await call(onUsage, callUsage); + if (hasDirectReturn) break; if (pendingAppendedMessages.length > 0) { diff --git a/src/providers/OpenRouterProvider.js b/src/providers/OpenRouterProvider.js index 5743005..682e465 100644 --- a/src/providers/OpenRouterProvider.js +++ b/src/providers/OpenRouterProvider.js @@ -31,7 +31,7 @@ class OpenRouterProvider { 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.apiUrl = apiUrl || process.env.OPENROUTER_API_URL || DEFAULT_API_URL; this.siteUrl = siteUrl || ''; this.siteName = siteName || ''; this.sessionId = sessionId || PROCESS_SESSION_ID; @@ -48,8 +48,9 @@ class OpenRouterProvider { model, messages, reasoning: { - effort: 'minimal' + effort: 'max' }, + include: ["reasoning.encrypted_content"], stream: true, ...(effectiveSessionId ? { session_id: effectiveSessionId } : {}), ...extraBody, @@ -90,8 +91,12 @@ class OpenRouterProvider { const decoder = new TextDecoder(); let buffer = ''; const result = {}; - const state = { workingSent: false }; - let firstPayload = true; + const state = { + firstEventSent: false, + reasoningSent: false, + contentSent: false, + workingSent: false, + }; while (true) { const { done, value } = await reader.read(); @@ -103,20 +108,34 @@ class OpenRouterProvider { for (const line of lines) { 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(); if (payload === '[DONE]') continue; - if (firstPayload) { - firstPayload = false; - handlers.onStatus?.('streaming'); - } - 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; } @@ -138,6 +157,10 @@ class OpenRouterProvider { // Content if (choice.delta?.content) { + if (!state.contentSent) { + state.contentSent = true; + onStatus?.('content'); + } result.content = (result.content || '') + choice.delta.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) { result.reasoning = (result.reasoning || '') + reasoningText; onReasoning?.(result.reasoning); diff --git a/test/openrouter.test.js b/test/openrouter.test.js index 18d2823..4d4e688 100644 --- a/test/openrouter.test.js +++ b/test/openrouter.test.js @@ -87,3 +87,124 @@ test('chatCompletions sends session_id in JSON body and x-session-id in HTTP hea 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; + } +}); + + diff --git a/test/orbiter.test.js b/test/orbiter.test.js index 462a653..7f8f16a 100644 --- a/test/orbiter.test.js +++ b/test/orbiter.test.js @@ -102,3 +102,54 @@ test('LLMOrbiter finishes immediately in 1 iteration when content is returned wi assert.equal(result.iterations, 1); 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', + ]); +}); +