/** * 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; } 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; } 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 = {}; for (const [k, v] of Object.entries(value as Record)) { 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 { 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 { 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(); 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 { 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 = { '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(); 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(); 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(); // tool_use_id → Werkzeugname const realCalls = new Set(); // 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 = {}; 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 = { 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 { 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 : {}; 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 { 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 : {}; 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 { 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 { 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 { const s = (json || '').trim(); if (!s) return {}; try { const v = JSON.parse(s); return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {}; } 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); }