This commit is contained in:
seb
2026-10-05 22:27:23 +02:00
parent af0ef29e22
commit 18699eb40b
7 changed files with 61 additions and 50 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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}`);

View File

@@ -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:

View File

@@ -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',

View File

@@ -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() {

View File

@@ -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: [