Files
nexus-browser-pilot-extension/src/background/llm.ts
T
coreandClaude Opus 5 f943261c3c feat: Gemini bridge, browser extension hardening, shared memory
Nexus gateway (measured 2026-08-07, not assumed):
- Gemini speaks Google-GenAI (/v1beta/models/{id}:streamGenerateContent, header
  api-key), NOT the Azure-OpenAI path — that returned 404 "no Route matched" and
  was the cause of the reported failures. New agent/gemini_bridge.py translates
  Bedrock Converse <-> Gemini in both directions.
- Only gemini-3.6-flash is subscribed; 2.5-flash/2.5-pro/3.1-flash-lite give 403,
  every other name 404. Catalog corrected.
- Four Gemini rules, each previously an HTTP 400, now covered by tests:
  thought signatures are mandatory, they belong to the TURN (not the individual
  call), functionResponse turns must be homogeneous, arrays need `items`.
- Prompt caching is NOT available: cachePoint is accepted and ignored.

System prompt:
- Was an f-string; a code sample containing braces broke build_system_prompt at
  request time (CLI and web both 500, import stayed green). Now a plain template
  with __TOKEN__ placeholders. Regression guards in tests/test_system_prompt.py.

CLI:
- `agent resume` now prints the stored transcript. The history was always loaded
  into the model context, only the terminal stayed empty.

Memory (new, all three surfaces):
- agent/memory.py stores notes about the user in one local file, written
  atomically; memory.cnull.net remains an optional mirror that can never fail a
  write. Tools memory_save/search/forget, injected into the prompt with a budget.
  HTTP surface /api/memory for the extension.

Browser extension (agent/extension, first commit of the source):
- driveMode 'direct' talks to Nexus without the Python broker: Claude via
  Bedrock converse, GPT via Azure-OpenAI, Gemini via Google-GenAI.
- browser_type no longer guesses the focused element — that wrote whole mails
  into Outlook's subject line. Read-back now reports where the text actually
  landed, so a mis-target is visible instead of silent.
- aria-labelledby is resolved across all ids (it is a list); contenteditable is
  interactive and marked editable. Without this, subject and message body look
  identical to the model.
- Hard block against sending mail, independent of riskMode.
- Runs survive the panel: events are buffered and replayed by sequence number.
- Image input (paste, file, drag&drop), on-page glow/spotlight, memory tools.

Cost: fixed tokens per round 11434 -> 6540 (-43%) by trimming tool schemas,
dropping gateway docs from the browser prompt and sending site knowledge only
where it applies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:31:26 +02:00

1230 lines
43 KiB
TypeScript

/**
* Provider-Adapter für den eingebauten Agent-Loop (driveMode 'direct').
*
* Drei Wege zum Modell, eine gemeinsame Oberfläche:
* 'anthropic' → POST {baseUrl}/messages (SSE, content_block_* / message_delta)
* 'openai' → POST {baseUrl}/chat/completions (SSE, choices[].delta)
* 'bedrock' → POST {baseUrl}/model/{id}/converse (JSON, nur Nexus-Claude)
*
* Beim Provider 'nexus' entscheidet das MODELL über den Weg, nicht der Provider:
* Claude läuft dort über Bedrock-Converse, GPT über den Azure-OpenAI-Pfad
* desselben Hosts. `wireOf()` ist die einzige Stelle, die das weiß.
*
* Der Konversationsverlauf wird im WIRE-Format des jeweiligen Providers gehalten.
* Ein Providerwechsel macht den Verlauf ungültig — agent.ts setzt ihn dann zurück.
*
* BILDER: Screenshots gehören als echter Bildblock in den Kontext, niemals als
* base64-Text. Das ist der Punkt, an dem naive Implementierungen scheitern:
* einmal als Text eingebettet, frisst ein einziger Screenshot fünfstellig Tokens
* und das Modell "sieht" trotzdem nichts.
*/
import type { ExtensionConfig } from '../shared/config';
import { NEXUS_API_VERSION, nexusWireFor } from '../shared/config';
// ─── Protokollwahl ────────────────────────────────────────────────────────────
export type Wire = 'anthropic' | 'openai' | 'bedrock' | 'gemini';
/**
* Welches Draht-Protokoll für diese Konfiguration gilt.
*
* Der VERLAUF wird immer in Anthropic-Blockform gehalten — auch für 'bedrock'.
* Umgebaut wird erst unmittelbar vor dem Request. Sonst müsste jeder
* Modellwechsel innerhalb von Nexus den Verlauf wegwerfen.
*/
export function wireOf(cfg: ExtensionConfig): Wire {
if (cfg.provider === 'nexus') return nexusWireFor(cfg.model);
if (cfg.provider === 'openai' || cfg.provider === 'requesty') return 'openai';
return 'anthropic';
}
/** Nexus hat einen eigenen Schlüssel, damit Preset-Wechsel keinen Key löschen. */
function authKey(cfg: ExtensionConfig): string {
return cfg.provider === 'nexus' ? (cfg.nexusApiKey || cfg.apiKey) : cfg.apiKey;
}
// ─── Öffentliche Typen ────────────────────────────────────────────────────────
export interface LlmToolCall {
id: string;
name: string;
input: Record<string, unknown>;
}
export interface LlmTurn {
text: string;
toolCalls: LlmToolCall[];
stopReason: string;
usage?: { inputTokens: number; outputTokens: number };
/**
* Rohe Content-Blöcke des Providers, in Originalreihenfolge.
* Bei Anthropic stecken hier auch thinking-Blöcke mit ihrer Signatur drin.
* Die MÜSSEN unverändert zurückgespielt werden, sonst lehnt die API den
* nächsten Request mit tool_use ab.
*/
raw?: unknown[];
}
export interface LlmToolDef {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export type TextDeltaHandler = (text: string) => void;
/** Ein ausgeführter Tool-Aufruf, bereit zur Rückgabe ans Modell. */
export interface LlmToolResult {
id: string;
name: string;
result: unknown;
isError: boolean;
}
/** Fehler mit HTTP-Status, damit der Loop 400er (kaputter Verlauf) erkennen kann. */
export class LlmError extends Error {
status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'LlmError';
this.status = status;
}
}
// ─── Konstanten ───────────────────────────────────────────────────────────────
/** Anthropic verlangt max_tokens zwingend. Reicht für Werkzeugaufrufe + Erklärtext. */
const ANTHROPIC_MAX_TOKENS = 8192;
/** Anthropic-Version-Header. Nicht raten — das ist der stabile Wert. */
const ANTHROPIC_VERSION = '2023-06-01';
/** Ab wie vielen Zeichen ein Tool-Ergebnis gekürzt wird, bevor es ins Modell geht. */
const MAX_RESULT_CHARS = 24_000;
/** Mehr als das nimmt kein Screenshot-Tool je zurück — Schutz gegen Kontext-Explosion. */
const MAX_IMAGES_PER_RESULT = 4;
/** Von Anthropic akzeptierte Bildtypen. Alles andere geht als Text-Hinweis raus. */
const IMAGE_DATA_URL = /^data:(image\/(?:jpeg|jpg|png|gif|webp));base64,([A-Za-z0-9+/=]+)$/;
// ─── Öffentliche Nachrichten-Bausteine ────────────────────────────────────────
/** Nutzertext. Diese Form akzeptieren beide Provider unverändert. */
export function userText(text: string): any {
return { role: 'user', content: [{ type: 'text', text }] };
}
/** Ein vom Nutzer angehängtes Bild, so wie es das Panel schickt. */
export interface UserImage {
/** z. B. 'image/png' */
mediaType: string;
/** base64, OHNE data:-Präfix */
data: string;
name?: string;
}
/**
* Nutzernachricht mit angehängten Bildern.
*
* Die Bildblöcke stehen VOR dem Text: alle drei Wire-Formate ordnen ein Bild dem
* folgenden Text zu, und das Modell soll erst sehen, worüber gesprochen wird.
* Gespeichert wird die Anthropic-Blockform — die Konverter für openai, bedrock
* und gemini übersetzen sie beim Request.
*/
export function userContent(text: string, images: UserImage[] = []): any {
const content: any[] = [];
for (const img of images) {
if (!img?.data) continue;
const mediaType = img.mediaType === 'image/jpg' ? 'image/jpeg' : (img.mediaType || 'image/png');
content.push({ type: 'image', source: { type: 'base64', media_type: mediaType, data: img.data } });
}
// Ein leerer Text-Block ist bei Anthropic ungültig — Platzhalter, wenn der
// Nutzer nur ein Bild ohne Worte schickt.
content.push({ type: 'text', text: text.trim() || 'Beschreibe und analysiere dieses Bild.' });
return { role: 'user', content };
}
/**
* Assistenten-Zug für den Verlauf.
*
* Die Nachricht trägt beide Darstellungen: `content` (Anthropic-Blöcke, inkl.
* thinking/tool_use) und `tool_calls` + `_text` (OpenAI). Der Wire-Builder
* pickt sich heraus, was der aktive Provider versteht, und verwirft den Rest.
*/
export function assistantTurn(turn: LlmTurn): any {
const blocks: any[] = [];
if (Array.isArray(turn.raw) && turn.raw.length > 0) {
// Rohblöcke bevorzugen: nur so bleiben thinking-Signaturen und die
// Reihenfolge text↔tool_use exakt erhalten.
for (const b of turn.raw) blocks.push(b);
} else {
if (turn.text.trim()) blocks.push({ type: 'text', text: turn.text });
for (const call of turn.toolCalls) {
blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.input });
}
}
return {
role: 'assistant',
content: blocks,
// Nur für den OpenAI-Wire-Builder:
_text: turn.text,
tool_calls: turn.toolCalls.map(c => ({
id: c.id,
type: 'function',
function: { name: c.name, arguments: JSON.stringify(c.input ?? {}) },
})),
};
}
/**
* Tool-Ergebnisse als Nachricht(en) für den Verlauf.
*
* anthropic: ALLE tool_result-Blöcke eines Zuges in EINE user-Message. Mehrere
* aufeinanderfolgende user-Messages mit je einem Ergebnis sind ein Fehler —
* das Modell lernt daraus, keine parallelen Tool-Aufrufe mehr zu machen.
* Bilder hängen direkt im tool_result-content; das erlaubt die API und es
* ordnet das Bild eindeutig seinem Aufruf zu.
* openai: je Ergebnis eine role:'tool'-Message. Bilder passen dort nicht hinein,
* also folgt eine user-Message mit den image_url-Blöcken derselben Runde.
*/
export function toolResults(cfg: ExtensionConfig, results: LlmToolResult[]): any[] {
// 'bedrock' teilt sich die Anthropic-Blockform; umgebaut wird erst beim Request.
if (wireOf(cfg) === 'openai') {
const messages: any[] = [];
const trailing: any[] = [];
for (const r of results) {
const { text, images } = renderResult(r.result);
messages.push({ role: 'tool', tool_call_id: r.id, content: text });
for (const img of images) {
trailing.push({ type: 'text', text: `Bild aus ${r.name}:` });
trailing.push({ type: 'image_url', image_url: { url: img.dataUrl } });
}
}
if (trailing.length) messages.push({ role: 'user', content: trailing });
return messages;
}
const blocks: any[] = [];
for (const r of results) {
const { text, images } = renderResult(r.result);
const content: any[] = [{ type: 'text', text }];
for (const img of images) {
content.push({
type: 'image',
source: { type: 'base64', media_type: img.mediaType, data: img.data },
});
}
const block: any = { type: 'tool_result', tool_use_id: r.id, content };
if (r.isError) block.is_error = true;
blocks.push(block);
}
return [{ role: 'user', content: blocks }];
}
// ─── Bilder aus Tool-Ergebnissen herauslösen ──────────────────────────────────
interface ExtractedImage {
mediaType: string;
data: string;
dataUrl: string;
}
/**
* Ersetzt data:image-URLs rekursiv durch einen Platzhalter und sammelt sie ein.
* Rekursiv, weil browser_batch Ergebnisse verschachtelt zurückgibt.
*/
function stripImages(value: unknown, images: ExtractedImage[], depth = 0): unknown {
if (depth > 8) return value;
if (typeof value === 'string') {
if (!value.startsWith('data:image/')) return value;
if (images.length >= MAX_IMAGES_PER_RESULT) return '[Bild verworfen: Limit erreicht]';
const m = IMAGE_DATA_URL.exec(value.replace(/\s+/g, ''));
if (!m) return '[Bild verworfen: nicht unterstütztes Format]';
// image/jpg ist kein gültiger media_type — auf image/jpeg normalisieren.
const mediaType = m[1] === 'image/jpg' ? 'image/jpeg' : m[1];
images.push({ mediaType, data: m[2], dataUrl: `data:${mediaType};base64,${m[2]}` });
return '[Bild ist als Bildblock angehängt]';
}
if (Array.isArray(value)) return value.map(v => stripImages(v, images, depth + 1));
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = stripImages(v, images, depth + 1);
}
return out;
}
return value;
}
/** Tool-Ergebnis → Text (ohne base64) + separat die Bilder. */
function renderResult(result: unknown): { text: string; images: ExtractedImage[] } {
const images: ExtractedImage[] = [];
const cleaned = stripImages(result, images);
let text: string;
if (typeof cleaned === 'string') {
text = cleaned;
} else if (cleaned === undefined || cleaned === null) {
text = 'ok';
} else {
try {
text = JSON.stringify(cleaned);
} catch {
text = String(cleaned);
}
}
if (text.length > MAX_RESULT_CHARS) {
text = `${text.slice(0, MAX_RESULT_CHARS)}\n… [gekürzt, ${text.length} Zeichen gesamt]`;
}
// Leerer content-String wird von der API abgelehnt.
if (!text) text = 'ok';
return { text, images };
}
// ─── Wire-Format ──────────────────────────────────────────────────────────────
const ANTHROPIC_BLOCKS = new Set([
'text', 'image', 'tool_use', 'tool_result', 'thinking', 'redacted_thinking', 'document',
]);
function toAnthropicMessages(messages: any[], cacheLast: boolean): any[] {
const out: any[] = [];
for (const m of messages) {
if (m.role !== 'user' && m.role !== 'assistant') continue; // role:'tool' ist OpenAI-only
const raw = Array.isArray(m.content) ? m.content : [{ type: 'text', text: String(m.content ?? '') }];
const content = raw.filter((b: any) => b && typeof b === 'object' && ANTHROPIC_BLOCKS.has(b.type));
if (!content.length) continue;
out.push({ role: m.role, content });
}
// Cache-Haltepunkt auf den letzten Block: bei einem Agent-Loop wird derselbe
// Verlauf jede Runde erneut geschickt — ohne Breakpoint zahlt man ihn jedes Mal voll.
if (cacheLast && out.length) {
const last = out[out.length - 1];
const content = last.content.map((b: any, i: number) =>
i === last.content.length - 1 ? { ...b, cache_control: { type: 'ephemeral' } } : b,
);
out[out.length - 1] = { role: last.role, content };
}
return out;
}
function toOpenAiMessages(cfg: ExtensionConfig, messages: any[]): any[] {
const out: any[] = [];
if (cfg.systemPrompt && cfg.systemPrompt.trim()) {
out.push({ role: 'system', content: cfg.systemPrompt });
}
for (const m of messages) {
if (m.role === 'tool') {
out.push({ role: 'tool', tool_call_id: m.tool_call_id, content: String(m.content ?? '') });
continue;
}
if (m.role === 'assistant') {
const msg: any = { role: 'assistant', content: typeof m._text === 'string' && m._text ? m._text : null };
if (Array.isArray(m.tool_calls) && m.tool_calls.length) msg.tool_calls = m.tool_calls;
// Eine assistant-Message ohne Inhalt UND ohne tool_calls ist ungültig.
if (msg.content === null && !msg.tool_calls) continue;
out.push(msg);
continue;
}
const raw = Array.isArray(m.content) ? m.content : [{ type: 'text', text: String(m.content ?? '') }];
const parts: any[] = [];
for (const b of raw) {
if (!b || typeof b !== 'object') continue;
if (b.type === 'text') parts.push({ type: 'text', text: String(b.text ?? '') });
else if (b.type === 'image_url') parts.push(b);
else if (b.type === 'image' && b.source?.type === 'base64') {
parts.push({
type: 'image_url',
image_url: { url: `data:${b.source.media_type};base64,${b.source.data}` },
});
}
}
if (parts.length) out.push({ role: 'user', content: parts });
}
return out;
}
function joinUrl(base: string, path: string): string {
return `${(base || '').trim().replace(/\/+$/, '')}${path}`;
}
/**
* Prompt-Caching nur gegen die echte Anthropic-API anbieten. Fremde
* OpenAI-/Anthropic-kompatible Gateways lehnen unbekannte Felder oft mit 400 ab.
*/
function supportsPromptCache(baseUrl: string): boolean {
try {
return new URL(baseUrl).hostname.endsWith('api.anthropic.com');
} catch {
return false;
}
}
// ─── Modellaufruf ─────────────────────────────────────────────────────────────
export async function callModel(
cfg: ExtensionConfig,
messages: any[],
tools: LlmToolDef[],
onDelta: TextDeltaHandler,
signal: AbortSignal,
): Promise<LlmTurn> {
if (!authKey(cfg).trim()) {
throw new LlmError(
cfg.provider === 'nexus'
? 'Kein Nexus-API-Key gesetzt. Trage ihn in den Einstellungen ein.'
: 'Kein API-Key gesetzt. Trage ihn in den Einstellungen ein.',
);
}
if (!cfg.model || !cfg.model.trim()) {
throw new LlmError('Kein Modell gesetzt. Trage es in den Einstellungen ein.');
}
switch (wireOf(cfg)) {
case 'openai': return await callOpenAi(cfg, messages, tools, onDelta, signal);
case 'bedrock': return await callBedrockConverse(cfg, messages, tools, onDelta, signal);
case 'gemini': return await callGemini(cfg, messages, tools, onDelta, signal);
default: return await callAnthropic(cfg, messages, tools, onDelta, signal);
}
}
// ─── Anthropic ────────────────────────────────────────────────────────────────
interface StreamBlock {
type: string;
text: string;
id: string;
name: string;
json: string;
thinking: string;
signature: string;
data: string;
}
function newBlock(type: string): StreamBlock {
return { type, text: '', id: '', name: '', json: '', thinking: '', signature: '', data: '' };
}
async function callAnthropic(
cfg: ExtensionConfig,
messages: any[],
tools: LlmToolDef[],
onDelta: TextDeltaHandler,
signal: AbortSignal,
): Promise<LlmTurn> {
const cache = supportsPromptCache(cfg.baseUrl);
const body: any = {
model: cfg.model,
max_tokens: ANTHROPIC_MAX_TOKENS,
stream: true,
messages: toAnthropicMessages(messages, cache),
};
if (cfg.systemPrompt && cfg.systemPrompt.trim()) {
body.system = cache
// tools rendern VOR system — ein Breakpoint auf dem letzten system-Block
// cacht beides zusammen. Das ist der teure, in jeder Runde identische Teil.
? [{ type: 'text', text: cfg.systemPrompt, cache_control: { type: 'ephemeral' } }]
: cfg.systemPrompt;
}
if (tools.length) {
body.tools = tools.map(t => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema,
}));
}
const res = await fetch(joinUrl(cfg.baseUrl, '/messages'), {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'text/event-stream',
'x-api-key': authKey(cfg),
'anthropic-version': ANTHROPIC_VERSION,
// Ohne diesen Header weist die API jeden Request aus einem Browser-Kontext
// (und der Service Worker IST einer) ab. Bekannter Stolperstein.
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify(body),
signal,
}).catch((e: unknown) => {
if (isAbort(e, signal)) throw e;
throw new LlmError(`Netzwerkfehler beim Aufruf von ${cfg.baseUrl}: ${errText(e)}`);
});
await throwIfNotOk(res, cfg.baseUrl);
const blocks = new Map<number, StreamBlock>();
let stopReason = '';
let inputTokens = 0;
let outputTokens = 0;
let apiError: string | null = null;
await readSse(res, signal, (payload) => {
const ev = safeParse(payload);
if (!ev || typeof ev.type !== 'string') return;
switch (ev.type) {
case 'message_start': {
const u = ev.message?.usage;
if (u) {
// Cache-Treffer zählen als Prompt-Tokens — sonst sieht die Anzeige
// bei aktivem Caching absurd niedrig aus.
inputTokens =
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
}
break;
}
case 'content_block_start': {
const b = newBlock(ev.content_block?.type || 'text');
if (b.type === 'tool_use') {
b.id = ev.content_block?.id || '';
b.name = ev.content_block?.name || '';
} else if (b.type === 'redacted_thinking') {
b.data = ev.content_block?.data || '';
} else if (b.type === 'text') {
b.text = ev.content_block?.text || '';
}
blocks.set(ev.index, b);
break;
}
case 'content_block_delta': {
const b = blocks.get(ev.index);
if (!b) break;
const d = ev.delta || {};
if (d.type === 'text_delta' && typeof d.text === 'string') {
b.text += d.text;
if (d.text) onDelta(d.text);
} else if (d.type === 'input_json_delta' && typeof d.partial_json === 'string') {
b.json += d.partial_json;
} else if (d.type === 'thinking_delta' && typeof d.thinking === 'string') {
b.thinking += d.thinking;
} else if (d.type === 'signature_delta' && typeof d.signature === 'string') {
b.signature += d.signature;
}
break;
}
case 'message_delta': {
if (ev.delta?.stop_reason) stopReason = ev.delta.stop_reason;
if (typeof ev.usage?.output_tokens === 'number') outputTokens = ev.usage.output_tokens;
break;
}
case 'error': {
apiError = `${ev.error?.type || 'error'}: ${ev.error?.message || 'unbekannter Fehler'}`;
break;
}
}
});
if (apiError) throw new LlmError(`Anthropic-Streamfehler — ${apiError}`);
const ordered = [...blocks.entries()].sort((a, b) => a[0] - b[0]).map(e => e[1]);
const raw: any[] = [];
const toolCalls: LlmToolCall[] = [];
let text = '';
for (const b of ordered) {
if (b.type === 'text') {
text += b.text;
// Leere text-Blöcke lehnt die API beim Zurückspielen ab.
if (b.text.trim()) raw.push({ type: 'text', text: b.text });
} else if (b.type === 'tool_use') {
const input = parseToolInput(b.json);
toolCalls.push({ id: b.id, name: b.name, input });
raw.push({ type: 'tool_use', id: b.id, name: b.name, input });
} else if (b.type === 'thinking') {
// Ohne Signatur ist der Block beim Zurückspielen ungültig — dann lieber weglassen.
if (b.signature) raw.push({ type: 'thinking', thinking: b.thinking, signature: b.signature });
} else if (b.type === 'redacted_thinking') {
if (b.data) raw.push({ type: 'redacted_thinking', data: b.data });
}
}
return {
text,
toolCalls,
stopReason: stopReason || 'end_turn',
usage: { inputTokens, outputTokens },
raw,
};
}
// ─── OpenAI ───────────────────────────────────────────────────────────────────
interface OpenAiCallBuf {
id: string;
name: string;
args: string;
}
async function callOpenAi(
cfg: ExtensionConfig,
messages: any[],
tools: LlmToolDef[],
onDelta: TextDeltaHandler,
signal: AbortSignal,
): Promise<LlmTurn> {
const body: any = {
model: cfg.model,
stream: true,
// Kein max_tokens: reale OpenAI-Modelle verlangen inzwischen
// max_completion_tokens, kompatible Server erwarten max_tokens. Weglassen
// funktioniert bei beiden.
stream_options: { include_usage: true },
messages: toOpenAiMessages(cfg, messages),
};
if (tools.length) {
body.tools = tools.map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description,
// Manche Server bestehen auf type/properties — beides sicherstellen.
parameters: { type: 'object', properties: {}, ...t.inputSchema },
},
}));
}
// Nexus liegt auf dem Azure-OpenAI-Pfad: Modell steckt in der URL, nicht nur
// im Body, und das Gateway will den Key zusätzlich als api-key-Header.
const nexus = cfg.provider === 'nexus';
const url = nexus
? `${(cfg.baseUrl || '').trim().replace(/\/+$/, '')}/openai/deployments/`
+ `${encodeURIComponent(cfg.model.trim())}/chat/completions?api-version=${NEXUS_API_VERSION}`
: joinUrl(cfg.baseUrl, '/chat/completions');
const headers: Record<string, string> = {
'content-type': 'application/json',
'accept': 'text/event-stream',
'authorization': `Bearer ${authKey(cfg)}`,
};
if (nexus) headers['api-key'] = authKey(cfg);
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal,
}).catch((e: unknown) => {
if (isAbort(e, signal)) throw e;
throw new LlmError(`Netzwerkfehler beim Aufruf von ${cfg.baseUrl}: ${errText(e)}`);
});
await throwIfNotOk(res, cfg.baseUrl);
const calls = new Map<number, OpenAiCallBuf>();
let text = '';
let stopReason = '';
let inputTokens = 0;
let outputTokens = 0;
let apiError: string | null = null;
await readSse(res, signal, (payload) => {
if (payload === '[DONE]') return;
const ev = safeParse(payload);
if (!ev) return;
if (ev.error) {
apiError = ev.error.message || JSON.stringify(ev.error);
return;
}
if (ev.usage) {
inputTokens = ev.usage.prompt_tokens || inputTokens;
outputTokens = ev.usage.completion_tokens || outputTokens;
}
const choice = Array.isArray(ev.choices) ? ev.choices[0] : null;
if (!choice) return; // letzter Chunk trägt oft nur usage
if (choice.finish_reason) stopReason = choice.finish_reason;
const delta = choice.delta || {};
if (typeof delta.content === 'string' && delta.content) {
text += delta.content;
onDelta(delta.content);
}
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
// Der Index ist der einzige verlässliche Schlüssel: id und name kommen
// nur im ersten Chunk, arguments tröpfeln über viele Chunks nach.
const idx = typeof tc.index === 'number' ? tc.index : 0;
let buf = calls.get(idx);
if (!buf) {
buf = { id: '', name: '', args: '' };
calls.set(idx, buf);
}
if (tc.id) buf.id = tc.id;
if (tc.function?.name) buf.name += tc.function.name;
if (typeof tc.function?.arguments === 'string') buf.args += tc.function.arguments;
}
}
});
if (apiError) throw new LlmError(`OpenAI-Streamfehler — ${apiError}`);
const toolCalls: LlmToolCall[] = [...calls.entries()]
.sort((a, b) => a[0] - b[0])
.map(([idx, buf]) => ({
id: buf.id || `call_${idx}_${Date.now()}`,
name: buf.name,
input: parseToolInput(buf.args),
}))
.filter(c => !!c.name);
return {
text,
toolCalls,
stopReason: stopReason || (toolCalls.length ? 'tool_calls' : 'stop'),
usage: { inputTokens, outputTokens },
};
}
// ─── Gemini (Google-GenAI, Nexus) ─────────────────────────────────────────────
/**
* Vier Regeln, die je einen HTTP 400 kosten — live am Gateway gemessen und in
* agent/gemini_bridge.py (Python-Seite) identisch umgesetzt:
*
* 1. Gemini 3 gibt zu functionCall-Teilen eine `thoughtSignature` zurück und
* verlangt sie beim Zurückspielen ("Function call is missing a
* thought_signature"). Unser Verlauf ist Anthropic-förmig und hat kein Feld
* dafür → Signaturcache toolUseId → Signatur.
* 2. Die Signatur gehört dem ZUG, nicht dem Aufruf: bei parallelen Aufrufen
* signiert Gemini nur den ERSTEN Teil, will aber alle als functionCall
* zurück. Also einmal setzen — und einen Zug ganz oder gar nicht als
* Werkzeugzug schicken. Gemischt → 400 "Requests ending with a model turn".
* 3. Ein Zug mit functionResponse muss sortenrein sein; Bilder folgen als
* eigener Nutzerzug.
* 4. Schemata: jedes `type: array` braucht `items` (auch verschachtelt), `type`
* darf keine Liste sein, unbekannte Schlüssel fliegen raus.
*/
const GEMINI_SIGNATURES = new Map<string, string>();
const GEMINI_SIG_LIMIT = 500;
function rememberSignature(id: string, sig: string): void {
if (!id || !sig) return;
GEMINI_SIGNATURES.delete(id);
GEMINI_SIGNATURES.set(id, sig);
while (GEMINI_SIGNATURES.size > GEMINI_SIG_LIMIT) {
const oldest = GEMINI_SIGNATURES.keys().next().value;
if (oldest === undefined) break;
GEMINI_SIGNATURES.delete(oldest);
}
}
/** Anthropic-Blockform (unser Verlaufsformat) → Gemini-`contents`. */
function toGeminiContents(messages: any[]): any[] {
const out: any[] = [];
const names = new Map<string, string>(); // tool_use_id → Werkzeugname
const realCalls = new Set<string>(); // als functionCall übergeben
for (const m of messages) {
if (m.role !== 'user' && m.role !== 'assistant') continue; // role:'tool' ist OpenAI-only
const role = m.role === 'assistant' ? 'model' : 'user';
const blocks = Array.isArray(m.content)
? m.content
: [{ type: 'text', text: String(m.content ?? '') }];
// Regel 2: Signatur pro Zug bestimmen, bevor irgendetwas geschrieben wird.
let turnSig = '';
for (const b of blocks) {
if (b?.type === 'tool_use') {
const s = GEMINI_SIGNATURES.get(String(b.id ?? ''));
if (s) { turnSig = s; break; }
}
}
let sigPlaced = false;
const parts: any[] = [];
const trailingImages: any[] = []; // Regel 3
for (const b of blocks) {
if (!b || typeof b !== 'object') continue;
if (b.type === 'text') {
if (String(b.text ?? '').trim()) parts.push({ text: String(b.text) });
} else if (b.type === 'image') {
const p = toGeminiImage(b);
if (p) parts.push(p);
} else if (b.type === 'tool_use') {
const id = String(b.id ?? '');
names.set(id, String(b.name ?? ''));
if (turnSig) {
const call: any = { functionCall: { name: b.name, args: b.input ?? {}, id } };
if (!sigPlaced) { call.thoughtSignature = turnSig; sigPlaced = true; }
parts.push(call);
realCalls.add(id);
} else {
parts.push({ text: `[Werkzeugaufruf ${b.name}: ${safeJson(b.input ?? {})}]` });
}
} else if (b.type === 'tool_result') {
const id = String(b.tool_use_id ?? '');
const inner = Array.isArray(b.content) ? b.content : [];
const texts: string[] = [];
for (const c of inner) {
if (c?.type === 'text' && String(c.text ?? '').trim()) texts.push(String(c.text));
else if (c?.type === 'image') {
const p = toGeminiImage(c);
if (p) trailingImages.push(p);
}
}
const payload = { result: texts.join('\n') || 'ok' };
if (realCalls.has(id)) {
parts.push({ functionResponse: { name: names.get(id) ?? 'tool', id, response: payload } });
} else {
parts.push({ text: `[Werkzeugergebnis ${names.get(id) ?? 'tool'}: ${safeJson(payload).slice(0, 4000)}]` });
}
}
// thinking/redacted_thinking: für Gemini bedeutungslos.
}
if (parts.length) out.push({ role, parts });
if (trailingImages.length) {
out.push({ role: 'user', parts: [{ text: 'Bild aus dem Werkzeugergebnis:' }, ...trailingImages] });
}
}
return out;
}
function toGeminiImage(block: any): any | null {
const src = block.source;
if (!src || src.type !== 'base64' || !src.data) return null;
const mediaType = src.media_type === 'image/jpg' ? 'image/jpeg' : String(src.media_type || 'image/png');
return { inlineData: { mimeType: mediaType, data: src.data } };
}
/** Regel 4: JSON-Schema auf die von Gemini akzeptierte OpenAPI-Teilmenge stutzen. */
const GEMINI_SCHEMA_DROP = new Set(['additionalProperties', '$schema', 'default', 'examples', 'title']);
function cleanGeminiSchema(schema: any): any {
if (Array.isArray(schema)) return schema.map(cleanGeminiSchema);
if (!schema || typeof schema !== 'object') return schema;
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(schema)) {
if (!GEMINI_SCHEMA_DROP.has(k)) out[k] = cleanGeminiSchema(v);
}
if (Array.isArray(out.type)) out.type = out.type.find((t: string) => t !== 'null') ?? 'string';
if (out.type === 'array' && out.items === undefined) out.items = { type: 'string' };
return out;
}
const GEMINI_FINISH: Record<string, string> = {
STOP: 'end_turn',
MAX_TOKENS: 'max_tokens',
SAFETY: 'refusal',
RECITATION: 'refusal',
PROHIBITED_CONTENT: 'refusal',
};
async function callGemini(
cfg: ExtensionConfig,
messages: any[],
tools: LlmToolDef[],
onDelta: TextDeltaHandler,
signal: AbortSignal,
): Promise<LlmTurn> {
const body: any = {
contents: toGeminiContents(messages),
generationConfig: { maxOutputTokens: ANTHROPIC_MAX_TOKENS },
};
if (cfg.systemPrompt && cfg.systemPrompt.trim()) {
body.systemInstruction = { parts: [{ text: cfg.systemPrompt }] };
}
if (tools.length) {
body.tools = [{
functionDeclarations: tools.map(t => ({
name: t.name,
description: t.description.slice(0, 1024),
parameters: cleanGeminiSchema({ type: 'object', properties: {}, ...t.inputSchema }),
})),
}];
}
const url = `${(cfg.baseUrl || '').trim().replace(/\/+$/, '')}/v1beta/models/`
+ `${encodeURIComponent(cfg.model.trim())}:streamGenerateContent?alt=sse`;
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'text/event-stream',
// NICHT Bearer: diese Route verlangt api-key (sonst 401).
'api-key': authKey(cfg),
},
body: JSON.stringify(body),
signal,
}).catch((e: unknown) => {
if (isAbort(e, signal)) throw e;
throw new LlmError(`Netzwerkfehler beim Aufruf von ${cfg.baseUrl}: ${errText(e)}`);
});
await throwIfNotOk(res, cfg.baseUrl);
const raw: any[] = [];
const toolCalls: LlmToolCall[] = [];
let text = '';
let stopReason = '';
let inputTokens = 0;
let outputTokens = 0;
let apiError: string | null = null;
await readSse(res, signal, (payload) => {
const ev = safeParse(payload);
if (!ev) return;
if (ev.error) {
apiError = ev.error.message || JSON.stringify(ev.error);
return;
}
const um = ev.usageMetadata;
if (um) {
inputTokens = um.promptTokenCount || inputTokens;
// Denk-Tokens zählen als Ausgabe, sonst wirkt die Anzeige zu niedrig.
outputTokens = (um.candidatesTokenCount || 0) + (um.thoughtsTokenCount || 0) || outputTokens;
}
for (const cand of ev.candidates ?? []) {
if (cand.finishReason) {
stopReason = GEMINI_FINISH[cand.finishReason] ?? String(cand.finishReason).toLowerCase();
}
for (const part of cand.content?.parts ?? []) {
if (!part || typeof part !== 'object') continue;
if (part.functionCall) {
const id = String(part.functionCall.id || `call_${toolCalls.length}`);
rememberSignature(id, String(part.thoughtSignature || ''));
const input = (part.functionCall.args && typeof part.functionCall.args === 'object')
? part.functionCall.args as Record<string, unknown>
: {};
const name = String(part.functionCall.name || '');
if (!name) continue;
toolCalls.push({ id, name, input });
raw.push({ type: 'tool_use', id, name, input });
continue;
}
if (typeof part.text === 'string' && part.text) {
text += part.text;
onDelta(part.text);
}
}
}
});
if (apiError) throw new LlmError(`Gemini-Streamfehler — ${apiError}`);
// Textblock VOR den Tool-Aufrufen in den Verlauf — Reihenfolge wie bei Anthropic.
if (text.trim()) raw.unshift({ type: 'text', text });
return {
text,
toolCalls,
// Gemini meldet auch bei Werkzeugaufrufen STOP; der Loop braucht 'tool_use'.
stopReason: toolCalls.length ? 'tool_use' : (stopReason || 'end_turn'),
usage: { inputTokens, outputTokens },
raw,
};
}
function safeJson(value: unknown): string {
try {
return JSON.stringify(value) ?? '';
} catch {
return String(value);
}
}
// ─── Bedrock Converse (Nexus-Claude) ──────────────────────────────────────────
/**
* Warum hier nicht gestreamt wird:
* `/model/{id}/converse-stream` antwortet im binären AWS-Event-Stream-Format
* (Prelude, Header, CRC32) — kein SSE. Das im Service Worker zu dekodieren wäre
* ein eigener Parser mit eigener Fehlerklasse. `/converse` liefert dieselbe
* Antwort als schlichtes JSON; der Agent-Loop braucht Tool-Aufrufe, keine
* Buchstaben-für-Buchstabe-Anzeige. Der Text geht am Ende in einem Stück raus.
*
* Auth: Nexus akzeptiert den API-Key als Bearer — kein AWS-SigV4. Genau deshalb
* ist dieser Weg aus dem Browser überhaupt gangbar.
*/
async function callBedrockConverse(
cfg: ExtensionConfig,
messages: any[],
tools: LlmToolDef[],
onDelta: TextDeltaHandler,
signal: AbortSignal,
): Promise<LlmTurn> {
const body: any = {
messages: toBedrockMessages(messages),
inferenceConfig: { maxTokens: ANTHROPIC_MAX_TOKENS },
};
if (cfg.systemPrompt && cfg.systemPrompt.trim()) {
body.system = [{ text: cfg.systemPrompt }];
}
if (tools.length) {
body.toolConfig = {
tools: tools.map(t => ({
toolSpec: {
name: t.name,
description: t.description,
inputSchema: { json: { type: 'object', properties: {}, ...t.inputSchema } },
},
})),
};
}
const url = `${(cfg.baseUrl || '').trim().replace(/\/+$/, '')}`
+ `/model/${encodeURIComponent(cfg.model.trim())}/converse`;
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json',
'authorization': `Bearer ${authKey(cfg)}`,
},
body: JSON.stringify(body),
signal,
}).catch((e: unknown) => {
if (isAbort(e, signal)) throw e;
throw new LlmError(`Netzwerkfehler beim Aufruf von ${cfg.baseUrl}: ${errText(e)}`);
});
await throwIfNotOk(res, cfg.baseUrl);
const data = safeParse(await res.text());
if (!data) throw new LlmError('Bedrock-Antwort war kein gültiges JSON.');
if (data.message && !data.output) {
// Kong-Fehler ohne HTTP-Fehlerstatus (kommt bei Rate-Limits vor).
throw new LlmError(`Nexus-Fehler — ${String(data.message)}`);
}
const content: any[] = data.output?.message?.content ?? [];
const raw: any[] = [];
const toolCalls: LlmToolCall[] = [];
let text = '';
for (const b of content) {
if (!b || typeof b !== 'object') continue;
if (typeof b.text === 'string') {
text += b.text;
if (b.text.trim()) raw.push({ type: 'text', text: b.text });
} else if (b.toolUse) {
const input = (b.toolUse.input && typeof b.toolUse.input === 'object')
? b.toolUse.input as Record<string, unknown>
: {};
const id = String(b.toolUse.toolUseId ?? '');
const name = String(b.toolUse.name ?? '');
if (!name) continue;
toolCalls.push({ id, name, input });
raw.push({ type: 'tool_use', id, name, input });
}
// reasoningContent wird bewusst verworfen: ohne Streaming trägt es keine
// Signatur, die man zurückspielen könnte.
}
// Kein Streaming — der Text erscheint in einem Stück, sonst bleibt die Anzeige leer.
if (text) onDelta(text);
const stop = String(data.stopReason || 'end_turn');
return {
text,
toolCalls,
// 'content_filtered' ist der Bedrock-Name für das, was agent.ts 'refusal' nennt.
stopReason: stop === 'content_filtered' ? 'refusal' : stop,
usage: {
inputTokens: Number(data.usage?.inputTokens ?? 0),
outputTokens: Number(data.usage?.outputTokens ?? 0),
},
raw,
};
}
/** Anthropic-Blockform (unser Verlaufsformat) → Bedrock-Converse-Blöcke. */
function toBedrockMessages(messages: any[]): any[] {
const out: any[] = [];
for (const m of messages) {
if (m.role !== 'user' && m.role !== 'assistant') continue; // role:'tool' ist OpenAI-only
const raw = Array.isArray(m.content) ? m.content : [{ type: 'text', text: String(m.content ?? '') }];
const content: any[] = [];
for (const b of raw) {
if (!b || typeof b !== 'object') continue;
if (b.type === 'text') {
// Leere text-Blöcke lehnt Converse mit ValidationException ab.
if (String(b.text ?? '').trim()) content.push({ text: String(b.text) });
} else if (b.type === 'image') {
const img = toBedrockImage(b);
if (img) content.push(img);
} else if (b.type === 'tool_use') {
content.push({ toolUse: { toolUseId: b.id, name: b.name, input: b.input ?? {} } });
} else if (b.type === 'tool_result') {
const inner: any[] = [];
const blocks = Array.isArray(b.content) ? b.content : [{ type: 'text', text: String(b.content ?? '') }];
for (const c of blocks) {
if (!c || typeof c !== 'object') continue;
if (c.type === 'text' && String(c.text ?? '').trim()) inner.push({ text: String(c.text) });
else if (c.type === 'image') {
const img = toBedrockImage(c);
if (img) inner.push(img);
}
}
if (!inner.length) inner.push({ text: 'ok' });
content.push({
toolResult: {
toolUseId: b.tool_use_id,
content: inner,
status: b.is_error ? 'error' : 'success',
},
});
}
// thinking/redacted_thinking: ohne gültige Signatur nicht zurückspielbar.
}
if (content.length) out.push({ role: m.role, content });
}
return out;
}
/**
* Anthropic-Bildblock → Bedrock-Bildblock.
* Über die REST-API ist `source.bytes` ein base64-String — boto3 kodiert nur
* deshalb selbst, weil es rohe Bytes entgegennimmt.
*/
function toBedrockImage(block: any): any | null {
const src = block.source;
if (!src || src.type !== 'base64' || !src.data) return null;
const format = String(src.media_type || '').replace(/^image\//, '').replace(/^jpg$/, 'jpeg');
if (!['png', 'jpeg', 'gif', 'webp'].includes(format)) return null;
return { image: { format, source: { bytes: src.data } } };
}
// ─── SSE / HTTP-Helfer ────────────────────────────────────────────────────────
/**
* Liest einen SSE-Body zeilenweise und reicht jede data:-Nutzlast weiter.
* Chunks brechen mitten in UTF-8-Sequenzen und mitten in Zeilen — deshalb
* streamender Decoder plus Zeilenpuffer.
*/
async function readSse(
res: Response,
signal: AbortSignal,
onPayload: (payload: string) => void,
): Promise<void> {
if (!res.body) throw new LlmError('Antwort ohne Body — Streaming nicht möglich.');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const handleLine = (line: string) => {
if (!line || line.startsWith(':')) return; // Kommentar/Heartbeat
if (!line.startsWith('data:')) return; // event:/id:/retry: ignorieren
const payload = line.slice(5).trim();
if (payload) onPayload(payload);
};
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl).replace(/\r$/, '');
buffer = buffer.slice(nl + 1);
handleLine(line);
}
}
buffer += decoder.decode();
if (buffer.trim()) handleLine(buffer.replace(/\r$/, '').trim());
} catch (e: unknown) {
if (isAbort(e, signal)) throw e;
throw new LlmError(`Stream abgebrochen: ${errText(e)}`);
} finally {
// Bei Abbruch hängt der Reader sonst am offenen Socket.
reader.cancel().catch(() => {});
}
}
async function throwIfNotOk(res: Response, baseUrl: string): Promise<void> {
if (res.ok) return;
let detail = '';
try {
detail = (await res.text()).slice(0, 600);
} catch {
detail = '(Body nicht lesbar)';
}
throw new LlmError(
`HTTP ${res.status} ${res.statusText} von ${baseUrl}${detail ? ` — ${detail}` : ''}`,
res.status,
);
}
function safeParse(payload: string): any {
try {
return JSON.parse(payload);
} catch {
return null;
}
}
/** Leerer Argument-String bedeutet "keine Argumente", nicht "kaputt". */
function parseToolInput(json: string): Record<string, unknown> {
const s = (json || '').trim();
if (!s) return {};
try {
const v = JSON.parse(s);
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
} catch {
// Abgeschnittenes JSON (z. B. bei stop_reason 'max_tokens'). Lieber leer
// weitergeben — das Tool meldet dann einen Fehler und das Modell korrigiert.
return {};
}
}
function isAbort(e: unknown, signal: AbortSignal): boolean {
return signal.aborted || (e instanceof DOMException && e.name === 'AbortError');
}
function errText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}