mirror of
https://github.com/Jeuners/agenttwo-tools.git
synced 2026-09-12 08:22:30 +02:00
feat: Statistik-Leiste mit Tokens, tok/s, TTFT und Kontext-Füllstand
Zeile über dem Composer mit den Messwerten der letzten Antwort plus Session-Summe. Die Zahlen sind gemessen, nicht geschätzt. Quellen: - Ollama: prompt_eval_count, eval_count, eval_duration aus dem Abschluss-Chunk; TTFT wird beim ersten Content- oder Thinking-Chunk gestoppt - OpenRouter: usage-Block, dafür stream_options.include_usage. Der Block kommt erst nach finish_reason, deshalb wird nur noch abgekürzt, wenn er schon da ist — sonst gingen die Tokenzahlen verloren - Kosten aus promptPrice/completionPrice der Modellliste Werkzeugrunden sind mehrere Modellaufrufe für eine sichtbare Antwort: Erzeugtes wird summiert, der Prompt-Stand ist der der letzten Runde, TTFT zählt nur die erste. Kontext-Füllstand gegen GET /api/ps statt gegen die deklarierte Länge des Modells. Das ist nicht dasselbe: qwen3.5 deklariert 262144, geladen läuft es mit 4096. Gegen die deklarierte Länge stünde der Balken bei 1 %, während vorne längst abgeschnitten wird. onStats darf asynchron sein und wird vor onDone abgewartet — der /api/ps-Lookup schob die Stats-Nachricht sonst hinter das done, und der Client verwarf sie. Während des Streamens zählt die Leiste eingehende Chunks als Näherung (mit ≈ markiert); gemessen liegt das bei Prosa innerhalb weniger Prozent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015AUP4R3rgq4XwVs4bVf7uh
This commit is contained in:
parent
dd0ee30165
commit
1297ffcc56
9 changed files with 455 additions and 8 deletions
|
|
@ -7,7 +7,7 @@ import { readFileSync } from "node:fs";
|
|||
import path from "node:path";
|
||||
import * as dbmod from "./db.js";
|
||||
import * as mem from "./memory.js";
|
||||
import { streamChat, type OllamaOptions } from "./ollama.js";
|
||||
import { streamChat, type ChatStats, type OllamaOptions } from "./ollama.js";
|
||||
import { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js";
|
||||
import {
|
||||
streamOpenRouter,
|
||||
|
|
@ -45,6 +45,37 @@ const CONFIRM_TIMEOUT_MS = 120_000;
|
|||
/** Chats pro Minute und Verbindung. Bremst Schleifen und OpenRouter-Kosten. */
|
||||
const CHAT_LIMIT_PER_MIN = 30;
|
||||
|
||||
/**
|
||||
* Effektive Kontextlänge eines geladenen Ollama-Modells.
|
||||
*
|
||||
* Nicht dasselbe wie die im Modell deklarierte Länge: qwen3.5 meldet 262144,
|
||||
* geladen läuft es je nach Ollama-Default aber mit 4096. Für einen ehrlichen
|
||||
* Füllstand zählt nur, womit das Modell tatsächlich läuft — und das steht in
|
||||
* /api/ps. Kurz gecacht, weil sich das nur beim Nachladen ändert.
|
||||
*/
|
||||
const CONTEXT_TTL_MS = 30_000;
|
||||
const contextCache = new Map<string, { value: number; at: number }>();
|
||||
|
||||
async function effectiveContextLength(model: string): Promise<number | undefined> {
|
||||
const hit = contextCache.get(model);
|
||||
if (hit && Date.now() - hit.at < CONTEXT_TTL_MS) return hit.value;
|
||||
try {
|
||||
const res = await fetch(`${OLLAMA_URL}/api/ps`);
|
||||
if (!res.ok) return hit?.value;
|
||||
const data = (await res.json()) as {
|
||||
models?: { name?: string; model?: string; context_length?: number }[];
|
||||
};
|
||||
const entry = (data.models ?? []).find(
|
||||
(m) => m.name === model || m.model === model,
|
||||
);
|
||||
if (!entry?.context_length) return hit?.value;
|
||||
contextCache.set(model, { value: entry.context_length, at: Date.now() });
|
||||
return entry.context_length;
|
||||
} catch {
|
||||
return hit?.value;
|
||||
}
|
||||
}
|
||||
|
||||
const DREAM_IDLE_MS = 180_000;
|
||||
const DREAM_BATCH = 10;
|
||||
const dreamTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
|
@ -542,6 +573,24 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
onToolConfirm(name: string, args: Record<string, unknown>) {
|
||||
return confirmTool(assistantRow.id, name, args);
|
||||
},
|
||||
async onStats(stats: ChatStats) {
|
||||
// Nur bei Ollama kennt der Server das echte Fenster; bei OpenRouter
|
||||
// steht die Kontextlänge in der Modellliste, die der Client schon hat.
|
||||
const contextLength =
|
||||
opts.provider === "ollama"
|
||||
? await effectiveContextLength(opts.model)
|
||||
: undefined;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "stats",
|
||||
messageId: assistantRow.id,
|
||||
model: opts.provider === "ollama" ? opts.model : opts.openrouterModel,
|
||||
provider: opts.provider,
|
||||
contextLength,
|
||||
...stats,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,24 @@ export interface OllamaOptions {
|
|||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Messwerte einer Antwort. Zahlen kommen aus dem Abschluss-Chunk von Ollama
|
||||
* bzw. dem usage-Block von OpenRouter — nicht geschätzt.
|
||||
*/
|
||||
export interface ChatStats {
|
||||
/** Tokens im Prompt der letzten Runde: das, was zuletzt im Kontext lag. */
|
||||
promptTokens: number;
|
||||
/** Erzeugte Tokens, über alle Werkzeugrunden summiert. */
|
||||
responseTokens: number;
|
||||
/** Bis zum ersten sichtbaren Token (Denken zählt mit). null, wenn keins kam. */
|
||||
ttftMs: number | null;
|
||||
/** Reine Generierungszeit, ohne Prompt-Auswertung und Modell-Laden. */
|
||||
evalMs: number;
|
||||
/** Wanduhr über alles, inklusive Werkzeuglaufzeit. */
|
||||
totalMs: number;
|
||||
rounds: number;
|
||||
}
|
||||
|
||||
export interface StreamCallbacks {
|
||||
onThinking(text: string): void;
|
||||
onToken(text: string): void;
|
||||
|
|
@ -29,6 +47,11 @@ export interface StreamCallbacks {
|
|||
* Fehlt der Rückkanal, lehnt `runTool` solche Werkzeuge ab.
|
||||
*/
|
||||
onToolConfirm?(name: string, args: Record<string, unknown>): Promise<boolean>;
|
||||
/**
|
||||
* Messwerte, sobald die Antwort steht. Darf asynchron sein — der Aufrufer
|
||||
* wartet ab, damit die Zahlen sicher vor `done` beim Client sind.
|
||||
*/
|
||||
onStats?(stats: ChatStats): void | Promise<void>;
|
||||
}
|
||||
|
||||
/** Muss zum OLLAMA_URL in index.ts passen — vorher war der Host hier hartkodiert. */
|
||||
|
|
@ -47,8 +70,22 @@ interface ChatChunk {
|
|||
};
|
||||
done?: boolean;
|
||||
error?: string;
|
||||
/** Nur im Abschluss-Chunk. Dauern in Nanosekunden. */
|
||||
prompt_eval_count?: number;
|
||||
eval_count?: number;
|
||||
eval_duration?: number;
|
||||
}
|
||||
|
||||
/** Rohwerte einer Runde, wie Ollama sie meldet. */
|
||||
interface RoundStats {
|
||||
promptTokens: number;
|
||||
responseTokens: number;
|
||||
evalMs: number;
|
||||
ttftMs: number | null;
|
||||
}
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
|
||||
export interface ChatMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
|
|
@ -95,7 +132,23 @@ async function streamOnce(
|
|||
opts: OllamaOptions,
|
||||
cb: StreamCallbacks,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ toolCalls: ToolCall[]; content: string }> {
|
||||
): Promise<{ toolCalls: ToolCall[]; content: string; stats: RoundStats }> {
|
||||
const startedAt = Date.now();
|
||||
let firstTokenAt: number | null = null;
|
||||
const stats: RoundStats = {
|
||||
promptTokens: 0,
|
||||
responseTokens: 0,
|
||||
evalMs: 0,
|
||||
ttftMs: null,
|
||||
};
|
||||
/** Übernimmt die Zahlen aus dem Abschluss-Chunk. */
|
||||
function collect(chunk: ChatChunk) {
|
||||
stats.promptTokens = chunk.prompt_eval_count ?? 0;
|
||||
stats.responseTokens = chunk.eval_count ?? 0;
|
||||
stats.evalMs = Math.round((chunk.eval_duration ?? 0) / NS_PER_MS);
|
||||
stats.ttftMs = firstTokenAt === null ? null : firstTokenAt - startedAt;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: opts.model,
|
||||
messages,
|
||||
|
|
@ -144,6 +197,9 @@ async function streamOnce(
|
|||
continue;
|
||||
}
|
||||
if (chunk.error) throw new Error(chunk.error);
|
||||
if (chunk.message?.thinking || chunk.message?.content) {
|
||||
firstTokenAt ??= Date.now();
|
||||
}
|
||||
if (chunk.message?.thinking) cb.onThinking(chunk.message.thinking);
|
||||
if (chunk.message?.content) {
|
||||
content += chunk.message.content;
|
||||
|
|
@ -155,10 +211,13 @@ async function streamOnce(
|
|||
toolCalls.push({ id: call.id, name, arguments: parseArgs(call.function?.arguments) });
|
||||
}
|
||||
}
|
||||
if (chunk.done) return { toolCalls, content };
|
||||
if (chunk.done) {
|
||||
collect(chunk);
|
||||
return { toolCalls, content, stats };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { toolCalls, content };
|
||||
return { toolCalls, content, stats };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -181,15 +240,40 @@ export async function streamChat(
|
|||
...history.map(toWire),
|
||||
];
|
||||
|
||||
const startedAt = Date.now();
|
||||
const total: ChatStats = {
|
||||
promptTokens: 0,
|
||||
responseTokens: 0,
|
||||
ttftMs: null,
|
||||
evalMs: 0,
|
||||
totalMs: 0,
|
||||
rounds: 0,
|
||||
};
|
||||
/**
|
||||
* Werkzeugrunden sind mehrere Ollama-Aufrufe für eine sichtbare Antwort:
|
||||
* Erzeugtes wird summiert, der Prompt-Stand ist der der letzten Runde
|
||||
* (die größte Belegung), TTFT zählt nur die erste Runde.
|
||||
*/
|
||||
function fold(round: RoundStats) {
|
||||
total.rounds++;
|
||||
total.promptTokens = round.promptTokens || total.promptTokens;
|
||||
total.responseTokens += round.responseTokens;
|
||||
total.evalMs += round.evalMs;
|
||||
total.ttftMs ??= round.ttftMs;
|
||||
total.totalMs = Date.now() - startedAt;
|
||||
}
|
||||
|
||||
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
|
||||
const lastRound = round === MAX_TOOL_ROUNDS;
|
||||
// In der letzten Runde ohne Werkzeuge fragen, damit eine Antwort entsteht
|
||||
// statt eines weiteren Aufrufwunsches.
|
||||
const roundOpts = lastRound ? { ...opts, tools: false } : opts;
|
||||
|
||||
const { toolCalls, content } = await streamOnce(messages, roundOpts, cb, signal);
|
||||
const { toolCalls, content, stats } = await streamOnce(messages, roundOpts, cb, signal);
|
||||
fold(stats);
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
await cb.onStats?.(total);
|
||||
cb.onDone();
|
||||
return;
|
||||
}
|
||||
|
|
@ -217,5 +301,6 @@ export async function streamChat(
|
|||
}
|
||||
}
|
||||
|
||||
await cb.onStats?.(total);
|
||||
cb.onDone();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { mimeFromBase64 } from "./images.js";
|
||||
import type { ChatStats } from "./ollama.js";
|
||||
|
||||
export interface OpenRouterOptions {
|
||||
model: string;
|
||||
|
|
@ -10,6 +11,8 @@ export interface StreamCallbacks {
|
|||
onThinking(text: string): void;
|
||||
onToken(text: string): void;
|
||||
onDone(): void;
|
||||
/** Messwerte, sobald die Antwort steht. Wird vor `done` abgewartet. */
|
||||
onStats?(stats: ChatStats): void | Promise<void>;
|
||||
}
|
||||
|
||||
export function getOpenRouterKey(): string | undefined {
|
||||
|
|
@ -17,7 +20,13 @@ export function getOpenRouterKey(): string | undefined {
|
|||
}
|
||||
|
||||
export async function listOpenRouterModels(): Promise<
|
||||
{ id: string; name: string; contextLength: number; promptPrice: number }[]
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
contextLength: number;
|
||||
promptPrice: number;
|
||||
completionPrice: number;
|
||||
}[]
|
||||
> {
|
||||
const res = await fetch("https://openrouter.ai/api/v1/models");
|
||||
if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`);
|
||||
|
|
@ -26,15 +35,17 @@ export async function listOpenRouterModels(): Promise<
|
|||
id: string;
|
||||
name: string;
|
||||
context_length: number;
|
||||
pricing: { prompt: string };
|
||||
pricing: { prompt: string; completion?: string };
|
||||
}[];
|
||||
};
|
||||
// Preise kommen pro Token; die Anzeige rechnet in Preis je 1 Mio. Tokens.
|
||||
return data.data
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
contextLength: m.context_length,
|
||||
promptPrice: Number(m.pricing?.prompt ?? 0) * 1_000_000,
|
||||
completionPrice: Number(m.pricing?.completion ?? 0) * 1_000_000,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
|
@ -76,6 +87,8 @@ export async function streamOpenRouter(
|
|||
body: JSON.stringify({
|
||||
model: opts.model,
|
||||
stream: true,
|
||||
// Ohne das kommt kein usage-Block und die Tokenzahlen blieben leer.
|
||||
stream_options: { include_usage: true },
|
||||
messages: [
|
||||
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
|
||||
...history.map(toOpenAIMessage),
|
||||
|
|
@ -95,6 +108,27 @@ export async function streamOpenRouter(
|
|||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
const startedAt = Date.now();
|
||||
let firstTokenAt: number | null = null;
|
||||
const stats: ChatStats = {
|
||||
promptTokens: 0,
|
||||
responseTokens: 0,
|
||||
ttftMs: null,
|
||||
evalMs: 0,
|
||||
totalMs: 0,
|
||||
rounds: 1,
|
||||
};
|
||||
/**
|
||||
* OpenRouter meldet keine reine Generierungszeit. Als evalMs zählt deshalb
|
||||
* die Zeit ab dem ersten Token — das ist die Spanne, über die tok/s
|
||||
* überhaupt aussagekräftig ist.
|
||||
*/
|
||||
function finish(): void | Promise<void> {
|
||||
stats.totalMs = Date.now() - startedAt;
|
||||
stats.evalMs = firstTokenAt === null ? 0 : Date.now() - firstTokenAt;
|
||||
return cb.onStats?.(stats);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
|
@ -107,6 +141,7 @@ export async function streamOpenRouter(
|
|||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (payload === "[DONE]") {
|
||||
await finish();
|
||||
cb.onDone();
|
||||
return;
|
||||
}
|
||||
|
|
@ -116,6 +151,7 @@ export async function streamOpenRouter(
|
|||
finish_reason?: string | null;
|
||||
}[];
|
||||
error?: { message?: string };
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||
};
|
||||
try {
|
||||
chunk = JSON.parse(payload);
|
||||
|
|
@ -123,14 +159,24 @@ export async function streamOpenRouter(
|
|||
continue;
|
||||
}
|
||||
if (chunk.error) throw new Error(chunk.error.message ?? "OpenRouter error");
|
||||
if (chunk.usage) {
|
||||
stats.promptTokens = chunk.usage.prompt_tokens ?? 0;
|
||||
stats.responseTokens = chunk.usage.completion_tokens ?? 0;
|
||||
}
|
||||
const delta = chunk.choices?.[0]?.delta;
|
||||
if (delta?.reasoning || delta?.content) firstTokenAt ??= Date.now();
|
||||
if (delta?.reasoning) cb.onThinking(delta.reasoning);
|
||||
if (delta?.content) cb.onToken(delta.content);
|
||||
if (chunk.choices?.[0]?.finish_reason && !delta?.content) {
|
||||
// Der usage-Block kommt erst nach dem finish_reason-Chunk. Nur wenn er
|
||||
// schon da ist, darf hier abgekürzt werden — sonst bis [DONE] weiterlesen
|
||||
// und die Tokenzahlen mitnehmen.
|
||||
if (chunk.choices?.[0]?.finish_reason && !delta?.content && stats.promptTokens) {
|
||||
await finish();
|
||||
cb.onDone();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await finish();
|
||||
cb.onDone();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue