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>
1230 lines
43 KiB
TypeScript
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);
|
|
}
|