From 4a08e7f20b5ec5095f81260206ab65be6ad1c757 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 5 Oct 2026 11:29:41 +0200 Subject: [PATCH] u --- .env.example | 2 ++ src/api/routes/deliveryParse.js | 2 +- src/api/routes/modelconversation.js | 15 ++++++++- src/config/env.js | 4 +++ src/services/conversationManager.js | 52 +++++++++++++++++++++++++++-- todo.md | 8 +---- 6 files changed, 72 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index b8b75bd..bef201e 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,5 @@ KAT_LISTS=0 STEUER_ZONE=Zone-EU OPENROUTER_FREE_KEY=sk-or-v1-3d3c4e +# Default model for agent conversations (optional, defaults to 'openrouter/free') +# OPENROUTER_MODEL=openrouter/free diff --git a/src/api/routes/deliveryParse.js b/src/api/routes/deliveryParse.js index b424dc7..3d5f93a 100644 --- a/src/api/routes/deliveryParse.js +++ b/src/api/routes/deliveryParse.js @@ -294,7 +294,7 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) { }); // ── LLMOrbiter setup ───────────────────────────────────────────── - const model = 'stealth/space-bunny-alpha';//'~openai/gpt-luna-latest:floor'; + const model = env.defaultModel; const provider = new OpenRouterProvider({ apiKey }); const orbiter = new LLMOrbiter(provider, { onStatus: (status) => { diff --git a/src/api/routes/modelconversation.js b/src/api/routes/modelconversation.js index 24f7aac..25e95cc 100644 --- a/src/api/routes/modelconversation.js +++ b/src/api/routes/modelconversation.js @@ -1,4 +1,9 @@ import * as db from '../../db/modelconversation.js'; +import env from '../../config/env.js'; + +// Model ids that are known to return empty responses from OpenRouter. Requests naming one of +// these are transparently rerouted to `env.defaultModel` instead of failing at generation time. +const RETIRED_MODELS = new Set(['stealth/space-bunny-alpha']); import { emit } from '../sse.js'; import { getOrCreateManager, removeManager } from '../../services/conversationManager.js'; @@ -145,8 +150,16 @@ export function register(app, basePath = '') { const { provider, model } = req.body ?? {}; + // Clients may still send the retired `stealth/space-bunny-alpha` id, which now returns + // empty responses from the provider. Map it onto the configured default rather than + // ignoring the client's choice entirely, so an explicit model selection still works. + const requestedModel = typeof model === 'string' ? model.trim() : ''; + const effectiveModel = RETIRED_MODELS.has(requestedModel) || !requestedModel + ? env.defaultModel + : requestedModel; + try { - const conversation = await db.create(req.user.id, { provider, model }); + const conversation = await db.create(req.user.id, { provider, model: effectiveModel }); emit('modelconversation', { companyId: selected.companyId }); res.status(201).json({ conversation }); } catch (error) { diff --git a/src/config/env.js b/src/config/env.js index f676e06..3baff0b 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -28,6 +28,10 @@ export default { dbProvider: process.env.DB_PROVIDER || (process.env.SQLITE_PATH ? 'sqlite' : 'mssql'), openrouterFreeKey: process.env.OPENROUTER_FREE_KEY, serperApiKey: process.env.SERPER_API_KEY, + // Default model for agent conversations. `openrouter/free` routes to whatever free model + // OpenRouter currently offers, which avoids pinning to a specific upstream id that can be + // withdrawn or become unavailable. A conversation's stored model still wins when set. + defaultModel: process.env.OPENROUTER_MODEL || 'openrouter/free', sqlite: { filename: process.env.SQLITE_PATH || process.env.SQLITE_FILENAME || resolve(__dirname, '../../data/dash.db'), }, diff --git a/src/services/conversationManager.js b/src/services/conversationManager.js index de7af99..a724096 100644 --- a/src/services/conversationManager.js +++ b/src/services/conversationManager.js @@ -345,6 +345,12 @@ export class ConversationManager { if (conversation.provider !== 'openrouter') { throw new Error(`Unsupported provider: ${conversation.provider}`); } + // Fall back to the configured default when the stored model is missing or blank, so + // an old or malformed conversation row cannot send an empty `model` and make the + // provider return an empty response. + const model = (typeof conversation.model === 'string' && conversation.model.trim()) + ? conversation.model.trim() + : env.defaultModel; const apiKey = await resolveOpenRouterKey(googleAuthId); // Transcript comes from the database (includes user, assistant, tool, reasoning). @@ -459,16 +465,37 @@ export class ConversationManager { } }, onUsage: (u) => { + // 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 + // these in a row burn iterations for nothing. Flag them loudly so the + // cause is visible instead of showing up as a silent weird loop. + if (u.input === 0 && u.output === 0) { + console.warn( + `[conversation ${this.conversationId}] EMPTY LLM RESPONSE ` + + `(iteration reported no usage at all). The provider returned no content ` + + `and no tool calls, so the orchestrator will retry. ` + + `If this repeats, the upstream model/API is failing — check the model id ` + + `and the key.` + ); + } console.log( `[conversation ${this.conversationId}] usage: input=${u.input}, cached=${u.cached}, output=${u.output}` + (u.cost != null ? `, cost=$${u.cost.toFixed(4)}` : '') ); }, + onLLMCall: (call) => { + console.log( + `[conversation ${this.conversationId}] LLM call #${call.iteration}: ` + + `model=${call.model}, messages=${call.messageCount}, flow=${call.messages}, ` + + `promptChars=${call.promptSize}` + ); + }, }); const result = await this.orbiter.run({ messages, - model: conversation.model, + model, tools: [ createDashbackRequestTool({ googleAuthId }), createAskUserTool(), @@ -481,7 +508,7 @@ export class ConversationManager { createPatchTool({ googleAuthId }), createRmTool({ googleAuthId }), createSearchImagesTool({ googleAuthId }), - createAnalyzeImageTool({ googleAuthId, model: conversation.model }), + createAnalyzeImageTool({ googleAuthId, model }), createResetVfsTool({ googleAuthId }), createApiSuggestionsOrIssuesTool({ googleAuthId, companyId: this.companyId }), ], @@ -489,6 +516,17 @@ export class ConversationManager { if (this.destroyed) return; + // Log the shape of every run, including degenerate ones. When nothing comes back + // there is nothing persisted and the UI just sits there — this line is the only + // clue, so make it explicit about which field was empty. + console.log( + `[conversation ${this.conversationId}] run finished: ` + + `iterations=${result?.iterations}, ` + + `content=${result?.content ? `${result.content.length} chars` : 'EMPTY'}, ` + + `toolCalls=${result?.toolResults ? Object.keys(result.toolResults).length : 0}, ` + + `directReturn=${result?.directReturn === true}` + ); + // Direct return (e.g. from ask_user) halts without adding an additional assistant message. if (result?.directReturn) { return; @@ -500,6 +538,16 @@ export class ConversationManager { : undefined; const finalReasoning = result.reasoning || currentReasoning.trim() || undefined; + // Nothing came back at all: no content, no reasoning. Surface it to the user rather + // than silently returning to `idle` with an empty turn, which reads as a hang. + if (!content && !finalReasoningDetails && !finalReasoning) { + console.error( + `[conversation ${this.conversationId}] generation produced no content after ` + + `${result?.iterations ?? 0} iteration(s). The model returned neither text nor tool calls.` + ); + error = 'The model returned an empty response. Please try again.'; + } + if (content || finalReasoningDetails || finalReasoning) { const assistantMsg = await db.addMessage(googleAuthId, this.conversationId, { message: JSON.stringify({ diff --git a/todo.md b/todo.md index 45d432f..48a7a1c 100644 --- a/todo.md +++ b/todo.md @@ -1,15 +1,9 @@ -Fehler gefunden: Verletzungen der Eindeutigkeit werden als HTTP-500-Fehler weitergegeben -Konsistent und reproduzierbar an vier Endpunkten – ein doppelter Name/Wert trifft auf eine eindeutige Datenbankbeschränkung und wird als Fehler 500 {"error":"Internal server error"}anstelle eines 4xx-Fehlers angezeigt: -POST /attributename/{id}/valuemit einem bestehenden Wert → 500 -POST /attributenamemit bestehendem Namen → 500 -POST /propertynamemit bereits existierendem Namen ( Notiz) → 500 -Nichts wird gespeichert (verifiziert), daher ist es zwar kosmetisch, aber fehleranfällig – dennoch ist es die einzige Klasse, die interne Informationen preisgibt und die Fehlertoleranz erhöht. Bemerkenswerterweise POST /categoryfunktioniert es auch mit einem doppelten Namen (201), das Verhalten ist also auch zwischen den Modulen inkonsistent . Beobachtungen, keine Fehler PUT /artificlelist/ optionslistDoppelte Einträge ablehnen statt zusammenführen – vertretbar, aber nicht dokumentiert. Lange Werte werden von der Datenbank stillschweigend abgeschnitten, anstatt validiert zu werden (5000 Zeichen → 201 mit einer abgeschnittenen Zeile). -POST /article/{sku}/childrenDie Dokumentation beschreibt ein optionales Element, skudas von der API jedoch immer abgelehnt wird sku is not allowed.... Der angegebene Pfad ist nicht funktionsfähig – um einen bestehenden Artikel einzubinden, ist eine direkte Verknüpfung erforderlich POST /article/{sku}/parent, die in der Dokumentation nicht beschrieben wird. Eine Korrektur der Dokumentation oder eine zusätzliche Route wäre wünschenswert. +POST /article/{sku}/childrenDie Dokumentation beschreibt ein optionales Element, skudas von der API jedoch immer abgelehnt wird sku is not allowed.... Der angegebene Pfad ist nicht funktionsfähig – um einen bestehenden Artikel einzubinden, ist eine direkte Verknüpfung erforderlich POST /article/{sku}/father, die in der Dokumentation nicht beschrieben wird. Eine Korrektur der Dokumentation oder eine zusätzliche Route wäre wünschenswert. ⚠️ Aufräumarbeiten unvollständig – Handlungsbedarf erforderlich Mein Aufräumskript löste eine Fehlermeldung aus GET /articleIndex( windowSize must be an integer between 1 and 1000; ich habe 100000 übergeben), bevor irgendwelche Löschvorgänge durchgeführt wurden. Es wurde nichts entfernt. Zurück blieb: