From 18699eb40b749c938d78c59a4cce2d7820bb9ea3 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 5 Oct 2026 22:27:23 +0200 Subject: [PATCH] u --- .env.example | 2 + .../routes/modelconversation/_id/stream.md | 12 ++-- src/api/routes/deliveryParse.js | 2 +- src/api/routes/modelconversation.js | 26 +++++--- src/config/env.js | 1 + src/services/conversationManager.js | 66 ++++++++++--------- src/tools/analyze-image.js | 2 +- 7 files changed, 61 insertions(+), 50 deletions(-) diff --git a/.env.example b/.env.example index 18d2d37..795c73d 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,8 @@ KAT_LISTS=0 # Tax zone name for VAT rates (tSteuerzone.cName) STEUER_ZONE=Zone-EU +# OpenRouter (leave OPENROUTER_API_URL unset for default OpenRouter endpoint) +# OPENROUTER_API_URL=http://127.0.0.1:8787/api/v1/chat/completions OPENROUTER_FREE_KEY=sk-or-v1-3d3c4e OPENROUTER_FREE_MODEL=openrouter/free OPENROUTER_PAID_MODEL=openai/gpt-6-luna:floor diff --git a/docs/api/routes/modelconversation/_id/stream.md b/docs/api/routes/modelconversation/_id/stream.md index e79e0e2..fdad731 100644 --- a/docs/api/routes/modelconversation/_id/stream.md +++ b/docs/api/routes/modelconversation/_id/stream.md @@ -5,11 +5,11 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). Stream conversation events (SSE) Opens a Server-Sent Events stream for the specified conversation. -Streams `message` events (persisted user/assistant messages), live `delta` -text chunks of the model answer, and `state` events (`thinking`, `working`, -`done`). The SSE states are not stored; the database only knows `idle`/`busy`. -`done` carries `usage` (`input`, `cached`, `output`, `cost`) on success or -`error` (string) if the run failed. +Streams live `delta` text chunks of the model answer, `usage` stats per turn, +and `state` events (`thinking`, `connected`, `firstevent`, `reasoning`, +`content`, `working`, `toolcall`, `done`). The SSE states are not stored; +the database only knows `idle`/`busy`. +`done` indicates generation has finished, carrying `error` (string) only if the run failed. ## Path parameters @@ -20,7 +20,7 @@ text chunks of the model answer, and `state` events (`thinking`, `working`, ## Response `200` -SSE stream (`text/event-stream`). Emits `state`, `delta`, and `message` events. +SSE stream (`text/event-stream`). Emits `state`, `delta`, and `usage` events. ## Errors diff --git a/src/api/routes/deliveryParse.js b/src/api/routes/deliveryParse.js index 7d03c83..5fb62e3 100644 --- a/src/api/routes/deliveryParse.js +++ b/src/api/routes/deliveryParse.js @@ -296,7 +296,7 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) { // ── LLMOrbiter setup ───────────────────────────────────────────── const model = await resolveDefaultModel(req.user.id); - const provider = new OpenRouterProvider({ apiKey }); + const provider = new OpenRouterProvider({ apiKey, apiUrl: env.openrouterApiUrl }); const orbiter = new LLMOrbiter(provider, { onStatus: (status) => { console.log(`[llmorbiter] Status: ${status}`); diff --git a/src/api/routes/modelconversation.js b/src/api/routes/modelconversation.js index 4ce9009..081d9b9 100644 --- a/src/api/routes/modelconversation.js +++ b/src/api/routes/modelconversation.js @@ -313,11 +313,11 @@ export function register(app, basePath = '') { * summary: Stream conversation events (SSE) * description: | * Opens a Server-Sent Events stream for the specified conversation. - * Streams `message` events (persisted user/assistant messages), live `delta` - * text chunks of the model answer, and `state` events (`thinking`, `working`, - * `done`). The SSE states are not stored; the database only knows `idle`/`busy`. - * `done` carries `usage` (`input`, `cached`, `output`, `cost`) on success or - * `error` (string) if the run failed. + * Streams live `delta` text chunks of the model answer, `usage` stats per turn, + * and `state` events (`thinking`, `connected`, `firstevent`, `reasoning`, + * `content`, `working`, `toolcall`, `done`). The SSE states are not stored; + * the database only knows `idle`/`busy`. + * `done` indicates generation has finished, carrying `error` (string) only if the run failed. * security: * - cookieAuth: [] * parameters: @@ -330,7 +330,7 @@ export function register(app, basePath = '') { * responses: * 200: * description: | - * SSE stream (`text/event-stream`). Emits `state`, `delta`, and `message` events. + * SSE stream (`text/event-stream`). Emits `state`, `delta`, and `usage` events. * content: * text/event-stream: * schema: @@ -340,16 +340,22 @@ export function register(app, basePath = '') { * data: {"state":"thinking"} * * event: state - * data: {"state":"working"} + * data: {"state":"connected"} + * + * event: state + * data: {"state":"firstevent"} + * + * event: state + * data: {"state":"content"} * * event: delta * data: {"delta":"Hel","text":"Hel"} * - * event: message - * data: {"id":2,"conversation_id":1,"created_at":"...","role":"assistant","message":"Hello there"} + * event: usage + * data: {"input":20,"cached":0,"output":3,"cost":0.0001} * * event: state - * data: {"state":"done","usage":{"input":20,"cached":0,"output":3,"cost":0.0001}} + * data: {"state":"done"} * 400: * description: Invalid ID or no company selected * content: diff --git a/src/config/env.js b/src/config/env.js index 259dae7..0488770 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -26,6 +26,7 @@ export default { implicitCreate: process.env.IMPLICIT_CREATE === 'true', jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production', dbProvider: process.env.DB_PROVIDER || (process.env.SQLITE_PATH ? 'sqlite' : 'mssql'), + openrouterApiUrl: process.env.OPENROUTER_API_URL, openrouterFreeKey: process.env.OPENROUTER_FREE_KEY, openrouterFreeModel: process.env.OPENROUTER_FREE_MODEL || 'openrouter/free', openrouterPaidModel: process.env.OPENROUTER_PAID_MODEL || 'openai/gpt-6-luna:floor', diff --git a/src/services/conversationManager.js b/src/services/conversationManager.js index efbabfe..fadaa81 100644 --- a/src/services/conversationManager.js +++ b/src/services/conversationManager.js @@ -303,7 +303,6 @@ export class ConversationManager { throw err; } - this.broadcast('message', userMsg); this.broadcast('state', { state: 'thinking' }); emitCompanyEvent('modelconversation', { companyId: this.companyId }); @@ -343,7 +342,6 @@ export class ConversationManager { throw err; } - this.broadcast('message', toolMsg); this.broadcast('state', { state: 'working' }); emitCompanyEvent('modelconversation', { companyId: this.companyId }); @@ -361,7 +359,6 @@ export class ConversationManager { * @param {number} googleAuthId */ async runGeneration(googleAuthId) { - let usage = null; let error = null; try { @@ -397,33 +394,23 @@ export class ConversationManager { ...history.map(toLlmMessage), ]; - let working = false; let text = ''; let currentReasoning = ''; let savedToolCallsTurn = null; - const markWorking = () => { - if (working) return; - working = true; - this.broadcast('state', { state: 'working' }); - }; - - this.orbiter = new LLMOrbiter(new OpenRouterProvider({ apiKey }), { - onStatus: (status) => { - if (status === 'working') markWorking(); + this.orbiter = new LLMOrbiter(new OpenRouterProvider({ apiKey, apiUrl: env.openrouterApiUrl }), { + onStatus: (status, meta) => { + this.broadcast('state', { state: status, ...(meta || {}) }); }, onReasoning: (reasoning) => { - markWorking(); currentReasoning = reasoning; this.broadcast('reasoning', { text: reasoning }); }, onContent: (chunk) => { - markWorking(); text += chunk; this.broadcast('delta', { delta: chunk, text }); }, onToolCall: async ({ name, args, id, callItem, tool_calls, reasoning_details, reasoning, content }) => { - markWorking(); try { const effectiveToolCalls = tool_calls || (callItem ? [callItem] : [{ id: id || randomUUID(), @@ -449,12 +436,11 @@ export class ConversationManager { ...(effectiveReasoning ? { reasoning: effectiveReasoning } : {}), }; - const callMsg = await db.addMessage(googleAuthId, this.conversationId, { + await db.addMessage(googleAuthId, this.conversationId, { role: 'assistant', message: JSON.stringify(assistantPayload), }); text = ''; - this.broadcast('message', callMsg); emitCompanyEvent('modelconversation', { companyId: this.companyId }); } } catch (err) { @@ -462,15 +448,23 @@ export class ConversationManager { } }, onToolResult: async ({ name, result, id, callItem }) => { + const rawArgs = callItem?.function?.arguments || ''; + this.broadcast('state', { + state: 'toolcall', + id: id || callItem?.id || '', + name, + arguments: rawArgs, + content: rawArgs, + result, + }); try { - const resMsg = await db.addMessage(googleAuthId, this.conversationId, { + await db.addMessage(googleAuthId, this.conversationId, { role: 'tool', message: JSON.stringify({ content: typeof result === 'string' ? result : JSON.stringify(result), tool_call_id: id || callItem?.id || '', }), }); - this.broadcast('message', resMsg); emitCompanyEvent('modelconversation', { companyId: this.companyId }); } catch (err) { console.error(`[ConversationManager ${this.conversationId}] failed to save tool result message:`, err); @@ -478,21 +472,37 @@ export class ConversationManager { }, onToolError: async ({ name, error, id, callItem }) => { console.error(`[ConversationManager ${this.conversationId}] Tool "${name}" errored:`, error); + const rawArgs = callItem?.function?.arguments || ''; + this.broadcast('state', { + state: 'toolcall', + id: id || callItem?.id || '', + name, + arguments: rawArgs, + content: rawArgs, + error: String(error), + }); try { - const errMsg = await db.addMessage(googleAuthId, this.conversationId, { + await db.addMessage(googleAuthId, this.conversationId, { role: 'tool', message: JSON.stringify({ content: `Tool error [${name}]: ${error}`, tool_call_id: id || callItem?.id || '', }), }); - this.broadcast('message', errMsg); emitCompanyEvent('modelconversation', { companyId: this.companyId }); } catch (err) { console.error(`[ConversationManager ${this.conversationId}] failed to save tool error message:`, err); } }, onUsage: (u) => { + if (u) { + this.broadcast('usage', { + input: u.input ?? 0, + cached: u.cached ?? 0, + output: u.output ?? 0, + ...(u.cost != null ? { cost: u.cost } : {}), + }); + } // A call that consumed 0 input tokens AND produced 0 output means the // provider returned nothing at all — not a real generation. LLMOrbiter // treats "no content and no tool calls" as "keep looping", so several of @@ -577,7 +587,7 @@ export class ConversationManager { } if (content || finalReasoningDetails || finalReasoning) { - const assistantMsg = await db.addMessage(googleAuthId, this.conversationId, { + await db.addMessage(googleAuthId, this.conversationId, { message: JSON.stringify({ content: content || '', ...(finalReasoningDetails ? { reasoning_details: finalReasoningDetails } : {}), @@ -585,15 +595,7 @@ export class ConversationManager { }), role: 'assistant', }); - this.broadcast('message', assistantMsg); } - - usage = { - input: result.usage.input, - cached: result.usage.cached, - output: result.usage.output, - cost: result.usage.cost, - }; } catch (err) { if (this.destroyed) return; error = err.message; @@ -609,7 +611,7 @@ export class ConversationManager { console.error(`[ConversationManager ${this.conversationId}] failed to reset state:`, err); } emitCompanyEvent('modelconversation', { companyId: this.companyId }); - this.broadcast('state', error ? { state: 'done', error } : { state: 'done', usage }); + this.broadcast('state', error ? { state: 'done', error } : { state: 'done' }); } destroy() { diff --git a/src/tools/analyze-image.js b/src/tools/analyze-image.js index b23d7e3..8960d00 100644 --- a/src/tools/analyze-image.js +++ b/src/tools/analyze-image.js @@ -373,7 +373,7 @@ export function createAnalyzeImageTool({ googleAuthId, model }) { : question || 'Describe this image, judge its visual quality, flag any watermark, text overlay or defect, and say whether it is usable as a product or category image.'; const apiKey = await resolveOpenRouterKey(googleAuthId); - const provider = new OpenRouterProvider({ apiKey }); + const provider = new OpenRouterProvider({ apiKey, apiUrl: env.openrouterApiUrl }); const response = await provider.chatCompletions({ model, messages: [