mirror of
https://github.com/Jeuners/agenttwo-tools.git
synced 2026-09-12 16:32:31 +02:00
feat: Werkzeug-Bestätigung, DNS-Pinning, Statistik-Leiste, Produktionsbetrieb (#1)
Ergebnis eines Security-Reviews des Werkzeug-Pfads plus die daraus hervorgegangenen Verbesserungen. Sicherheit: - runTool wertet requiresConfirmation aus; Rückfrage über den WebSocket mit vollständigen Argumenten, Antwort allow/always/deny. Ohne Rückkanal gilt abgelehnt. read_webpage und remember sind bestätigungspflichtig - remember pinnt nicht mehr automatisch, Modell-Anker verfallen normal - SSRF-Guard mit gepinnter DNS-Auflösung (lookup-Hook statt fetch): geprüft wird genau die Adresse, die auch verbunden wird — schließt DNS-Rebinding - ctx.signal kombiniert Abbruch und Zeitlimit bis in den Netzwerkabruf - activeAborts als Set, Chat-Rate-Limit je Verbindung, Bucket-Cleanup Statistik-Leiste über dem Composer: Tokens, tok/s, TTFT, Gesamtzeit und Kontext-Füllstand, dazu die Session-Summe. Zahlen sind gemessen — bei Ollama aus dem Abschluss-Chunk, bei OpenRouter aus dem usage-Block. Der Füllstand rechnet gegen das tatsächlich genutzte Fenster aus /api/ps, nicht gegen die deklarierte Länge des Modells. Produktionsbetrieb: der Server liefert web/dist jetzt mit aus, npm start genügt. /api und /ws behalten Vorrang. README überarbeitet, Fork-Bezug entfernt, zerbrochene Konfigurationstabelle repariert, API-Referenz ergänzt. Nicht umgesetzt: Auth-/Origin-Härtung — der Server läuft bewusst lokal.
This commit is contained in:
parent
878acd9933
commit
8a489dfc5c
22 changed files with 1495 additions and 347 deletions
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -65,6 +76,10 @@ export async function streamOpenRouter(
|
|||
cb: StreamCallbacks,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
// Vor dem fetch: TTFT soll die Wartezeit auf den Anbieter enthalten, nicht
|
||||
// erst ab dem Eintreffen der Antwort-Header zählen (so misst es auch Ollama).
|
||||
const startedAt = Date.now();
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -76,6 +91,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 +112,27 @@ export async function streamOpenRouter(
|
|||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
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.ttftMs = firstTokenAt === null ? null : firstTokenAt - 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 +145,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 +155,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 +163,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