diff --git a/core/ki/README.md b/core/ki/README.md index 0a917d8..bc149b1 100644 --- a/core/ki/README.md +++ b/core/ki/README.md @@ -8,8 +8,9 @@ Stand Oktober 2026) und die Claude-API-Doku. ## Anbieter -Leon wählt in den Einstellungen **Microsoft Foundry** oder die **Claude API** (Anthropic direkt). -Beide sprechen dieselbe Messages-API; Prompt-Aufbau und Antwortprüfung sind identisch. +Leon wählt in den Einstellungen **Microsoft Foundry**, die **Claude API** (Anthropic direkt) oder +einen **OpenAI-kompatiblen Dienst** (siehe unten). Foundry und Claude API sprechen dieselbe +Messages-API; Prompt-Aufbau und Antwortprüfung sind für alle Anbieter identisch. | | Microsoft Foundry | Claude API | | --- | --- | --- | @@ -24,6 +25,40 @@ Beide sprechen dieselbe Messages-API; Prompt-Aufbau und Antwortprüfung sind ide Umschalten erhalten. Helfer: `kiAnbieter`, `kiModell`, `kiEndpoint`, `kiMitStandards` (`core/ki/anbieter.ts`). +## OpenAI-kompatible Dienste (E15) + +Dritter Anbieter `openai`: alles mit OpenAI-kompatibler Chat-Completions-API. Vorlagen in +`core/ki/anbieter.ts` (`VORLAGEN`): + +| Vorlage | Basis-URL | Schlüssel | Antwortformat | Effort | +| --- | --- | --- | --- | --- | +| Ollama (Standard) | `http://localhost:11434/v1` | optional | `json_schema` (per Grammatik erzwungen) | – | +| LM Studio | `http://localhost:1234/v1` | optional | `json_schema` | – | +| OpenAI | `https://api.openai.com/v1` | Pflicht | `json_schema` strict, `max_completion_tokens` | `reasoning_effort` | +| OpenRouter | `https://openrouter.ai/api/v1` | Pflicht | `json_schema`, `provider: {require_parameters, data_collection: "deny"}` | `reasoning.effort` | +| Qwen (Alibaba Cloud) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | Pflicht | `json_object`, Schema im System-Prompt | – | +| Eigene URL | – | optional | `json_schema` | – | + +- `core/ki` baut weiter Messages-API-Bodys und liest Messages-API-Antworten. Der Tauri-Transport + übersetzt davor und danach (`nachOpenAi`, `vonOpenAi` in `core/ki/openai.ts`): `system` → Nachricht + `system`, Inhaltsblöcke → ein Text (ohne `cache_control`; die Dienste cachen gleiche Anfänge selbst), + `finish_reason` `stop`/`length`/`content_filter` → `end_turn`/`max_tokens`/`refusal`, + `prompt_tokens − cached_tokens` → `input_tokens`, `cached_tokens` → `cache_read_input_tokens`. Ein + vorangestelltes `…` und ein ```-Zaun werden vor dem JSON entfernt. +- Unsere Schemas erfüllen den Strict-Modus von OpenAI (`obj()` in `anfrage.ts`: alle Felder Pflicht, + `additionalProperties: false`). +- Rust: `POST {endpoint}/chat/completions`, `content-type` und – nur wenn hinterlegt – + `Authorization: Bearer ` (Konto `openai-kompatibel`). Kein `x-api-key`, keine + `anthropic-*`-Kopfzeilen. `http://` nur für localhost. Zeitlimit 30 min (lokale Modelle lesen ein + ganzes Buch langsam ein). +- Kein `count_tokens`: `tokenZaehlen` lehnt ab, `core/ki` schätzt über Zeichen/3. +- Keine Listenpreise: Schätzung und Ergebnis tragen `kostenBekannt: false` (`kostenUsd` 0 bzw. fehlt), + Ablagen speichern dann kein `kostenUsd`. +- Kontextlänge: Ist `ki.openai.kontextTokens` gesetzt, lehnt der Transport Anfragen ab, die geschätzt + samt 4 000 Tokens Reserve nicht hineinpassen. Lokale Server kürzen sonst still (Ollama: + `OLLAMA_CONTEXT_LENGTH`, LM Studio: Kontext beim Laden des Modells). +- Modellnamen haben keinen Claude-Standard; eine leere Rolle nutzt das Prüfmodell. + ## Endpoint und Pfade - Basis-URL ohne `/v1/messages`. Vor der Nutzung: Leerraum und `/` am Ende entfernen; falls doch diff --git a/core/ki/anbieter.test.ts b/core/ki/anbieter.test.ts index 78156d1..1c04b6e 100644 --- a/core/ki/anbieter.test.ts +++ b/core/ki/anbieter.test.ts @@ -1,11 +1,13 @@ /** - * KI-Anbieter (Foundry | Claude API): ältere projekt.md bleiben gültig (= Foundry), beim Umschalten + * KI-Anbieter (Foundry | Claude API | OpenAI-kompatibel): ältere projekt.md bleiben gültig (= Foundry), beim Umschalten * geht die Konfiguration des anderen Anbieters nicht verloren, Modellwahl je Anbieter. */ import { parseProjekt, serializeProjekt } from '../projekt'; import type { KiEinstellungen, Projekt } from '../model/types'; import { + anbieterName, CLAUDE_API_ENDPOINT, + kostenBekannt, kiAnbieter, kiEndpoint, kiMitStandards, @@ -66,6 +68,7 @@ describe('KI-Anbieter', () => { endpoint: 'https://api.anthropic.com', modelle: { erkennung: 'claude-haiku-5-5', pruefung: 'claude-sonnet-5-5', tiefencheck: 'claude-opus-5-5' }, }, + openai: { vorlage: 'ollama', endpoint: 'http://localhost:11434/v1', modelle: { erkennung: '', pruefung: '', tiefencheck: '' } }, }); }); @@ -108,8 +111,64 @@ describe('KI-Anbieter', () => { expect([...pos].sort((a, b) => a - b)).toEqual(pos); }); + it('OpenAI-kompatibel: Modelle ohne Claude-Standard, fehlende Rolle = Prüfmodell, Basis-URL der Vorlage', () => { + const ki: KiEinstellungen = { + ...CLAUDE, + anbieter: 'openai', + openai: { vorlage: 'lmstudio', endpoint: ' ', modelle: { erkennung: 'qwen/qwen3-8b', pruefung: 'qwen/qwen3-32b', tiefencheck: '' } }, + }; + expect(kiAnbieter(ki)).toBe('openai'); + expect(kiEndpoint(ki)).toBe('http://localhost:1234/v1'); + expect(kiModell(ki, 'erkennung')).toBe('qwen/qwen3-8b'); + expect(kiModell(ki, 'tiefencheck')).toBe('qwen/qwen3-32b'); + expect(anbieterName(ki)).toBe('LM Studio'); + expect(kostenBekannt(ki)).toBe(false); + expect(kostenBekannt(CLAUDE)).toBe(true); + // nur ein Modell eingetragen: gilt für alle Rollen + const eins: KiEinstellungen = { ...ki, openai: { vorlage: 'ollama', endpoint: '', modelle: { erkennung: 'qwen3:8b', pruefung: '', tiefencheck: '' } } }; + expect(kiModell(eins, 'pruefung')).toBe('qwen3:8b'); + expect(kiModell(eins, 'tiefencheck')).toBe('qwen3:8b'); + // keines eingetragen: leer (kein Claude-Modell an Ollama schicken) + expect(kiModell({ ...ki, openai: undefined }, 'pruefung')).toBe(''); + // eigene URL ohne Endpoint bleibt leer + expect(kiEndpoint({ ...ki, openai: { vorlage: 'eigen', endpoint: '', modelle: ki.openai!.modelle } })).toBe(''); + expect(kiMitStandards({ ...ki, openai: { ...ki.openai!, vorlage: 'gibtsnicht' as never } }).openai.vorlage).toBe('ollama'); + }); + + it('Roundtrip mit OpenAI-kompatiblem Dienst; Umschalten verliert nichts', () => { + const OPENAI: KiEinstellungen = { + ...CLAUDE, + anbieter: 'openai', + openai: { + vorlage: 'openrouter', + endpoint: 'https://openrouter.ai/api/v1', + modelle: { erkennung: 'qwen/qwen3-32b', pruefung: 'openai/gpt-5', tiefencheck: '' }, + kontextTokens: 128000, + }, + }; + const projekt: Projekt = { ...parseProjekt(ALT), ki: OPENAI }; + const text = serializeProjekt(projekt, ALT); + expect(text).toContain('anbieter: openai'); + expect(text).toContain('vorlage: openrouter'); + expect(text).toContain('kontextTokens: 128000'); + expect(parseProjekt(text).ki).toEqual(OPENAI); + + // zurück zur Claude API, ohne openai-Block im Aufruf: die Einstellungen bleiben in der Datei + const { openai: _o, ...ohne } = OPENAI; + void _o; + const zurueck = serializeProjekt({ ...projekt, ki: { ...ohne, anbieter: 'claude' } }, text); + const geladen = parseProjekt(zurueck).ki!; + expect(kiAnbieter(geladen)).toBe('claude'); + expect(geladen.openai).toEqual(OPENAI.openai); + + // Schlüsselreihenfolge im openai-Block + const block = text.slice(text.indexOf('openai:')); + const pos = ['vorlage:', 'endpoint:', 'modelle:', 'kontextTokens:'].map((k) => block.indexOf(k)); + expect([...pos].sort((a, b) => a - b)).toEqual(pos); + }); + it('unbekannter Anbieter in der Datei gilt als Foundry, unbekannte Felder bleiben erhalten', () => { - const datei = ALT.replace(' endpoint: https', ' anbieter: openai\n eigenesFeld: 1\n endpoint: https'); + const datei = ALT.replace(' endpoint: https', ' anbieter: gemini\n eigenesFeld: 1\n endpoint: https'); const p = parseProjekt(datei); expect(p.ki?.anbieter).toBeUndefined(); expect(kiAnbieter(p.ki)).toBe('foundry'); diff --git a/core/ki/anbieter.ts b/core/ki/anbieter.ts index 11b1374..929872b 100644 --- a/core/ki/anbieter.ts +++ b/core/ki/anbieter.ts @@ -1,18 +1,20 @@ /** - * KI-Anbieter: Claude in Microsoft Foundry oder die Claude API von Anthropic direkt. - * Beide sprechen dieselbe Messages-API; es unterscheiden sich Basis-URL, Schlüsselbund-Eintrag - * und die Modellnamen (Foundry: Deployment-Namen, Claude API: Modell-IDs). + * KI-Anbieter: Claude in Microsoft Foundry, die Claude API von Anthropic direkt oder ein Dienst mit + * OpenAI-kompatibler Chat-Completions-API. Foundry und Claude API sprechen dieselbe Messages-API; es + * unterscheiden sich Basis-URL, Schlüsselbund-Eintrag und die Modellnamen (Foundry: Deployment-Namen, + * Claude API: Modell-IDs). Für OpenAI-kompatible Dienste übersetzt der Transport (core/ki/openai.ts). * * Ältere projekt.md-Dateien kennen nur `ki: {endpoint, deployments}` und gelten als Foundry. */ -import type { KiAnbieter, KiClaudeEinstellungen, KiEinstellungen, KiModelle } from '../model/types'; +import type { KiAnbieter, KiClaudeEinstellungen, KiEinstellungen, KiModelle, KiOpenAiEinstellungen, KiVorlage } from '../model/types'; import type { KiRolle } from './anfrage'; -export const KI_ANBIETER: readonly KiAnbieter[] = ['foundry', 'claude']; +export const KI_ANBIETER: readonly KiAnbieter[] = ['foundry', 'claude', 'openai']; export const ANBIETER_NAME: Record = { foundry: 'Microsoft Foundry', claude: 'Claude API', + openai: 'OpenAI-kompatibel', }; /** Standardmodelle (E14) – für Foundry als Deployment-Namen, für die Claude API als Modell-IDs. */ @@ -30,11 +32,110 @@ export const STANDARD_CLAUDE: Readonly = { modelle: STANDARD_MODELLE, }; +/** Wie ein OpenAI-kompatibler Dienst angesprochen wird (E15). */ +export interface VorlageInfo { + name: string; + /** Standard-Basis-URL ohne /chat/completions ('' = selbst eintragen) */ + endpoint: string; + /** Ohne Schlüssel nutzbar (lokale Server); sonst ist ein Schlüssel Pflicht */ + ohneSchluessel: boolean; + /** Läuft auf diesem Mac: Kontextlänge des Servers beachten, keine Kosten */ + lokal: boolean; + /** + * json_schema: `response_format` mit Schema (lokale Server erzwingen es per Grammatik); + * json_object: nur „gültiges JSON“, das Schema steht dann zusätzlich im System-Prompt. + */ + strukturiert: 'json_schema' | 'json_object'; + /** Wie `output_config.effort` übergeben wird (null = gar nicht) */ + effort: 'reasoning_effort' | 'reasoning' | null; + /** OpenAI: max_completion_tokens (max_tokens nehmen Reasoning-Modelle nicht an); sonst max_tokens */ + maxTokensFeld: 'max_completion_tokens' | 'max_tokens'; + /** Zusätzliche Felder im Body */ + zusatz?: Readonly>; + /** Beispiel-Modellnamen (nur Platzhalter, keine Standardwerte) */ + beispiele: Readonly; +} + +export const VORLAGEN: Readonly> = { + ollama: { + name: 'Ollama', + endpoint: 'http://localhost:11434/v1', + ohneSchluessel: true, + lokal: true, + strukturiert: 'json_schema', + effort: null, + maxTokensFeld: 'max_tokens', + beispiele: { erkennung: 'qwen3:8b', pruefung: 'qwen3:32b', tiefencheck: 'qwen3:32b' }, + }, + lmstudio: { + name: 'LM Studio', + endpoint: 'http://localhost:1234/v1', + ohneSchluessel: true, + lokal: true, + strukturiert: 'json_schema', + effort: null, + maxTokensFeld: 'max_tokens', + beispiele: { erkennung: 'qwen/qwen3-8b', pruefung: 'qwen/qwen3-32b', tiefencheck: 'qwen/qwen3-32b' }, + }, + openai: { + name: 'OpenAI', + endpoint: 'https://api.openai.com/v1', + ohneSchluessel: false, + lokal: false, + strukturiert: 'json_schema', + effort: 'reasoning_effort', + maxTokensFeld: 'max_completion_tokens', + beispiele: { erkennung: 'gpt-5-mini', pruefung: 'gpt-5', tiefencheck: 'gpt-5' }, + }, + openrouter: { + name: 'OpenRouter', + endpoint: 'https://openrouter.ai/api/v1', + ohneSchluessel: false, + lokal: false, + strukturiert: 'json_schema', + effort: 'reasoning', + maxTokensFeld: 'max_tokens', + // Nur Anbieter, die alle Parameter (Schema) unterstützen und keine Daten speichern bzw. zum Training nutzen. + zusatz: { provider: { require_parameters: true, data_collection: 'deny' } }, + beispiele: { erkennung: 'qwen/qwen3-32b', pruefung: 'openai/gpt-5', tiefencheck: 'openai/gpt-5' }, + }, + qwen: { + name: 'Qwen (Alibaba Cloud)', + endpoint: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + ohneSchluessel: false, + lokal: false, + strukturiert: 'json_object', + effort: null, + maxTokensFeld: 'max_tokens', + beispiele: { erkennung: 'qwen-flash', pruefung: 'qwen-plus', tiefencheck: 'qwen-max' }, + }, + eigen: { + name: 'Eigene URL', + endpoint: '', + ohneSchluessel: true, + lokal: false, + strukturiert: 'json_schema', + effort: null, + maxTokensFeld: 'max_tokens', + beispiele: { erkennung: 'modell-klein', pruefung: 'modell-gross', tiefencheck: 'modell-gross' }, + }, +}; + +/** Reihenfolge in den Einstellungen: lokal zuerst. */ +export const KI_VORLAGEN: readonly KiVorlage[] = ['ollama', 'lmstudio', 'openai', 'openrouter', 'qwen', 'eigen']; + +export const STANDARD_VORLAGE: KiVorlage = 'ollama'; + const ROLLEN: readonly KiRolle[] = ['erkennung', 'pruefung', 'tiefencheck']; +/** Unbekannte oder fehlende Vorlage = Ollama. */ +export function kiVorlage(v: unknown): KiVorlage { + return typeof v === 'string' && (KI_VORLAGEN as readonly string[]).includes(v) ? (v as KiVorlage) : STANDARD_VORLAGE; +} + /** Gewählter Anbieter; fehlt er (ältere projekt.md), ist es Foundry. */ export function kiAnbieter(ki: KiEinstellungen | undefined | null): KiAnbieter { - return ki?.anbieter === 'claude' ? 'claude' : 'foundry'; + return ki?.anbieter === 'claude' || ki?.anbieter === 'openai' ? ki.anbieter : 'foundry'; } function modelleMitStandard(m: Partial | undefined): KiModelle { @@ -43,7 +144,20 @@ function modelleMitStandard(m: Partial | undefined): KiModelle { return ergebnis; } -/** Alle Felder beider Anbieter mit Standardwerten (leerer Foundry-Endpoint bleibt leer). */ +/** OpenAI-kompatibel: Modelle ohne Claude-Standard (leer bleibt leer), Basis-URL der Vorlage, wenn keine eingetragen ist. */ +function openAiMitStandards(o: Partial | undefined): KiOpenAiEinstellungen { + const vorlage = kiVorlage(o?.vorlage); + const m = o?.modelle; + const kontext = o?.kontextTokens; + return { + vorlage, + endpoint: o?.endpoint?.trim() || VORLAGEN[vorlage].endpoint, + modelle: { erkennung: m?.erkennung?.trim() ?? '', pruefung: m?.pruefung?.trim() ?? '', tiefencheck: m?.tiefencheck?.trim() ?? '' }, + ...(typeof kontext === 'number' && Number.isFinite(kontext) && kontext > 0 ? { kontextTokens: Math.round(kontext) } : {}), + }; +} + +/** Alle Felder aller Anbieter mit Standardwerten (leerer Foundry-Endpoint bleibt leer). */ export function kiMitStandards(ki: KiEinstellungen | undefined | null): Required { return { endpoint: ki?.endpoint ?? '', @@ -53,17 +167,52 @@ export function kiMitStandards(ki: KiEinstellungen | undefined | null): Required endpoint: ki?.claude?.endpoint?.trim() || CLAUDE_API_ENDPOINT, modelle: modelleMitStandard(ki?.claude?.modelle), }, + openai: openAiMitStandards(ki?.openai), }; } -/** Modell für eine Aufgabe beim gewählten Anbieter (Foundry: Deployment-Name, Claude API: Modell-ID). */ +/** OpenAI-kompatibel: fehlt das Modell einer Rolle, gilt das der Prüfung (sonst irgendeines, sonst ''). */ +function openAiModell(m: KiModelle, rolle: KiRolle): string { + return m[rolle] || m.pruefung || m.tiefencheck || m.erkennung; +} + +/** + * Modell für eine Aufgabe beim gewählten Anbieter (Foundry: Deployment-Name, Claude API: Modell-ID, + * OpenAI-kompatibel: Modellname des Dienstes, '' = keiner eingetragen). + */ export function kiModell(ki: KiEinstellungen | undefined | null, rolle: KiRolle): string { const voll = kiMitStandards(ki); + if (voll.anbieter === 'openai') return openAiModell(voll.openai.modelle, rolle); return voll.anbieter === 'claude' ? voll.claude.modelle[rolle] : voll.deployments[rolle]; } -/** Basis-URL des gewählten Anbieters ('' = Foundry ohne Endpoint). */ +/** Basis-URL des gewählten Anbieters ('' = Foundry bzw. eigene URL ohne Endpoint). */ export function kiEndpoint(ki: KiEinstellungen | undefined | null): string { const voll = kiMitStandards(ki); + if (voll.anbieter === 'openai') return voll.openai.endpoint; return voll.anbieter === 'claude' ? voll.claude.endpoint : voll.endpoint.trim(); } + +/** Name für Texte wie „An {name} gehen nur …“: bei OpenAI-kompatiblen Diensten der Name der Vorlage. */ +export function anbieterName(ki: KiEinstellungen | undefined | null): string { + const voll = kiMitStandards(ki); + return voll.anbieter === 'openai' ? VORLAGEN[voll.openai.vorlage].name : ANBIETER_NAME[voll.anbieter]; +} + +/** + * Kosten nach Listenpreis bekannt? Nur für Claude (Foundry, Claude API). Bei OpenAI-kompatiblen Diensten + * hängen die Preise von Dienst und Modell ab (lokal kostet es nichts); die App nennt dann keinen Betrag. + */ +export function kostenBekannt(ki: KiEinstellungen | undefined | null): boolean { + return kiAnbieter(ki) !== 'openai'; +} + +/** Kosten für Schätzung oder Ergebnis; ohne bekannten Listenpreis 0 mit `kostenBekannt: false`. */ +export function mitKosten(ki: KiEinstellungen | undefined | null, usd: number): { kostenUsd: number; kostenBekannt?: false } { + return kostenBekannt(ki) ? { kostenUsd: usd } : { kostenUsd: 0, kostenBekannt: false }; +} + +/** Für Ablagen (`lauf.kostenUsd`): nur bekannte Kosten speichern. */ +export function bekannteKosten(r: { kostenUsd: number; kostenBekannt?: false }): { kostenUsd?: number } { + return r.kostenBekannt === false ? {} : { kostenUsd: r.kostenUsd }; +} diff --git a/core/ki/aufgabe.ts b/core/ki/aufgabe.ts index dd6e79e..5e1cf5b 100644 --- a/core/ki/aufgabe.ts +++ b/core/ki/aufgabe.ts @@ -12,7 +12,7 @@ import type { Sprache } from '../i18n'; import type { Id, KiEinstellungen, ProjektDaten, Zitat } from '../model/types'; import type { ProjektLeser } from '../projekt/fs'; import { ladeProjekt } from '../projekt/lesen'; -import { kiModell } from './anbieter'; +import { kiModell, mitKosten } from './anbieter'; import { baueAnfrage, EFFORT, MAX_TOKENS, prefixZaehlBody, zaehlBody, type KiRolle } from './anfrage'; import { KiFehler, LEERE_NUTZUNG, leseAntwort, type Nutzung } from './antwort'; import type { KiTransport, Schaetzung } from './index'; @@ -86,6 +86,8 @@ export interface AufgabenErgebnis { tokens: { eingabe: number; ausgabe: number; cacheGelesen: number }; modell: string; kostenUsd: number; + /** false = kein Listenpreis bekannt (OpenAI-kompatible Dienste); kostenUsd ist dann 0 */ + kostenBekannt?: false; /** ISO-Zeitpunkt */ datum: string; } @@ -137,6 +139,7 @@ interface AufgabenLauf { kontext: BuchKontext; modell: string; body: Record; + einstellungen: KiEinstellungen | undefined; } function planeAufgabe( @@ -154,7 +157,7 @@ function planeAufgabe( ...baueAnfrage({ modell, system, kontext, aufgabe, schema: d.schema(kontext), effort: EFFORT[d.rolle] }), max_tokens: d.maxTokens ?? MAX_TOKENS, }; - return { kontext, modell, body }; + return { kontext, modell, body, einstellungen }; } /** Der vollständige Messages-API-Body der Aufgabe (z. B. für Tests oder vorbereitete Antworten). */ @@ -251,7 +254,7 @@ function ergebnisAus( verworfen: [...p.verworfen], tokens: { eingabe: nutzung.eingabe + nutzung.cacheGeschrieben, ausgabe: nutzung.ausgabe, cacheGelesen: nutzung.cacheGelesen }, modell: lauf.modell, - kostenUsd: kostenUsd(nutzung, preisFuer(lauf.modell, d.rolle, lauf.kontext.text.length / 3)), + ...mitKosten(lauf.einstellungen, kostenUsd(nutzung, preisFuer(lauf.modell, d.rolle, lauf.kontext.text.length / 3))), datum: jetzt().toISOString(), }; } @@ -347,7 +350,7 @@ export async function schaetzeAufgabe( eingabeTokens: voll.tokens, prefixTokens, ausgabeTokens: ausgabe, - kostenUsd: kostenUsd({ eingabe: voll.tokens - prefixTokens, ausgabe, cacheGelesen: 0, cacheGeschrieben: prefixTokens }, preis), + ...mitKosten(einstellungen, kostenUsd({ eingabe: voll.tokens - prefixTokens, ausgabe, cacheGelesen: 0, cacheGeschrieben: prefixTokens }, preis)), gezaehlt: voll.gezaehlt && prefix.gezaehlt, }; } diff --git a/core/ki/fragen.ts b/core/ki/fragen.ts index dd952e1..f15025e 100644 --- a/core/ki/fragen.ts +++ b/core/ki/fragen.ts @@ -170,7 +170,7 @@ export function alsBuchFrage(id: Id, frage: string, e: AufgabenErgebnis; @@ -93,6 +107,7 @@ export interface PruefErgebnis { /** Lauf vorzeitig beendet (Fehler oder Abbruch); bis dahin gesammelte Ergebnisse sind enthalten */ fehler?: string; modell?: string; + /** fehlt, wenn die Kosten unbekannt sind (OpenAI-kompatible Dienste) */ kostenUsd?: number; } @@ -137,6 +152,8 @@ export interface Schaetzung { prefixTokens: number; ausgabeTokens: number; kostenUsd: number; + /** false = kein Listenpreis bekannt (OpenAI-kompatible Dienste); kostenUsd ist dann 0 und wird nicht angezeigt */ + kostenBekannt?: false; /** false = count_tokens nicht verfügbar, Schätzung über Zeichen/3 */ gezaehlt: boolean; } @@ -254,7 +271,7 @@ export async function schaetzeLauf( eingabeTokens: voll.tokens * n, prefixTokens, ausgabeTokens: ausgabe, - kostenUsd: kosten, + ...mitKosten(einstellungen, kosten), gezaehlt: voll.gezaehlt && prefix.gezaehlt, }; } @@ -361,7 +378,8 @@ export async function pruefe( optionen.fortschritt?.({ erledigt: ergebnis.geprueft!.length, gesamt, kapitel: null, titel: '' }); ergebnis.bereitsEntschieden = entschiedenGesehen.size; ergebnis.tokens = { eingabe: nutzung.eingabe + nutzung.cacheGeschrieben, ausgabe: nutzung.ausgabe, cacheGelesen: nutzung.cacheGelesen }; - ergebnis.kostenUsd = kostenUsd(nutzung, preisFuer(lauf.modell, lauf.rolle, lauf.kontext.text.length / 3)); + if (kostenBekannt(einstellungen)) ergebnis.kostenUsd = kostenUsd(nutzung, preisFuer(lauf.modell, lauf.rolle, lauf.kontext.text.length / 3)); + else delete ergebnis.kostenUsd; return ergebnis; } diff --git a/core/ki/openai.test.ts b/core/ki/openai.test.ts new file mode 100644 index 0000000..187adba --- /dev/null +++ b/core/ki/openai.test.ts @@ -0,0 +1,204 @@ +/** + * OpenAI-kompatible Dienste (E15): Übersetzung Messages-API ⇄ Chat Completions, und ein ganzer Prüflauf + * über die Übersetzung mit Demo-Antworten (wie ein lokales Denkmodell: -Block und ```-Zaun). + */ +import { demoDateien } from '../../fixtures/demoProjekt'; +import { erstelleDemoKiTransport } from '../../src/backend/ki/demo'; +import type { KiEinstellungen } from '../model/types'; +import { MemoryFs, PFADE } from '../projekt'; +import { baueAnfrage, MAX_TOKENS } from './anfrage'; +import { + jsonAusText, + KONTEXT_RESERVE, + nachOpenAi, + passtInKontext, + pruefe, + schaetzeLauf, + STANDARD_KI, + VORLAGEN, + vonOpenAi, + type KiTransport, +} from './index'; + +type Body = Record; + +const SCHEMA = { type: 'object', additionalProperties: false, properties: { a: { type: 'string' } }, required: ['a'] }; + +function anfrage(): Body { + return baueAnfrage({ + modell: 'qwen3:32b', + system: 'Du prüfst.', + kontext: { text: 'Text' } as never, + aufgabe: 'Prüfe.', + schema: SCHEMA, + effort: 'medium', + }); +} + +describe('nachOpenAi', () => { + it('Ollama: system zuerst, Buch vor Aufgabe in einer Nachricht, Schema als json_schema strict', () => { + const b = nachOpenAi(anfrage(), VORLAGEN.ollama); + expect(b).toEqual({ + model: 'qwen3:32b', + messages: [ + { role: 'system', content: 'Du prüfst.' }, + { role: 'user', content: 'Text\n\nPrüfe.' }, + ], + max_tokens: MAX_TOKENS, + stream: false, + response_format: { type: 'json_schema', json_schema: { name: 'antwort', strict: true, schema: SCHEMA } }, + }); + // nichts Anthropic-Spezifisches + const text = JSON.stringify(b); + for (const feld of ['cache_control', 'output_config', 'fallbacks', 'thinking']) expect(text).not.toContain(feld); + }); + + it('OpenAI: max_completion_tokens und reasoning_effort', () => { + const b = nachOpenAi(anfrage(), VORLAGEN.openai); + expect(b.max_completion_tokens).toBe(MAX_TOKENS); + expect(b.max_tokens).toBeUndefined(); + expect(b.reasoning_effort).toBe('medium'); + }); + + it('OpenRouter: reasoning.effort und Anbieter nur mit Schema-Unterstützung, ohne Datenspeicherung', () => { + const b = nachOpenAi(anfrage(), VORLAGEN.openrouter); + expect(b.reasoning).toEqual({ effort: 'medium' }); + expect(b.provider).toEqual({ require_parameters: true, data_collection: 'deny' }); + }); + + it('json_object (Qwen): Schema steht im System-Prompt', () => { + const b = nachOpenAi(anfrage(), VORLAGEN.qwen); + expect(b.response_format).toEqual({ type: 'json_object' }); + const system = (b.messages as { role: string; content: string }[])[0]!; + expect(system.role).toBe('system'); + expect(system.content.startsWith('Du prüfst.')).toBe(true); + expect(system.content).toContain('JSON'); + expect(system.content).toContain(JSON.stringify(SCHEMA)); + }); + + it('Bodys ohne Schema (z. B. zum Zählen) bekommen kein response_format', () => { + const b = nachOpenAi({ model: 'm', system: [{ type: 'text', text: 's' }], messages: [{ role: 'user', content: 'x' }] }, VORLAGEN.ollama); + expect(b.response_format).toBeUndefined(); + expect(b.messages).toEqual([ + { role: 'system', content: 's' }, + { role: 'user', content: 'x' }, + ]); + }); +}); + +describe('vonOpenAi', () => { + const antwort = (content: unknown, finish = 'stop', extra: Body = {}): Body => ({ + id: 'c1', + model: 'qwen3:32b', + choices: [{ index: 0, message: { role: 'assistant', content, ...extra }, finish_reason: finish }], + usage: { prompt_tokens: 1000, completion_tokens: 50, prompt_tokens_details: { cached_tokens: 800 } }, + }); + + it('übersetzt Text, stop_reason und Nutzung (gecachte Tokens getrennt)', () => { + expect(vonOpenAi(antwort('{"a":"x"}'))).toEqual({ + type: 'message', + role: 'assistant', + model: 'qwen3:32b', + content: [{ type: 'text', text: '{"a":"x"}' }], + stop_reason: 'end_turn', + usage: { input_tokens: 200, output_tokens: 50, cache_read_input_tokens: 800, cache_creation_input_tokens: 0 }, + }); + }); + + it('length → max_tokens, content_filter und refusal → refusal', () => { + expect(vonOpenAi(antwort('{', 'length')).stop_reason).toBe('max_tokens'); + expect(vonOpenAi(antwort(null, 'content_filter')).stop_reason).toBe('refusal'); + expect(vonOpenAi(antwort(null, 'stop', { refusal: 'Nein.' })).stop_reason).toBe('refusal'); + expect(vonOpenAi({ choices: [] }).stop_reason).toBe('keine_antwort'); + }); + + it('Fehler-JSON (OpenAI und Ollama) → Messages-API-Fehler', () => { + expect(vonOpenAi({ error: { message: 'model not found', type: 'invalid_request_error' } })).toEqual({ + type: 'error', + error: { type: 'invalid_request_error', message: 'model not found' }, + }); + expect(vonOpenAi({ error: 'model "x" not found' })).toEqual({ type: 'error', error: { type: 'error', message: 'model "x" not found' } }); + }); + + it('jsonAusText: ohne , ohne Zaun, ohne Text drumherum', () => { + expect(jsonAusText('erst denken {nicht das}\n{"a":1}')).toBe('{"a":1}'); + expect(jsonAusText('```json\n{"a":1}\n```')).toBe('{"a":1}'); + expect(jsonAusText('Hier ist das Ergebnis: {"a":{"b":2}} – fertig.')).toBe('{"a":{"b":2}}'); + expect(jsonAusText(' {"a":"bleibt"} ')).toBe('{"a":"bleibt"}'); + }); +}); + +describe('Kontextlänge', () => { + it('lehnt ab, was samt Reserve nicht in den Kontext passt; ohne Angabe wird nicht geprüft', () => { + const b = nachOpenAi(anfrage(), VORLAGEN.ollama); + const { tokens } = passtInKontext(b, undefined); + expect(passtInKontext(b, undefined).passt).toBe(true); + expect(passtInKontext(b, tokens + KONTEXT_RESERVE).passt).toBe(true); + expect(passtInKontext(b, tokens + KONTEXT_RESERVE - 1).passt).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- + +const OLLAMA: KiEinstellungen = { + ...STANDARD_KI, + anbieter: 'openai', + openai: { vorlage: 'ollama', endpoint: '', modelle: { erkennung: '', pruefung: 'qwen3:32b', tiefencheck: '' } }, +}; + +/** Demo-Antworten, aber über die Übersetzung – wie ein lokales Denkmodell verpackt. Ohne count_tokens. */ +function ueberOpenAi(): KiTransport & { gesendet: Body[] } { + const demo = erstelleDemoKiTransport({ verzoegerungMs: 0 }); + const gesendet: Body[] = []; + return { + gesendet, + bereit: async () => true, + async senden(body) { + const oa = nachOpenAi(body, VORLAGEN.ollama); + gesendet.push(oa); + const m = await demo.senden(body); + const text = (m.content as { text: string }[])[0]!.text; + return vonOpenAi({ + model: oa.model, + choices: [{ index: 0, message: { role: 'assistant', content: `Ich lese.\n\`\`\`json\n${text}\n\`\`\`` }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1000, completion_tokens: 50, prompt_tokens_details: { cached_tokens: 800 } }, + }); + }, + async tokenZaehlen() { + throw new Error('count_tokens gibt es hier nicht'); + }, + }; +} + +function frischFs(): MemoryFs { + const d = demoDateien(); + for (const p of Object.keys(d)) if (p.startsWith(`${PFADE.analysen}/`)) delete d[p]; + return new MemoryFs(d); +} + +describe('Prüflauf über einen OpenAI-kompatiblen Dienst', () => { + it('liefert dieselben Hinweise wie mit Claude; Kosten unbekannt, Cache aus der Nutzung', async () => { + const direkt = await pruefe(frischFs(), { umfang: 'buch' }, erstelleDemoKiTransport({ verzoegerungMs: 0 }), STANDARD_KI); + const t = ueberOpenAi(); + const e = await pruefe(frischFs(), { umfang: 'buch' }, t, OLLAMA); + + expect(e.fehler).toBeUndefined(); + expect(e.hinweise.map((h) => h.schluessel).sort()).toEqual(direkt.hinweise.map((h) => h.schluessel).sort()); + expect(e.analysen).toHaveLength(direkt.analysen.length); + expect(e.modell).toBe('qwen3:32b'); + expect(e.kostenUsd).toBeUndefined(); + expect(e.tokens.cacheGelesen).toBe(800 * t.gesendet.length); + for (const b of t.gesendet) { + expect(b.model).toBe('qwen3:32b'); + expect(b.response_format).toMatchObject({ type: 'json_schema' }); + } + }); + + it('Schätzung ohne count_tokens: über die Zeichenzahl, Kosten unbekannt', async () => { + const s = await schaetzeLauf(frischFs(), { umfang: 'buch' }, ueberOpenAi(), OLLAMA); + expect(s.gezaehlt).toBe(false); + expect(s.kostenBekannt).toBe(false); + expect(s.kostenUsd).toBe(0); + expect(s.modell).toBe('qwen3:32b'); + }); +}); diff --git a/core/ki/openai.ts b/core/ki/openai.ts new file mode 100644 index 0000000..441f135 --- /dev/null +++ b/core/ki/openai.ts @@ -0,0 +1,138 @@ +/** + * Übersetzer Messages-API ⇄ OpenAI Chat Completions (E15). core/ki baut weiter Messages-API-Bodys und + * prüft Messages-API-Antworten; für OpenAI-kompatible Dienste (OpenAI, OpenRouter, Qwen, Ollama, + * LM Studio …) übersetzt der Transport davor und danach. Prompt, Allowlist und Antwortprüfung bleiben + * damit für alle Anbieter dieselben. + * + * - system-Blöcke → Nachricht `system`; Inhaltsblöcke → ein Text (cache_control entfällt: OpenAI-kompatible + * Dienste cachen gleiche Anfänge selbst, und das Buch bleibt der feste Anfang). + * - output_config.format → response_format (json_schema strict; bei `json_object` steht das Schema im Prompt). + * - output_config.effort → reasoning_effort bzw. reasoning.effort, wo die Vorlage es vorsieht. + * - Antwort: finish_reason → stop_reason, usage → input/output/cache_read_input_tokens, Text → JSON + * (ohne vorangestelltes … und ohne ```-Zaun). + */ +import { MAX_TOKENS } from './anfrage'; +import type { VorlageInfo } from './anbieter'; + +/** Ausgabe-Tokens, die im Kontext des Modells frei bleiben müssen (Antwort samt Denken, grob). */ +export const KONTEXT_RESERVE = 4000; + +type Roh = Record; + +function istObjekt(v: unknown): v is Roh { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** Text aus einem String oder einer Liste von Text-Blöcken (Messages-API wie Chat Completions). */ +function textAus(inhalt: unknown): string { + if (typeof inhalt === 'string') return inhalt; + if (!Array.isArray(inhalt)) return ''; + return inhalt + .filter((b): b is Roh => istObjekt(b) && b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text as string) + .join('\n\n'); +} + +/** Messages-API-Body → Chat-Completions-Body für die Vorlage. */ +export function nachOpenAi(body: Roh, vorlage: VorlageInfo): Roh { + const ausgabe = istObjekt(body.output_config) ? body.output_config : {}; + const format = istObjekt(ausgabe.format) ? ausgabe.format : undefined; + const schema = format?.type === 'json_schema' && istObjekt(format.schema) ? format.schema : undefined; + + let system = textAus(body.system); + if (schema && vorlage.strukturiert === 'json_object') { + system += `\n\nRespond only with one JSON object that matches this JSON Schema, without any other text:\n${JSON.stringify(schema)}`; + } + const nachrichten: Roh[] = []; + if (system.trim()) nachrichten.push({ role: 'system', content: system }); + for (const n of Array.isArray(body.messages) ? body.messages : []) { + if (!istObjekt(n)) continue; + nachrichten.push({ role: n.role === 'assistant' ? 'assistant' : 'user', content: textAus(n.content) }); + } + + const ergebnis: Roh = { + model: body.model, + messages: nachrichten, + [vorlage.maxTokensFeld]: typeof body.max_tokens === 'number' ? body.max_tokens : MAX_TOKENS, + stream: false, + }; + if (schema) { + ergebnis.response_format = + vorlage.strukturiert === 'json_schema' + ? { type: 'json_schema', json_schema: { name: 'antwort', strict: true, schema } } + : { type: 'json_object' }; + } + const effort = typeof ausgabe.effort === 'string' ? ausgabe.effort : undefined; + if (effort && vorlage.effort === 'reasoning_effort') ergebnis.reasoning_effort = effort; + if (effort && vorlage.effort === 'reasoning') ergebnis.reasoning = { effort }; + return { ...ergebnis, ...(vorlage.zusatz ?? {}) }; +} + +/** + * JSON aus dem Antworttext: ohne vorangestelltes … (lokale Denkmodelle) und ohne + * ```json-Zaun; steht davor oder danach noch Text, zählt der Teil vom ersten { bis zur letzten }. + */ +export function jsonAusText(text: string): string { + let t = text.replace(/^\s*[\s\S]*?<\/think>/, '').trim(); + const zaun = /^```[a-zA-Z]*\s*\n([\s\S]*?)\n?```$/.exec(t); + if (zaun) t = zaun[1]!.trim(); + if (!t.startsWith('{') && !t.startsWith('[')) { + const anfang = t.indexOf('{'); + const ende = t.lastIndexOf('}'); + if (anfang >= 0 && ende > anfang) t = t.slice(anfang, ende + 1); + } + return t; +} + +function zahl(v: unknown): number { + return typeof v === 'number' && Number.isFinite(v) ? v : 0; +} + +const STOP: Record = { + stop: 'end_turn', + length: 'max_tokens', + content_filter: 'refusal', +}; + +/** Chat-Completions-Antwort → Messages-API-Antwort (für leseAntwort). */ +export function vonOpenAi(roh: Roh): Roh { + if (roh.error !== undefined && roh.error !== null) { + const f = roh.error; + const meldung = typeof f === 'string' ? f : istObjekt(f) && typeof f.message === 'string' ? f.message : JSON.stringify(f); + const typ = istObjekt(f) ? String(f.type ?? f.code ?? 'error') : 'error'; + return { type: 'error', error: { type: typ, message: meldung } }; + } + const wahl = Array.isArray(roh.choices) && istObjekt(roh.choices[0]) ? roh.choices[0] : undefined; + const nachricht = wahl && istObjekt(wahl.message) ? wahl.message : {}; + const u = istObjekt(roh.usage) ? roh.usage : {}; + const details = istObjekt(u.prompt_tokens_details) ? u.prompt_tokens_details : {}; + const gecacht = zahl(details.cached_tokens); + const usage = { + input_tokens: Math.max(0, zahl(u.prompt_tokens) - gecacht), + output_tokens: zahl(u.completion_tokens), + cache_read_input_tokens: gecacht, + cache_creation_input_tokens: 0, + }; + const abgelehnt = typeof nachricht.refusal === 'string' && nachricht.refusal.trim() !== ''; + const grund = typeof wahl?.finish_reason === 'string' ? wahl.finish_reason : 'keine_antwort'; + const text = textAus(nachricht.content); + return { + type: 'message', + role: 'assistant', + model: roh.model, + content: text ? [{ type: 'text', text: jsonAusText(text) }] : [], + stop_reason: abgelehnt ? 'refusal' : (STOP[grund] ?? grund), + usage, + }; +} + +/** Grobe Schätzung der Eingabe-Tokens eines Chat-Completions-Bodys (Zeichen/3 wie ohne count_tokens, eher zu hoch). */ +export function eingabeTokensSchaetzen(body: Roh): number { + return Math.ceil(JSON.stringify(body.messages ?? []).length / 3); +} + +/** Passt die Anfrage samt KONTEXT_RESERVE in `kontextTokens`? Ohne Angabe wird nicht geprüft. */ +export function passtInKontext(body: Roh, kontextTokens: number | undefined): { passt: boolean; tokens: number } { + const tokens = eingabeTokensSchaetzen(body); + return { passt: !kontextTokens || tokens + KONTEXT_RESERVE <= kontextTokens, tokens }; +} diff --git a/core/ki/vorschlaege.ts b/core/ki/vorschlaege.ts index 1adf1db..7e67cfc 100644 --- a/core/ki/vorschlaege.ts +++ b/core/ki/vorschlaege.ts @@ -29,7 +29,7 @@ import { ladeProjekt } from '../projekt/lesen'; import { normalisiere } from '../text'; import { baueAnfrage, EFFORT, prefixZaehlBody, zaehlBody } from './anfrage'; import { KiFehler, LEERE_NUTZUNG, leseAntwort, type Nutzung } from './antwort'; -import { kiModell } from './anbieter'; +import { kiModell, mitKosten } from './anbieter'; import { kostenUsd, preisFuer } from './kosten'; import { meldungenIn } from './meldungen'; import { baueBuchKontext, type BuchKontext, type KontextFigur, type KontextOrt } from './kontext'; @@ -212,6 +212,7 @@ interface VorschlagsLauf { rolle: Rolle; modell: string; body: Record; + einstellungen: KiEinstellungen | undefined; } function modellFuer(einstellungen: KiEinstellungen | undefined, rolle: Rolle): string { @@ -235,7 +236,7 @@ function planeVorschlaege(daten: ProjektDaten, einstellungen: KiEinstellungen | }), max_tokens: VORSCHLAG_MAX_TOKENS, }; - return { daten, kontext, rolle, modell, body }; + return { daten, kontext, rolle, modell, body, einstellungen }; } /** Der vollständige Messages-API-Body, wie er an das Modell ginge (z. B. um Antworten vorzubereiten). */ @@ -279,7 +280,7 @@ export async function schaetzeVorschlaege( eingabeTokens: voll.tokens, prefixTokens, ausgabeTokens: ausgabe, - kostenUsd: kostenUsd({ eingabe: voll.tokens - prefixTokens, ausgabe, cacheGelesen: 0, cacheGeschrieben: prefixTokens }, preis), + ...mitKosten(einstellungen, kostenUsd({ eingabe: voll.tokens - prefixTokens, ausgabe, cacheGelesen: 0, cacheGeschrieben: prefixTokens }, preis)), gezaehlt: voll.gezaehlt && prefix.gezaehlt, }; } @@ -301,6 +302,8 @@ export interface VorschlagsErgebnis { tokens: { eingabe: number; ausgabe: number; cacheGelesen: number }; modell: string; kostenUsd: number; + /** false = kein Listenpreis bekannt (OpenAI-kompatible Dienste); kostenUsd ist dann 0 */ + kostenBekannt?: false; /** ISO-Zeitpunkt des Laufs */ datum: string; } @@ -321,7 +324,7 @@ function ergebnisAus(lauf: VorschlagsLauf, roh: unknown, nutzung: Nutzung, jetzt ...geprueft, tokens: { eingabe: nutzung.eingabe + nutzung.cacheGeschrieben, ausgabe: nutzung.ausgabe, cacheGelesen: nutzung.cacheGelesen }, modell: lauf.modell, - kostenUsd: kostenUsd(nutzung, preisFuer(lauf.modell, lauf.rolle, lauf.kontext.text.length / 3)), + ...mitKosten(lauf.einstellungen, kostenUsd(nutzung, preisFuer(lauf.modell, lauf.rolle, lauf.kontext.text.length / 3))), datum: jetzt().toISOString(), }; } diff --git a/core/model/types.ts b/core/model/types.ts index 65bbf80..b6929cb 100644 --- a/core/model/types.ts +++ b/core/model/types.ts @@ -18,8 +18,14 @@ export type Realitaet = 'gesichert' | 'offen' | 'bewusst_vage'; // Projekt (projekt.md) // --------------------------------------------------------------------------- -/** KI-Anbieter: Claude in Microsoft Foundry oder die Claude API von Anthropic direkt. */ -export type KiAnbieter = 'foundry' | 'claude'; +/** + * KI-Anbieter: Claude in Microsoft Foundry, die Claude API von Anthropic direkt oder ein Dienst mit + * OpenAI-kompatibler Chat-Completions-API (OpenAI, OpenRouter, Qwen, Ollama, LM Studio …). + */ +export type KiAnbieter = 'foundry' | 'claude' | 'openai'; + +/** Vorlage für den OpenAI-kompatiblen Anbieter: bestimmt Standard-URL und kleine Unterschiede im Request. */ +export type KiVorlage = 'openai' | 'openrouter' | 'qwen' | 'ollama' | 'lmstudio' | 'eigen'; /** Modell je Aufgabe (Foundry: Deployment-Namen, Claude API: Modell-IDs). */ export interface KiModelle { @@ -39,6 +45,17 @@ export interface KiClaudeEinstellungen { modelle: KiModelle; } +/** OpenAI-kompatibler Dienst. Schlüssel (optional, lokal meist keiner) im Schlüsselbund, Konto „openai-kompatibel“. */ +export interface KiOpenAiEinstellungen { + vorlage: KiVorlage; + /** Basis-URL ohne /chat/completions, z. B. http://localhost:11434/v1 */ + endpoint: string; + /** Modellnamen des Dienstes; leer = das Modell einer anderen Rolle (kein Claude-Standard) */ + modelle: KiModelle; + /** Kontextlänge des Modells in Tokens; größere Anfragen werden vor dem Senden abgelehnt statt still gekürzt */ + kontextTokens?: number; +} + export interface KiEinstellungen { /** Foundry: z. B. https://.services.ai.azure.com/anthropic – ohne /v1/messages */ endpoint: string; @@ -55,6 +72,8 @@ export interface KiEinstellungen { anbieter?: KiAnbieter; /** Einstellungen der Claude API; bleiben erhalten, solange Foundry gewählt ist. */ claude?: KiClaudeEinstellungen; + /** Einstellungen des OpenAI-kompatiblen Dienstes; bleiben erhalten, solange ein anderer Anbieter gewählt ist. */ + openai?: KiOpenAiEinstellungen; } export interface Projekt { diff --git a/core/projekt/lesen.ts b/core/projekt/lesen.ts index 3e3a7e3..c3ff1f9 100644 --- a/core/projekt/lesen.ts +++ b/core/projekt/lesen.ts @@ -45,6 +45,7 @@ import type { KapitelAnalyse, KapitelPlan, KiEinstellungen, + KiOpenAiEinstellungen, Motiv, Ort, Projekt, @@ -294,15 +295,31 @@ function parseKiModelle(v: unknown): KiEinstellungen['deployments'] { }; } -/** Ältere projekt.md ohne `anbieter`/`claude` bleiben gültig (gelten als Foundry). */ +/** Vorlagen des OpenAI-kompatiblen Anbieters (= KiVorlage; Standard Ollama wie in core/ki/anbieter.ts). */ +const KI_VORLAGEN = ['ollama', 'lmstudio', 'openai', 'openrouter', 'qwen', 'eigen'] as const; + +/** OpenAI-kompatibel: Modellnamen ohne Claude-Standard (leer bleibt leer). */ +function parseOpenAi(v: Record): KiOpenAiEinstellungen { + const m = istObjekt(v.modelle) ? v.modelle : {}; + const kontext = zahl(v.kontextTokens, 0); + return ohneUndefined({ + vorlage: eines(v.vorlage, KI_VORLAGEN, 'ollama'), + endpoint: s(v.endpoint), + modelle: { erkennung: s(m.erkennung), pruefung: s(m.pruefung), tiefencheck: s(m.tiefencheck) }, + kontextTokens: kontext > 0 ? Math.round(kontext) : undefined, + }); +} + +/** Ältere projekt.md ohne `anbieter`/`claude`/`openai` bleiben gültig (gelten als Foundry). */ function parseKi(v: unknown): KiEinstellungen | undefined { if (!istObjekt(v)) return undefined; const c = istObjekt(v.claude) ? v.claude : undefined; return ohneUndefined({ endpoint: s(v.endpoint), deployments: parseKiModelle(v.deployments), - anbieter: v.anbieter === 'claude' ? 'claude' : v.anbieter === 'foundry' ? 'foundry' : undefined, + anbieter: v.anbieter === 'claude' || v.anbieter === 'foundry' || v.anbieter === 'openai' ? v.anbieter : undefined, claude: c ? { endpoint: s(c.endpoint), modelle: parseKiModelle(c.modelle) } : undefined, + openai: istObjekt(v.openai) ? parseOpenAi(v.openai) : undefined, }); } diff --git a/core/projekt/schreiben.ts b/core/projekt/schreiben.ts index 49713ec..00d1be6 100644 --- a/core/projekt/schreiben.ts +++ b/core/projekt/schreiben.ts @@ -61,9 +61,10 @@ import { const S = { projekt: ['version', 'titel', 'kapitel', 'ki', 'angaben'], angaben: ['autor', 'untertitel', 'genre', 'sprache'], - ki: ['endpoint', 'deployments', 'anbieter', 'claude'], + ki: ['endpoint', 'deployments', 'anbieter', 'claude', 'openai'], deployments: ['erkennung', 'pruefung', 'tiefencheck'], claude: ['endpoint', 'modelle'], + openai: ['vorlage', 'endpoint', 'modelle', 'kontextTokens'], kapitel: ['id', 'titel'], plan: ['id', 'titel', 'akt', 'status', 'szene', 'straenge', 'figuren', 'orte', 'abweichungen', 'realitaet', 'zeit', 'szenen'], zeitpunkt: ['tag', 'wochentag', 'datum', 'tageszeit', 'uhrzeit', 'dauer', 'sicherheit', 'beleg', 'notiz'], @@ -149,9 +150,20 @@ export function serializeProjekt(p: Projekt, vorhanden?: string | null): string const claude = p.ki?.claude ? ordne({ endpoint: p.ki.claude.endpoint, modelle: ordne(p.ki.claude.modelle, S.deployments, altClaude?.modelle) }, S.claude, altClaude) : altClaude; + // Ebenso die Einstellungen des OpenAI-kompatiblen Dienstes. + const altOpenAi = altKi && istObjekt(altKi.openai) ? altKi.openai : undefined; + const openai = p.ki?.openai + ? ordne({ ...p.ki.openai, modelle: ordne(p.ki.openai.modelle, S.deployments, altOpenAi?.modelle) }, S.openai, altOpenAi) + : altOpenAi; const ki = p.ki ? ordne( - { endpoint: p.ki.endpoint, deployments: ordne(p.ki.deployments, S.deployments, altKi?.deployments), anbieter: p.ki.anbieter, claude }, + { + endpoint: p.ki.endpoint, + deployments: ordne(p.ki.deployments, S.deployments, altKi?.deployments), + anbieter: p.ki.anbieter, + claude, + openai, + }, S.ki, altKi, ) diff --git a/docs/architektur.md b/docs/architektur.md index 20e79e4..a4000a0 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -22,6 +22,7 @@ Design: `docs/design/screens/*.dc.html` (verbindlich, Pixelwerte und Farben exak | E12 | **Allowlist** für alles, was an das Modell geht: Kapiteltext, Kapiteltitel, Figuren-/Ortsnamen + Aliase, Gewissheit von Figuren und Stellen (`existenz`/`realitaet`), Gruppen-Namen, **Plan-Felder von Figuren (`plan`) und Kapitelplänen (Szene, „Was soll hier passieren?“)** – freigegeben von Leon am 2026-10-09 für F-05. Nie: `wahrheit`, `notizen`, `kurz`, Ortsbeschreibungen, Abweichungen, Akte, Stränge, Anmerkungen, Ideen. Werkzeuge (2026-10-10): Die **Namen der Zustandsarten** (`plan/zeitleiste.md`, z. B. „nüchtern“, „betrunken“) gehen als Antwortvokabular der Zustands-Aufgabe (G1) mit – nur die Namen, keine eingetragenen Zustände, Zeitpunkte oder Notizen; von Leon am 2026-10-11 bestätigt. | private Felder können nicht durchrutschen; Plan ↔ Text-Abgleich (F-05) | | E13 | **Ganzes Buch als gecachter Prefix** (Prompt Caching) + Aufgaben pro Kapitel; Ergebnis-Cache pro Kapitel-Text-Hash. Token-Schätzung über `count_tokens`. | einfacher, findet kapitelübergreifende Widersprüche | | E14 | **Zwei KI-Anbieter, Wahl in den Einstellungen** (`projekt.ki.anbieter`, fehlt = Foundry): **Microsoft Foundry** (Endpoint `https://.services.ai.azure.com/anthropic`, eingetragen werden Deployment-Namen; Schlüsselbund-Konto `azure-foundry`) oder **Claude API** von Anthropic (Basis-URL `https://api.anthropic.com`, überschreibbar; Modell-IDs; Konto `anthropic-api`). Die Einstellungen des anderen Anbieters bleiben beim Umschalten erhalten. Modelle: `claude-haiku-5-5` (Erkennung), `claude-sonnet-5-5` (Prüfung), `claude-opus-5-5` (Tiefencheck). Nur Claude API: bei Sonnet/Opus 5.5 serverseitiger Fallback `fallbacks: "default"` (Beta `server-side-fallback-2026-07-01`), gesetzt in der Transportschicht. Hosting/Region egal (Leon akzeptiert Verarbeitung in den USA). | Leons Vorgabe | +| E15 | **Dritter Anbieter: OpenAI-kompatibel** (`projekt.ki.anbieter: openai`, Block `ki.openai{vorlage, endpoint, modelle, kontextTokens?}`). Vorlagen Ollama (Standard, `http://localhost:11434/v1`), LM Studio (`:1234/v1`), OpenAI, OpenRouter, Qwen (Alibaba Cloud), eigene URL (`core/ki/anbieter.ts`, `VORLAGEN`). `core/ki` baut weiter Messages-API-Bodys; der Transport übersetzt nach Chat Completions und zurück (`core/ki/openai.ts`: `nachOpenAi`/`vonOpenAi`, Schema als `response_format` json_schema strict, bei Qwen json_object mit Schema im System-Prompt), der Rust-Kern schickt an `{endpoint}/chat/completions` mit `Authorization: Bearer` (Schlüssel optional, Konto `openai-kompatibel`, http nur für localhost, Zeitlimit 30 min). Modelle ohne Claude-Standard (leere Rolle = Prüfmodell). Kein `count_tokens` (Schätzung Zeichen/3), keine Listenpreise (Kosten „keine Angabe“, Ablagen ohne `kostenUsd`). Ist `kontextTokens` gesetzt, lehnt der Transport Anfragen ab, die samt 4 000 Tokens Reserve nicht hineinpassen – lokale Server kürzen sonst still. Andere Modelle als Claude sind nicht abgestimmt; die Zitatprüfung (E6) verwirft, was nicht wörtlich zitiert. | Leons Vorgabe: lokale und andere Modelle | ## Phasen @@ -36,7 +37,7 @@ Design: `docs/design/screens/*.dc.html` (verbindlich, Pixelwerte und Farben exak ``` / -├── projekt.md Frontmatter: version, titel, kapitel[], ki{endpoint, deployments, anbieter, claude{endpoint, modelle}} +├── projekt.md Frontmatter: version, titel, kapitel[], ki{endpoint, deployments, anbieter, claude{endpoint, modelle}, openai{vorlage, endpoint, modelle, kontextTokens}} ├── manuskript/.md Frontmatter: id, titel · Body: Markdown ├── plan/akte.md Frontmatter: akte[] ├── plan/straenge.md Frontmatter: straenge[] @@ -154,9 +155,9 @@ Zeitpunkte, Zustände, Bögen, Marken, Recherche und Lektorat gehören nicht daz - `ProjektLeser` / `ProjektDateisystem`: `core/projekt/fs.ts` - `core/projekt`: `ladeProjekt(leser): Promise`, `speichereKapitel(fs, kapitel)`, `speicherePlan(fs, plan)`, … je Dateityp ein Paar `parseX(text)` / `serializeX(obj)`; `schnappschuss(fs, pfad, zeit)`. -- `KiTransport` (`core/ki`): `senden(body: object): Promise` (Messages-API-Body → Antwort-JSON), `tokenZaehlen(body): Promise`, `bereit(): Promise`. +- `KiTransport` (`core/ki`): `senden(body: object): Promise` (Messages-API-Body → Antwort-JSON; OpenAI-kompatibel übersetzt der Transport, E15), `tokenZaehlen(body): Promise`, `bereit(): Promise`. - Store: `useStore` (zustand). Slices bekommen `(set, get)` und lesen `get().daten` (ProjektDaten) und `get().fs`. -- Rust-Kommandos (camelCase-Argumente via Tauri): `projekt_oeffnen(pfad)`, `fs_lesen(pfad)`, `fs_schreiben(pfad, inhalt)`, `fs_auflisten(ordner)`, `fs_existiert(pfad)`, `fs_entfernen(pfad)`, `vorlesen_stimmen()`, `vorlesen_sagen(text, stimme?, tempo?)` → `"fertig"`/`"abgebrochen"`, `vorlesen_stopp()` (macOS `say`, Text über stdin; in der Mac-App immer statt der Web Speech API), `link_oeffnen(url)` (nur http/https), `schluessel_setzen(wert, anbieter?)`, `schluessel_vorhanden(anbieter?)`, `schluessel_loeschen(anbieter?)`, `ki_anfrage(endpoint, body, anbieter?)`, `ki_tokens(endpoint, body, anbieter?)` (`anbieter`: `"foundry"` (Standard) oder `"claude"`). Alle `fs_*` arbeiten relativ zum geöffneten Projekt und lehnen `..`/absolute Pfade ab. +- Rust-Kommandos (camelCase-Argumente via Tauri): `projekt_oeffnen(pfad)`, `fs_lesen(pfad)`, `fs_schreiben(pfad, inhalt)`, `fs_auflisten(ordner)`, `fs_existiert(pfad)`, `fs_entfernen(pfad)`, `vorlesen_stimmen()`, `vorlesen_sagen(text, stimme?, tempo?)` → `"fertig"`/`"abgebrochen"`, `vorlesen_stopp()` (macOS `say`, Text über stdin; in der Mac-App immer statt der Web Speech API), `link_oeffnen(url)` (nur http/https), `schluessel_setzen(wert, anbieter?)`, `schluessel_vorhanden(anbieter?)`, `schluessel_loeschen(anbieter?)`, `ki_anfrage(endpoint, body, anbieter?)`, `ki_tokens(endpoint, body, anbieter?)` (`anbieter`: `"foundry"` (Standard), `"claude"` oder `"openai"`; bei `"openai"` geht `ki_anfrage` an `/chat/completions`, `ki_tokens` gibt es nicht). Alle `fs_*` arbeiten relativ zum geöffneten Projekt und lehnen `..`/absolute Pfade ab. - Binärdateien (G8): `fs_lesen_binaer(kennung?, pfad)` → rohe Bytes (`ipc::Response`, in TS `ArrayBuffer`), `fs_schreiben_binaer` mit rohem Body und Kopfzeilen `plotwerk-pfad` (URI-kodiert) und `plotwerk-kennung`. Nur unter `recherche/` und `.versionen/recherche/` (`dateien::binaer_erlaubt` = `binaerPfadErlaubt` in TS), atomar, gleicher Pfadwächter. TS: optionale Methoden `lesenBinaer`/`schreibenBinaer` an `ProjektDateisystem` (TauriFs, MemoryFs), Aufruf über `leseBinaer`/`schreibeBinaer`/`entferneBinaerMitSchnappschuss` (core/projekt) bzw. Store `leseBinaerDatei`/`speichereBinaerDatei`/`entferneBinaerDatei`. ## Sprachen (i18n, ab 2026-10-12) diff --git a/src-tauri/crates/plotwerk-ki/src/lib.rs b/src-tauri/crates/plotwerk-ki/src/lib.rs index 97d96db..5327d38 100644 --- a/src-tauri/crates/plotwerk-ki/src/lib.rs +++ b/src-tauri/crates/plotwerk-ki/src/lib.rs @@ -1,16 +1,18 @@ -//! HTTP-Transport zur Messages-API: Claude in Microsoft Foundry oder die Claude API von -//! Anthropic direkt (Anbieter wählt Leon in den Einstellungen). +//! HTTP-Transport zur KI: Messages-API (Claude in Microsoft Foundry oder die Claude API von +//! Anthropic direkt) oder OpenAI-kompatible Chat-Completions-API (OpenAI, OpenRouter, Ollama, +//! LM Studio …). Den Anbieter wählt Leon in den Einstellungen. //! //! Dieses Crate hat bewusst keinen Dateizugriff (siehe `clippy.toml` und //! `tests/kein_dateizugriff.rs`): Die KI kann nichts schreiben. Es nimmt einen fertigen -//! Request-Body entgegen, schickt ihn an `{endpoint}/v1/messages` bzw. -//! `{endpoint}/v1/messages/count_tokens` und gibt das Antwort-JSON zurück. +//! Request-Body entgegen, schickt ihn an `{endpoint}/v1/messages` (bzw. +//! `{endpoint}/v1/messages/count_tokens`) oder `{endpoint}/chat/completions` und gibt das +//! Antwort-JSON zurück. Die Übersetzung zwischen beiden Formaten passiert in TypeScript. #![deny(clippy::disallowed_methods, clippy::disallowed_types)] use std::fmt; use std::time::Duration; -use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, RETRY_AFTER}; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER}; use reqwest::StatusCode; use serde_json::{Map, Value}; @@ -24,6 +26,8 @@ pub const BETA_FALLBACK_STANDARD: &str = "server-side-fallback-2026-07-01"; pub const BETA_FALLBACK_LISTE: &str = "server-side-fallback-2026-06-01"; /// Großzügig: Ein Tiefencheck über das ganze Buch kann mehrere Minuten dauern. pub const ZEITLIMIT: Duration = Duration::from_secs(600); +/// OpenAI-kompatibel: Lokale Modelle lesen ein ganzes Buch deutlich langsamer ein. +pub const ZEITLIMIT_OPENAI: Duration = Duration::from_secs(1800); const VERBINDUNGS_ZEITLIMIT: Duration = Duration::from_secs(30); /// Längere Retry-After-Angaben werden nicht abgewartet, sondern als Fehler gemeldet. const MAX_RETRY_AFTER: u64 = 60; @@ -51,6 +55,8 @@ pub enum Anbieter { Foundry, /// Claude API von Anthropic (`https://api.anthropic.com`). Claude, + /// OpenAI-kompatible Chat-Completions-API (z. B. `http://localhost:11434/v1`). Schlüssel optional. + OpenAi, } impl Anbieter { @@ -59,11 +65,14 @@ impl Anbieter { match wert.map(str::trim) { None | Some("" | "foundry") => Ok(Self::Foundry), Some("claude") => Ok(Self::Claude), + Some("openai") => Ok(Self::OpenAi), Some(andere) => Err(KiFehler::Einstellung(Text::neu( format!( - "Unbekannter KI-Anbieter „{andere}“. Erwartet wird „foundry“ oder „claude“." + "Unbekannter KI-Anbieter „{andere}“. Erwartet wird „foundry“, „claude“ oder „openai“." + ), + format!( + "Unknown AI provider “{andere}”. Expected “foundry”, “claude” or “openai”." ), - format!("Unknown AI provider “{andere}”. Expected “foundry” or “claude”."), ))), } } @@ -72,6 +81,15 @@ impl Anbieter { match self { Self::Foundry => "Microsoft Foundry", Self::Claude => "Claude API", + Self::OpenAi => "OpenAI-kompatibel", + } + } + + /// Zeitlimit je Anfrage. + pub fn zeitlimit(self) -> Duration { + match self { + Self::OpenAi => ZEITLIMIT_OPENAI, + _ => ZEITLIMIT, } } } @@ -113,7 +131,8 @@ pub enum KiFehler { Einstellung(Text), /// Keine Verbindung (DNS, TLS, Netz weg). Verbindung(String), - Zeitueberschreitung, + /// Keine Antwort innerhalb des Zeitlimits (in Minuten). + Zeitueberschreitung { minuten: u64 }, /// HTTP-Status ≠ 2xx. Http { status: u16, @@ -149,18 +168,16 @@ impl KiFehler { match self { KiFehler::Einstellung(m) => m.in_sprache(englisch).to_string(), KiFehler::Verbindung(m) if de => format!( - "Keine Verbindung zum KI-Endpoint. Bitte Internetverbindung und Endpoint-URL in den KI-Einstellungen prüfen. ({m})" + "Keine Verbindung zum KI-Endpoint. Bitte Internetverbindung (bei einem lokalen Modell: ob der Server läuft) und Endpoint-URL in den KI-Einstellungen prüfen. ({m})" ), KiFehler::Verbindung(m) => format!( - "No connection to the AI endpoint. Please check the internet connection and the endpoint URL in the AI settings. ({m})" + "No connection to the AI endpoint. Please check the internet connection (for a local model: whether the server is running) and the endpoint URL in the AI settings. ({m})" ), - KiFehler::Zeitueberschreitung if de => format!( - "Die KI hat nicht innerhalb von {} Minuten geantwortet. Bitte später erneut versuchen.", - ZEITLIMIT.as_secs() / 60 + KiFehler::Zeitueberschreitung { minuten } if de => format!( + "Die KI hat nicht innerhalb von {minuten} Minuten geantwortet. Bitte später erneut versuchen." ), - KiFehler::Zeitueberschreitung => format!( - "The AI did not answer within {} minutes. Please try again later.", - ZEITLIMIT.as_secs() / 60 + KiFehler::Zeitueberschreitung { minuten } => format!( + "The AI did not answer within {minuten} minutes. Please try again later." ), KiFehler::Antwort(m) if de => { format!("Die Antwort des KI-Dienstes ist unbrauchbar: {m}") @@ -231,7 +248,9 @@ impl std::error::Error for KiFehler {} /// Basis-URL aus dem eingetragenen Endpoint, z. B. /// `https://.services.ai.azure.com/anthropic` (Foundry) oder /// `https://api.anthropic.com` (Claude API; leer = dieser Standard). Akzeptiert auch -/// eingefügte Ziel-URIs mit `/v1/messages` am Ende. Nur HTTPS (HTTP nur für localhost/Tests). +/// eingefügte Ziel-URIs mit `/v1/messages` am Ende. OpenAI-kompatibel: Basis-URL samt `/v1` +/// (z. B. `http://localhost:11434/v1`), ein angehängtes `/chat/completions` wird abgeschnitten. +/// Nur HTTPS (HTTP nur für localhost/Tests). pub fn basis_url(anbieter: Anbieter, endpoint: &str) -> Result { let mut e = endpoint.trim().trim_end_matches('/').to_string(); if e.is_empty() { @@ -241,13 +260,22 @@ pub fn basis_url(anbieter: Anbieter, endpoint: &str) -> Result "Kein KI-Endpoint eingetragen. Bitte in den KI-Einstellungen die Foundry-URL angeben (https://.services.ai.azure.com/anthropic).", "No AI endpoint set. Please enter the Foundry URL in the AI settings (https://.services.ai.azure.com/anthropic).", ))), + Anbieter::OpenAi => return Err(KiFehler::Einstellung(Text::neu( + "Keine Basis-URL eingetragen. Bitte in den KI-Einstellungen die Adresse des Dienstes angeben (z. B. http://localhost:11434/v1).", + "No base URL set. Please enter the address of the service in the AI settings (e.g. http://localhost:11434/v1).", + ))), } } let beispiel = match anbieter { Anbieter::Foundry => "https://.services.ai.azure.com/anthropic", Anbieter::Claude => CLAUDE_API_ENDPOINT, + Anbieter::OpenAi => "http://localhost:11434/v1", + }; + let endungen: &[&str] = match anbieter { + Anbieter::OpenAi => &["/chat/completions"], + _ => &["/v1/messages/count_tokens", "/v1/messages", "/v1"], }; - for endung in ["/v1/messages/count_tokens", "/v1/messages", "/v1"] { + for endung in endungen { if e.to_ascii_lowercase().ends_with(endung) { e.truncate(e.len() - endung.len()); e = e.trim_end_matches('/').to_string(); @@ -281,11 +309,21 @@ pub fn basis_url(anbieter: Anbieter, endpoint: &str) -> Result /// `POST`-Ziel für eine Nachricht. pub fn nachrichten_url(anbieter: Anbieter, endpoint: &str) -> Result { - Ok(format!("{}/v1/messages", basis_url(anbieter, endpoint)?)) + let basis = basis_url(anbieter, endpoint)?; + Ok(match anbieter { + Anbieter::OpenAi => format!("{basis}/chat/completions"), + _ => format!("{basis}/v1/messages"), + }) } -/// `POST`-Ziel für die Token-Zählung. +/// `POST`-Ziel für die Token-Zählung (OpenAI-kompatible Dienste haben keine; die App schätzt dann). pub fn tokens_url(anbieter: Anbieter, endpoint: &str) -> Result { + if anbieter == Anbieter::OpenAi { + return Err(KiFehler::Einstellung(Text::neu( + "OpenAI-kompatible Dienste können keine Tokens zählen.", + "OpenAI-compatible services cannot count tokens.", + ))); + } Ok(format!( "{}/v1/messages/count_tokens", basis_url(anbieter, endpoint)? @@ -322,12 +360,28 @@ pub fn token_body(body: &Value) -> Result { } /// Kopfzeilen: `x-api-key`, `anthropic-version`, `content-type` und bei Bedarf `anthropic-beta`. +/// OpenAI-kompatibel: `content-type` und, falls ein Schlüssel hinterlegt ist, `Authorization: Bearer`. pub fn kopfzeilen( anbieter: Anbieter, schluessel: &str, body: &Value, ) -> Result { let schluessel = schluessel.trim(); + if anbieter == Anbieter::OpenAi { + let mut h = HeaderMap::new(); + h.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + if !schluessel.is_empty() { + let mut wert = HeaderValue::from_str(&format!("Bearer {schluessel}")).map_err(|_| { + KiFehler::Einstellung(Text::neu( + "Der gespeicherte API-Schlüssel enthält ungültige Zeichen. Bitte neu eintragen.", + "The stored API key contains invalid characters. Please enter it again.", + )) + })?; + wert.set_sensitive(true); + h.insert(AUTHORIZATION, wert); + } + return Ok(h); + } if schluessel.is_empty() { return Err(KiFehler::Einstellung(Text::neu( "Kein API-Schlüssel hinterlegt. Bitte in den KI-Einstellungen eintragen.", @@ -377,9 +431,11 @@ fn wiederholen_nach(h: &HeaderMap) -> Option { h.get(RETRY_AFTER)?.to_str().ok()?.trim().parse().ok() } -fn netzfehler(e: reqwest::Error) -> KiFehler { +fn netzfehler(e: reqwest::Error, zeitlimit: Duration) -> KiFehler { if e.is_timeout() { - return KiFehler::Zeitueberschreitung; + return KiFehler::Zeitueberschreitung { + minuten: zeitlimit.as_secs() / 60, + }; } let mut ursache: &dyn std::error::Error = &e; while let Some(innen) = ursache.source() { @@ -396,8 +452,8 @@ pub struct KiKlient { impl KiKlient { pub fn neu() -> Result { + // Zeitlimit je Anfrage (Anbieter::zeitlimit) let http = reqwest::Client::builder() - .timeout(ZEITLIMIT) .connect_timeout(VERBINDUNGS_ZEITLIMIT) .user_agent(concat!("Plotwerk/", env!("CARGO_PKG_VERSION"))) .build() @@ -419,18 +475,25 @@ impl KiKlient { self } - async fn einmal(&self, url: &str, kopf: &HeaderMap, body: &Value) -> Result { + async fn einmal( + &self, + url: &str, + kopf: &HeaderMap, + body: &Value, + zeitlimit: Duration, + ) -> Result { let antwort = self .http .post(url) + .timeout(zeitlimit) .headers(kopf.clone()) .json(body) .send() .await - .map_err(netzfehler)?; + .map_err(|e| netzfehler(e, zeitlimit))?; let status = antwort.status(); let retry = wiederholen_nach(antwort.headers()); - let text = antwort.text().await.map_err(netzfehler)?; + let text = antwort.text().await.map_err(|e| netzfehler(e, zeitlimit))?; if !status.is_success() { return Err(KiFehler::Http { status: status.as_u16(), @@ -446,10 +509,16 @@ impl KiKlient { } /// Mit höchstens zwei Wiederholungen bei 429/529/5xx (Retry-After wird beachtet). - async fn post(&self, url: String, kopf: HeaderMap, body: &Value) -> Result { + async fn post( + &self, + url: String, + kopf: HeaderMap, + body: &Value, + zeitlimit: Duration, + ) -> Result { let mut versuch = 0; loop { - let fehler = match self.einmal(&url, &kopf, body).await { + let fehler = match self.einmal(&url, &kopf, body, zeitlimit).await { Ok(v) => return Ok(v), Err(e) => e, }; @@ -473,7 +542,8 @@ impl KiKlient { } } - /// POST {endpoint}/v1/messages – Body unverändert, Antwort-JSON unverändert. + /// POST {endpoint}/v1/messages (OpenAI-kompatibel: {endpoint}/chat/completions) – Body + /// unverändert, Antwort-JSON unverändert. pub async fn nachricht( &self, anbieter: Anbieter, @@ -489,7 +559,7 @@ impl KiKlient { } let url = nachrichten_url(anbieter, endpoint)?; let kopf = kopfzeilen(anbieter, schluessel, body)?; - self.post(url, kopf, body).await + self.post(url, kopf, body, anbieter.zeitlimit()).await } /// POST {endpoint}/v1/messages/count_tokens → `input_tokens`. @@ -503,7 +573,7 @@ impl KiKlient { let url = tokens_url(anbieter, endpoint)?; let body = token_body(body)?; let kopf = kopfzeilen(anbieter, schluessel, &body)?; - let antwort = self.post(url, kopf, &body).await?; + let antwort = self.post(url, kopf, &body, anbieter.zeitlimit()).await?; antwort .get("input_tokens") .and_then(Value::as_u64) @@ -518,6 +588,7 @@ mod tests { const F: Anbieter = Anbieter::Foundry; const C: Anbieter = Anbieter::Claude; + const O: Anbieter = Anbieter::OpenAi; #[test] fn anbieter_aus_text() { @@ -525,7 +596,8 @@ mod tests { assert_eq!(Anbieter::aus_text(Some("")).unwrap(), F); assert_eq!(Anbieter::aus_text(Some("foundry")).unwrap(), F); assert_eq!(Anbieter::aus_text(Some(" claude ")).unwrap(), C); - assert!(Anbieter::aus_text(Some("openai")).is_err()); + assert_eq!(Anbieter::aus_text(Some("openai")).unwrap(), O); + assert!(Anbieter::aus_text(Some("gemini")).is_err()); assert_eq!(Anbieter::default(), F); } @@ -644,13 +716,13 @@ mod tests { assert!(ohne .to_string() .starts_with("Kein KI-Endpoint eingetragen.")); - assert!(Anbieter::aus_text(Some("openai")) + assert!(Anbieter::aus_text(Some("gemini")) .unwrap_err() .meldung(true) - .contains("Unknown AI provider “openai”")); - assert!(KiFehler::Zeitueberschreitung + .contains("Unknown AI provider “gemini”")); + assert!(KiFehler::Zeitueberschreitung { minuten: 30 } .meldung(true) - .contains("did not answer within")); + .contains("did not answer within 30 minutes")); assert!(KiFehler::Verbindung("dns".into()) .meldung(true) .starts_with("No connection to the AI endpoint.")); @@ -713,4 +785,48 @@ mod tests { .is_none()); assert_eq!(beta_fuer(C, &json!({"fallbacks": "irgendwas"})), None); } + + #[test] + fn openai_urls() { + assert_eq!( + nachrichten_url(O, "http://localhost:11434/v1").unwrap(), + "http://localhost:11434/v1/chat/completions" + ); + assert_eq!( + nachrichten_url(O, " https://openrouter.ai/api/v1/chat/completions/ ").unwrap(), + "https://openrouter.ai/api/v1/chat/completions" + ); + // /v1 bleibt Teil der Basis-URL + assert_eq!( + basis_url(O, "https://api.openai.com/v1/").unwrap(), + "https://api.openai.com/v1" + ); + assert!(basis_url(O, "") + .unwrap_err() + .to_string() + .contains("Basis-URL")); + // HTTP nur lokal + assert!(basis_url(O, "http://127.0.0.1:1234/v1").is_ok()); + assert!(basis_url(O, "http://example.com/v1").is_err()); + assert!(tokens_url(O, "http://localhost:11434/v1").is_err()); + } + + #[test] + fn openai_kopfzeilen() { + let b = json!({"model":"qwen3:8b","messages":[]}); + // lokal ohne Schlüssel: nur content-type + let ohne = kopfzeilen(O, "", &b).unwrap(); + assert_eq!(ohne.len(), 1); + assert_eq!(ohne[CONTENT_TYPE], "application/json"); + // mit Schlüssel: Bearer, kein x-api-key, keine anthropic-*-Kopfzeilen + let mit = kopfzeilen(O, " sk-or-x ", &json!({"fallbacks":"default"})).unwrap(); + assert_eq!(mit[AUTHORIZATION], "Bearer sk-or-x"); + assert!(mit[AUTHORIZATION].is_sensitive()); + assert!(mit.get("x-api-key").is_none()); + assert!(mit.get("anthropic-version").is_none()); + assert!(mit.get("anthropic-beta").is_none()); + assert!(kopfzeilen(O, "a\nb", &b).is_err()); + assert_eq!(O.zeitlimit(), ZEITLIMIT_OPENAI); + assert_eq!(C.zeitlimit(), ZEITLIMIT); + } } diff --git a/src-tauri/crates/plotwerk-ki/tests/http.rs b/src-tauri/crates/plotwerk-ki/tests/http.rs index b4d2be4..68ce909 100644 --- a/src-tauri/crates/plotwerk-ki/tests/http.rs +++ b/src-tauri/crates/plotwerk-ki/tests/http.rs @@ -9,6 +9,7 @@ use tokio::net::TcpListener; const F: Anbieter = Anbieter::Foundry; const C: Anbieter = Anbieter::Claude; +const O: Anbieter = Anbieter::OpenAi; struct Anfrage { zeile: String, @@ -394,3 +395,81 @@ async fn foundry_ohne_beta_header() { assert_eq!(anfrage.zeile, "POST /anthropic/v1/messages HTTP/1.1"); assert_eq!(anfrage.kopf("anthropic-beta"), None); } + +#[tokio::test] +async fn openai_chat_completions_mit_bearer() { + let (endpoint, server) = server( + "200 OK", + "", + r#"{"choices":[{"index":0,"message":{"role":"assistant","content":"{}"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}"#, + ) + .await; + let body = json!({"model":"gpt-5","messages":[{"role":"user","content":"Hallo"}],"max_completion_tokens":10}); + let antwort = KiKlient::neu() + .unwrap() + .nachricht( + O, + &format!("{endpoint}/v1/chat/completions"), + "sk-test", + &body, + ) + .await + .unwrap(); + assert_eq!(antwort["usage"]["prompt_tokens"], 3); + let anfrage = server.await.unwrap(); + assert_eq!( + anfrage.zeile, + "POST /anthropic/v1/chat/completions HTTP/1.1" + ); + assert_eq!(anfrage.kopf("authorization"), Some("Bearer sk-test")); + assert_eq!(anfrage.kopf("x-api-key"), None); + assert_eq!(anfrage.kopf("anthropic-version"), None); + assert_eq!(anfrage.body, body); +} + +#[tokio::test] +async fn openai_lokal_ohne_schluessel() { + let (endpoint, server) = server( + "200 OK", + "", + r#"{"choices":[{"index":0,"message":{"role":"assistant","content":"{}"},"finish_reason":"stop"}]}"#, + ) + .await; + klient() + .nachricht( + O, + &format!("{endpoint}/v1"), + "", + &json!({"model":"qwen3:8b","messages":[]}), + ) + .await + .unwrap(); + let anfrage = server.await.unwrap(); + assert_eq!( + anfrage.zeile, + "POST /anthropic/v1/chat/completions HTTP/1.1" + ); + assert_eq!(anfrage.kopf("authorization"), None); +} + +#[tokio::test] +async fn openai_fehlermeldung_von_ollama() { + let (endpoint, server) = server( + "404 Not Found", + "", + r#"{"error":{"message":"model \"qwen3:99b\" not found, try pulling it first","type":"api_error"}}"#, + ) + .await; + let e = klient() + .nachricht( + O, + &format!("{endpoint}/v1"), + "", + &json!({"model":"qwen3:99b"}), + ) + .await + .unwrap_err(); + server.await.unwrap(); + assert_eq!(e.status(), Some(404)); + assert!(e.to_string().contains("try pulling it first"), "{e}"); +} diff --git a/src-tauri/src/ki.rs b/src-tauri/src/ki.rs index feb82cc..b553c75 100644 --- a/src-tauri/src/ki.rs +++ b/src-tauri/src/ki.rs @@ -47,7 +47,7 @@ async fn im_hintergrund( })? } -/// `anbieter`: "foundry" (Standard, auch wenn es fehlt) oder "claude". +/// `anbieter`: "foundry" (Standard, auch wenn es fehlt), "claude" oder "openai". fn anbieter(wert: Option) -> Result { Anbieter::aus_text(wert.as_deref()).map_err(|e| KiFehlerAntwort::from(e).meldung) } @@ -78,7 +78,16 @@ fn klient(zustand: &Zustand) -> Result { Ok(zustand.ki.get_or_init(|| k).clone()) } -/// POST {endpoint}/v1/messages mit dem Schlüssel des Anbieters aus dem Schlüsselbund. +/// Schlüssel des Anbieters; beim OpenAI-kompatiblen Dienst darf er fehlen (lokale Server). +async fn schluessel_fuer(a: Anbieter) -> Result { + match a { + Anbieter::OpenAi => im_hintergrund(move || schluessel::lesen_optional(a)).await, + _ => im_hintergrund(move || schluessel::lesen(a)).await, + } +} + +/// POST {endpoint}/v1/messages bzw. {endpoint}/chat/completions mit dem Schlüssel des Anbieters +/// aus dem Schlüsselbund. #[tauri::command] pub async fn ki_anfrage( zustand: State<'_, Zustand>, @@ -88,7 +97,7 @@ pub async fn ki_anfrage( ) -> Result { let a = Anbieter::aus_text(anbieter.as_deref())?; let k = klient(&zustand)?; - let schluessel = im_hintergrund(move || schluessel::lesen(a)).await?; + let schluessel = schluessel_fuer(a).await?; Ok(k.nachricht(a, &endpoint, &schluessel, &body).await?) } @@ -102,7 +111,7 @@ pub async fn ki_tokens( ) -> Result { let a = Anbieter::aus_text(anbieter.as_deref())?; let k = klient(&zustand)?; - let schluessel = im_hintergrund(move || schluessel::lesen(a)).await?; + let schluessel = schluessel_fuer(a).await?; Ok(k.tokens_zaehlen(a, &endpoint, &schluessel, &body).await?) } diff --git a/src-tauri/src/schluessel.rs b/src-tauri/src/schluessel.rs index e93802a..d9768e7 100644 --- a/src-tauri/src/schluessel.rs +++ b/src-tauri/src/schluessel.rs @@ -9,11 +9,14 @@ pub const DIENST: &str = "app.plotwerk.mac"; pub const KONTO: &str = "azure-foundry"; /// Konto für die Claude API von Anthropic. pub const KONTO_CLAUDE: &str = "anthropic-api"; +/// Konto für den OpenAI-kompatiblen Dienst (OpenAI, OpenRouter, Qwen …; lokal meist ohne Schlüssel). +pub const KONTO_OPENAI: &str = "openai-kompatibel"; pub fn konto(anbieter: Anbieter) -> &'static str { match anbieter { Anbieter::Foundry => KONTO, Anbieter::Claude => KONTO_CLAUDE, + Anbieter::OpenAi => KONTO_OPENAI, } } @@ -82,13 +85,22 @@ pub fn lesen(anbieter: Anbieter) -> Result { eintrag(anbieter)?.get_password().map_err(fehlertext) } +/// Wie `lesen`, aber ein fehlender Eintrag ist kein Fehler (lokale Server brauchen keinen Schlüssel). +pub fn lesen_optional(anbieter: Anbieter) -> Result { + match eintrag(anbieter)?.get_password() { + Ok(w) => Ok(w), + Err(Error::NoEntry) => Ok(String::new()), + Err(e) => Err(fehlertext(e)), + } +} + #[cfg(test)] mod tests { use super::*; #[test] fn leerer_schluessel_wird_abgelehnt() { - for anbieter in [Anbieter::Foundry, Anbieter::Claude] { + for anbieter in [Anbieter::Foundry, Anbieter::Claude, Anbieter::OpenAi] { assert!(setzen(anbieter, " ").is_err()); assert!(setzen(anbieter, "abc\ndef").is_err()); } @@ -98,6 +110,7 @@ mod tests { fn je_anbieter_ein_eintrag() { assert_eq!(konto(Anbieter::Foundry), "azure-foundry"); assert_eq!(konto(Anbieter::Claude), "anthropic-api"); + assert_eq!(konto(Anbieter::OpenAi), "openai-kompatibel"); assert_eq!(DIENST, "app.plotwerk.mac"); } diff --git a/src/backend/demo.ts b/src/backend/demo.ts index 68912f1..02132c0 100644 --- a/src/backend/demo.ts +++ b/src/backend/demo.ts @@ -112,7 +112,7 @@ export function erstelleDemoBackend(): Backend { let aktuellOffen: MemoryFs | null = null; const oeffne = () => (aktuellOffen = demo()); /** je Anbieter, nur im Speicher (die Demo nutzt keinen Schlüssel) */ - const schluesselGesetzt: Record = { foundry: false, claude: false }; + const schluesselGesetzt: Record = { foundry: false, claude: false, openai: false }; return { art: 'demo', diff --git a/src/backend/tauri/index.ts b/src/backend/tauri/index.ts index 24ba750..bc64e52 100644 --- a/src/backend/tauri/index.ts +++ b/src/backend/tauri/index.ts @@ -9,7 +9,7 @@ */ import { listen } from '@tauri-apps/api/event'; import Database from '@tauri-apps/plugin-sql'; -import { kiAnbieter, kiEndpoint } from '@core/ki/index'; +import { kiAnbieter, kiEndpoint, kiMitStandards } from '@core/ki/index'; import type { KiEinstellungen } from '@core/model/types'; import { parseProjekt } from '@core/projekt'; import type { Backend, GewaehlteDatei } from '../types'; @@ -57,11 +57,14 @@ export async function erstelleTauriBackend(): Promise { } } - /** Gewählter Anbieter und dessen Basis-URL (Foundry ohne Endpoint: ''). */ + /** Gewählter Anbieter und dessen Basis-URL (Foundry ohne Endpoint: ''); OpenAI-kompatibel mit Vorlage und Kontextlänge. */ async function kiZiel(): Promise { if (!aktuell) return { anbieter: 'foundry', endpoint: '' }; const ki = await kiEinstellungen(); - return { anbieter: kiAnbieter(ki), endpoint: kiEndpoint(ki) }; + const anbieter = kiAnbieter(ki); + if (anbieter !== 'openai') return { anbieter, endpoint: kiEndpoint(ki) }; + const { vorlage, kontextTokens } = kiMitStandards(ki).openai; + return { anbieter, endpoint: kiEndpoint(ki), openai: { vorlage, ...(kontextTokens ? { kontextTokens } : {}) } }; } const indexSpeicher = erstelleSqlIndexSpeicher( diff --git a/src/backend/tauri/kiTransport.test.ts b/src/backend/tauri/kiTransport.test.ts index 3fdd2af..0661a39 100644 --- a/src/backend/tauri/kiTransport.test.ts +++ b/src/backend/tauri/kiTransport.test.ts @@ -114,3 +114,71 @@ describe('Tauri-KI-Transport je Anbieter', () => { expect(argumente.some((a) => /schluessel"|apiKey|x-api-key|sk-ant/i.test(a))).toBe(false); }); }); + +describe('Tauri-KI-Transport: OpenAI-kompatibel', () => { + const ANFRAGE = { + model: 'qwen3:32b', + max_tokens: 16000, + system: [{ type: 'text', text: 'System' }], + messages: [{ role: 'user', content: [{ type: 'text', text: 'Buch', cache_control: { type: 'ephemeral' } }, { type: 'text', text: 'Aufgabe' }] }], + output_config: { effort: 'medium', format: { type: 'json_schema', schema: { type: 'object' } } }, + }; + + function openAiTransport(ziel: KiZiel, antwort: unknown, schluessel: Partial> = {}) { + mocks.invoke.mockImplementation(async (cmd: string, args: Record = {}) => { + if (cmd === 'schluessel_vorhanden') return schluessel[args.anbieter as KiAnbieter] ?? false; + if (cmd === 'ki_anfrage') return antwort; + throw `Unbekanntes Kommando ${cmd}`; + }); + return erstelleTauriKiTransport(async () => ziel); + } + + const OLLAMA: KiZiel = { anbieter: 'openai', endpoint: 'http://localhost:11434/v1', openai: { vorlage: 'ollama' } }; + + it('übersetzt Body und Antwort; lokal ohne Schlüssel bereit', async () => { + const t = openAiTransport(OLLAMA, { + model: 'qwen3:32b', + choices: [{ index: 0, message: { role: 'assistant', content: '…{"ok":true}' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 2 }, + }); + expect(await t.bereit()).toBe(true); + expect(mocks.invoke.mock.calls.some((c) => c[0] === 'schluessel_vorhanden')).toBe(false); + + const antwort = await t.senden(ANFRAGE); + expect(antwort).toMatchObject({ type: 'message', stop_reason: 'end_turn', content: [{ type: 'text', text: '{"ok":true}' }] }); + expect(antwort.usage).toEqual({ input_tokens: 10, output_tokens: 2, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }); + const args = letzteArgs('ki_anfrage'); + expect(args.anbieter).toBe('openai'); + expect(args.endpoint).toBe('http://localhost:11434/v1'); + expect(args.body).toEqual({ + model: 'qwen3:32b', + messages: [ + { role: 'system', content: 'System' }, + { role: 'user', content: 'Buch\n\nAufgabe' }, + ], + max_tokens: 16000, + stream: false, + response_format: { type: 'json_schema', json_schema: { name: 'antwort', strict: true, schema: { type: 'object' } } }, + }); + }); + + it('Cloud-Dienste brauchen einen Schlüssel; Tokens zählen gibt es nicht (core/ki schätzt)', async () => { + const t = openAiTransport({ anbieter: 'openai', endpoint: 'https://openrouter.ai/api/v1', openai: { vorlage: 'openrouter' } }, {}); + expect(await t.bereit()).toBe(false); + expect(letzteArgs('schluessel_vorhanden')).toEqual({ anbieter: 'openai' }); + await expect(t.tokenZaehlen(ANFRAGE)).rejects.toThrow('Tokens'); + expect(mocks.invoke.mock.calls.some((c) => c[0] === 'ki_tokens')).toBe(false); + }); + + it('ohne Modell oder zu groß für den Kontext: kein Aufruf, verständliche Meldung', async () => { + const t = openAiTransport({ ...OLLAMA, openai: { vorlage: 'ollama', kontextTokens: 4100 } }, {}); + await expect(t.senden({ ...ANFRAGE, model: '' })).rejects.toThrow('Kein Modell'); + const gross = { ...ANFRAGE, messages: [{ role: 'user', content: [{ type: 'text', text: 'x'.repeat(3000) }] }] }; + await expect(t.senden(gross)).rejects.toThrow(/Kontextlänge von 4\.100 Tokens/); + expect(mocks.invoke.mock.calls.some((c) => c[0] === 'ki_anfrage')).toBe(false); + // ohne Kontextangabe wird nicht geprüft + const ohne = openAiTransport(OLLAMA, { choices: [{ message: { content: '{}' }, finish_reason: 'stop' }] }); + await expect(ohne.senden(gross)).resolves.toMatchObject({ stop_reason: 'end_turn' }); + }); +}); + diff --git a/src/backend/tauri/kiTransport.ts b/src/backend/tauri/kiTransport.ts index 0081288..4a11522 100644 --- a/src/backend/tauri/kiTransport.ts +++ b/src/backend/tauri/kiTransport.ts @@ -7,16 +7,23 @@ * eine Anfrage ab, beantwortet sie die API im selben Aufruf mit dem von Anthropic empfohlenen * Ersatzmodell (`fallbacks: "default"`, Beta-Header setzt der Rust-Kern). Nicht bei Haiku 5.5 * (kein serverseitiger Fallback) und nicht bei Foundry (dort nicht verfügbar). + * + * OpenAI-kompatible Dienste (E15): Body und Antwort werden hier übersetzt (core/ki/openai.ts), core/ki + * sieht weiter die Messages-API. Vor dem Senden: Modellname vorhanden und Anfrage passt in die + * eingestellte Kontextlänge (sonst Ablehnung statt still gekürztem Buch). Tokens zählen können diese + * Dienste nicht; core/ki schätzt dann über die Zeichenzahl. */ -import type { KiTransport } from '@core/ki/index'; -import type { KiAnbieter } from '@core/model/types'; -import { texte } from '../../i18n'; +import { kiVorlage, nachOpenAi, passtInKontext, VORLAGEN, vonOpenAi, type KiTransport } from '@core/ki/index'; +import type { KiAnbieter, KiOpenAiEinstellungen } from '@core/model/types'; +import { f, texte, zahl } from '../../i18n'; import { aufruf, TauriFehler } from './aufruf'; /** Anbieter und Basis-URL der aktuellen Einstellungen ('' = kein Endpoint). */ export interface KiZiel { anbieter: KiAnbieter; endpoint: string; + /** nur beim OpenAI-kompatiblen Dienst: Vorlage und Kontextlänge */ + openai?: Pick; } /** Modelle mit serverseitigem Fallback `"default"` auf der Claude API. */ @@ -39,7 +46,19 @@ export function erstelleTauriKiTransport(ziel: () => Promise): KiTranspo const z = await ziel(); const endpoint = z.endpoint.trim(); if (!endpoint) throw new TauriFehler(texte().backend.keinEndpoint[z.anbieter]); - return { anbieter: z.anbieter, endpoint }; + return { ...z, endpoint }; + } + + async function sendenOpenAi(body: Record, z: KiZiel): Promise> { + if (typeof body.model !== 'string' || !body.model.trim()) throw new TauriFehler(texte().backend.keinModell); + const anfrage = nachOpenAi(body, VORLAGEN[kiVorlage(z.openai?.vorlage)]); + const grenze = z.openai?.kontextTokens; + const kontext = passtInKontext(anfrage, grenze); + if (!kontext.passt) { + throw new TauriFehler(f(texte().backend.kontextZuGross, { tokens: zahl(kontext.tokens), grenze: zahl(grenze ?? 0) })); + } + const antwort = await aufruf>('ki_anfrage', { endpoint: z.endpoint, body: anfrage, anbieter: z.anbieter }); + return vonOpenAi(antwort); } return { @@ -47,6 +66,8 @@ export function erstelleTauriKiTransport(ziel: () => Promise): KiTranspo try { const z = await ziel(); if (!z.endpoint.trim()) return false; + // Lokale Server und eigene URLs gehen auch ohne Schlüssel. + if (z.anbieter === 'openai' && VORLAGEN[kiVorlage(z.openai?.vorlage)].ohneSchluessel) return true; return await aufruf('schluessel_vorhanden', { anbieter: z.anbieter }); } catch (e) { console.warn('KI-Bereitschaft nicht prüfbar:', e); @@ -56,11 +77,13 @@ export function erstelleTauriKiTransport(ziel: () => Promise): KiTranspo // Ein AbortSignal wird hier ignoriert: ein laufendes Rust-Kommando lässt sich nicht abbrechen. core/ki verwirft // die Antwort nach einem Abbruch selbst; gespeichert wird nichts. async senden(body) { - const { anbieter, endpoint } = await pflichtZiel(); - return aufruf>('ki_anfrage', { endpoint, body: mitServerFallback(body, anbieter), anbieter }); + const z = await pflichtZiel(); + if (z.anbieter === 'openai') return sendenOpenAi(body, z); + return aufruf>('ki_anfrage', { endpoint: z.endpoint, body: mitServerFallback(body, z.anbieter), anbieter: z.anbieter }); }, async tokenZaehlen(body) { const { anbieter, endpoint } = await pflichtZiel(); + if (anbieter === 'openai') throw new TauriFehler(texte().backend.keinZaehlen); return aufruf('ki_tokens', { endpoint, body, anbieter }); }, }; diff --git a/src/features/beziehungen/Vorschlaege.tsx b/src/features/beziehungen/Vorschlaege.tsx index 8a645ca..31a0274 100644 --- a/src/features/beziehungen/Vorschlaege.tsx +++ b/src/features/beziehungen/Vorschlaege.tsx @@ -5,9 +5,10 @@ * Die KI legt nichts an; angelegt wird nur auf Klick über den Store. */ import { useEffect, useMemo, useState, type ReactNode } from 'react'; -import { EFFORT, figurVerweisAufloesen, formatiereUsd, offeneVorschlaege, type OffeneVorschlaege, type Schaetzung } from '@core/ki/index'; +import { EFFORT, figurVerweisAufloesen, offeneVorschlaege, type OffeneVorschlaege, type Schaetzung } from '@core/ki/index'; import type { BeziehungsVorschlag, FigurVorschlag, KiVorschlaege, OrtVorschlag, ProjektDaten, VorschlagsVerweis, Zitat } from '@core/model/types'; -import { f, liste, useTexte, zahl as zahlFormat, sprache } from '../../i18n'; +import { f, liste, useTexte, zahl as zahlFormat } from '../../i18n'; +import { kostenBetrag, kostenWert } from '../ki/kosten'; import { kiFehlerAnzeige, type KiFehlerAnzeige } from '../../state/slices/ki'; import type { VorschlagsArt } from '../../state/slices/vorschlaege'; import { useStore } from '../../state/store'; @@ -132,7 +133,7 @@ function VorschlagDialogInhalt() { eingabe: zahl(tok.eingabe), cache: zahl(tok.cacheGelesen), ausgabe: zahl(tok.ausgabe), - kosten: formatiereUsd(ergebnis.kostenUsd, sprache()), + kosten: kostenBetrag(ergebnis), })} @@ -187,7 +188,7 @@ function VorschlagDialogInhalt() {
{tv.ausgabe}
{f(tv.ausgabeWert, { n: zahl(s.ausgabeTokens) })}
{tv.kosten}
-
{f(tv.kostenWert, { kosten: formatiereUsd(s.kostenUsd, sprache()) })}
+
{kostenWert(s, tv.kostenWert, 'kosten')}
{!s.gezaehlt &&

{tv.nichtGezaehlt}

} @@ -355,7 +356,7 @@ export function VorschlaegePanel({ daten }: { daten: ProjektDaten }) { )} {ergebnis && !laeuft && ( - {f(tv.letzterLauf, { neu: ergebnis.neu, verworfen: ergebnis.verworfen.length, kosten: formatiereUsd(ergebnis.kostenUsd, sprache()) })} + {f(tv.letzterLauf, { neu: ergebnis.neu, verworfen: ergebnis.verworfen.length, kosten: kostenBetrag(ergebnis) })} )} diff --git a/src/features/figuren/StimmeDialog.tsx b/src/features/figuren/StimmeDialog.tsx index 8e8a68b..d352fd2 100644 --- a/src/features/figuren/StimmeDialog.tsx +++ b/src/features/figuren/StimmeDialog.tsx @@ -5,11 +5,12 @@ */ import { useEffect, useMemo, useState } from 'react'; import { redeDerFigur } from '@core/figuren/index'; -import { EFFORT, formatiereUsd, kiEndpoint, STIMME_MIN_REDE, type Schaetzung, type StimmenAuftrag } from '@core/ki/index'; +import { EFFORT, kiEndpoint, STIMME_MIN_REDE, type Schaetzung, type StimmenAuftrag } from '@core/ki/index'; import type { Id } from '@core/model/types'; import type { DialogKomponentenProps } from '../../app/dialogTypen'; import { kiFehlerAnzeige, type KiFehlerAnzeige } from '../../state/slices/ki'; -import { f as fmt, useTexte, vergleich, sprache } from '../../i18n'; +import { f as fmt, useTexte, vergleich } from '../../i18n'; +import { kostenBetrag, kostenWert } from '../ki/kosten'; import { useStore } from '../../state/store'; import { Button, Checkbox, Dialog } from '../../ui'; import '../ki/ki.css'; @@ -150,7 +151,7 @@ export default function StimmeDialog({ param, schliessen }: DialogKomponentenPro
{td.modell}
{ergebnis.modell}
{td.kosten}
-
{formatiereUsd(ergebnis.kostenUsd, sprache())}
+
{kostenBetrag(ergebnis)}
{ergebnis.verworfen.length > 0 && (
@@ -230,7 +231,7 @@ export default function StimmeDialog({ param, schliessen }: DialogKomponentenPro
{td.ausgabe}
{fmt(td.ausgabeWert, { n: Math.round(s.ausgabeTokens) })}
{td.kosten}
-
{fmt(td.kostenWert, { usd: formatiereUsd(s.kostenUsd, sprache()) })}
+
{kostenWert(s, td.kostenWert, 'usd')}
{!s.gezaehlt &&

{td.nichtGezaehlt}

} diff --git a/src/features/fragen/LeserSimulationDialog.tsx b/src/features/fragen/LeserSimulationDialog.tsx index 46766b7..06abc06 100644 --- a/src/features/fragen/LeserSimulationDialog.tsx +++ b/src/features/fragen/LeserSimulationDialog.tsx @@ -8,7 +8,7 @@ import { ausLeserStand, EFFORT, fadenAbgleich, - formatiereUsd, + leserStandVeraltet, offeneFragen, type FadenAbgleich, @@ -19,7 +19,8 @@ import { import type { Id, LeserAussage, ProjektDaten } from '@core/model/types'; import { fadenKarten } from '@core/struktur/faeden'; import type { DialogKomponentenProps } from '../../app/dialogTypen'; -import { f, useTexte, zahl as zahlFormat, sprache } from '../../i18n'; +import { f, useTexte, zahl as zahlFormat } from '../../i18n'; +import { kostenWert } from '../ki/kosten'; import { kiFehlerAnzeige, type KiFehlerAnzeige } from '../../state/slices/ki'; import { useStore } from '../../state/store'; import { bestaetigen, Button, Checkbox, Dialog, Segment } from '../../ui'; @@ -405,7 +406,7 @@ export default function LeserSimulationDialog({ param, schliessen }: DialogKompo
{t.ausgabe}
{f(t.ausgabeWert, { n: zahl(s.ausgabeTokens) })}
{t.kosten}
-
{f(t.kostenWert, { kosten: formatiereUsd(s.kostenUsd, sprache()) })}
+
{kostenWert(s, t.kostenWert, 'kosten')}
{!s.gezaehlt &&

{t.nichtGezaehlt}

} diff --git a/src/features/fragen/index.tsx b/src/features/fragen/index.tsx index 6905dba..19cb5d8 100644 --- a/src/features/fragen/index.tsx +++ b/src/features/fragen/index.tsx @@ -56,7 +56,9 @@ function Kostenzeile() { const schaetzung = useStore((s) => s.kileser.fragenSchaetzung); const schaetzen = useStore((s) => s.kileser.fragenSchaetzen); const hatDaten = useStore((s) => !!s.daten); - const t = useTexte().fragen.ansicht; + const alle = useTexte(); + const t = alle.fragen.ansicht; + const kostenUnbekanntText = alle.ki.kostenUnbekannt; useEffect(() => { if (hatDaten) void schaetzen(); }, [schnell, hatDaten, schaetzen]); @@ -64,7 +66,7 @@ function Kostenzeile() { if (!w) return {t.kostenSchaetzen}; return ( - {f(t.kostenZeile, { erste: formatiereKosten(w.ersteUsd), weitere: formatiereKosten(w.weitereUsd) })} + {w.kostenBekannt === false ? kostenUnbekanntText : f(t.kostenZeile, { erste: formatiereKosten(w.ersteUsd), weitere: formatiereKosten(w.weitereUsd) })} ); } diff --git a/src/features/ki/EinstellungenDialog.tsx b/src/features/ki/EinstellungenDialog.tsx index 2a19643..9959b02 100644 --- a/src/features/ki/EinstellungenDialog.tsx +++ b/src/features/ki/EinstellungenDialog.tsx @@ -1,11 +1,11 @@ /** - * Dialog „Einstellungen“: KI-Anbieter (Microsoft Foundry oder Claude API), Endpoint und Modelle je - * Anbieter, API-Schlüssel je Anbieter (Schlüsselbund). Beim Umschalten bleiben die Einstellungen - * des anderen Anbieters erhalten und werden mitgespeichert. + * Dialog „Einstellungen“: KI-Anbieter (Microsoft Foundry, Claude API oder ein OpenAI-kompatibler + * Dienst), Endpoint und Modelle je Anbieter, API-Schlüssel je Anbieter (Schlüsselbund). Beim + * Umschalten bleiben die Einstellungen der anderen Anbieter erhalten und werden mitgespeichert. */ import { useEffect, useState, type FormEvent } from 'react'; -import { ANBIETER_NAME, CLAUDE_API_ENDPOINT, STANDARD_MODELLE } from '@core/ki/index'; -import type { KiAnbieter, KiEinstellungen, KiModelle } from '@core/model/types'; +import { ANBIETER_NAME, CLAUDE_API_ENDPOINT, KI_VORLAGEN, kiMitStandards, STANDARD_MODELLE, VORLAGEN } from '@core/ki/index'; +import type { KiAnbieter, KiEinstellungen, KiModelle, KiOpenAiEinstellungen, KiVorlage } from '@core/model/types'; import type { DialogKomponentenProps } from '../../app/dialogTypen'; import { f, setzeSprachWahl, texte, useSprachWahl, useTexte, type SprachWahl } from '../../i18n'; import { kiFehlerAnzeige } from '../../state/slices/ki'; @@ -22,8 +22,20 @@ export function endpointNormalisieren(wert: string): string { .replace(/\/+$/, ''); } -export function endpointFehler(wert: string): string | null { - const e = endpointNormalisieren(wert); +/** OpenAI-kompatibel: Basis-URL samt /v1, ohne „/“ und ohne „/chat/completions“ am Ende. */ +export function openAiEndpointNormalisieren(wert: string): string { + return wert + .trim() + .replace(/\/+$/, '') + .replace(/\/chat\/completions$/i, '') + .replace(/\/+$/, ''); +} + +const LOKALE_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** `httpLokal`: http:// ist für localhost erlaubt (lokale Server wie Ollama und LM Studio). */ +export function endpointFehler(wert: string, httpLokal = false): string | null { + const e = httpLokal ? openAiEndpointNormalisieren(wert) : endpointNormalisieren(wert); if (!e) return null; let url: URL; try { @@ -31,20 +43,23 @@ export function endpointFehler(wert: string): string | null { } catch { return texte().ki.einstellungen.ungueltig; } - if (url.protocol !== 'https:') return texte().ki.einstellungen.https; - return null; + if (url.protocol === 'https:') return null; + if (httpLokal) return url.protocol === 'http:' && LOKALE_HOSTS.has(url.hostname) ? null : texte().ki.einstellungen.httpsLokal; + return texte().ki.einstellungen.https; } -/** Modellrollen; Beschriftung aus t.ki.einstellungen.rollen */ -const ROLLEN = ['erkennung', 'pruefung', 'tiefencheck'] as const; +/** Kontextlänge aus dem Feld: leer = keine Angabe, sonst ganze Zahl > 0 (Tausenderpunkte erlaubt); NaN = ungültig. */ +export function kontextAusText(wert: string): number | undefined { + const t = wert.replace(/[\s.,'’_]/g, ''); + if (!t) return undefined; + return /^\d+$/.test(t) && Number(t) > 0 ? Number(t) : NaN; +} -const ANBIETER_OPTIONEN = [ - { wert: 'foundry' as const, label: ANBIETER_NAME.foundry }, - { wert: 'claude' as const, label: ANBIETER_NAME.claude }, -]; +/** Modellrollen; Beschriftung aus t.ki.einstellungen.rollen bzw. rollenAllgemein */ +const ROLLEN = ['erkennung', 'pruefung', 'tiefencheck'] as const; -/** Bearbeitungsstand beider Anbieter (Rohwerte der Felder). */ -export type EinstellungsWerte = Required; +/** Bearbeitungsstand aller Anbieter (Rohwerte der Felder; Kontextlänge als Text). */ +export type EinstellungsWerte = Required & { kontextText: string }; function modelleSauber(m: KiModelle): KiModelle { return { @@ -54,8 +69,24 @@ function modelleSauber(m: KiModelle): KiModelle { }; } -/** Was in projekt.md landet: beide Anbieter, normalisiert, leere Modelle = Standard. */ -export function einstellungenAusWerten(w: EinstellungsWerte): Required { +function openAiSauber(w: EinstellungsWerte): KiOpenAiEinstellungen { + const o = w.openai; + const kontext = kontextAusText(w.kontextText); + return { + vorlage: o.vorlage, + endpoint: openAiEndpointNormalisieren(o.endpoint) || VORLAGEN[o.vorlage].endpoint, + modelle: { erkennung: o.modelle.erkennung.trim(), pruefung: o.modelle.pruefung.trim(), tiefencheck: o.modelle.tiefencheck.trim() }, + ...(kontext && Number.isFinite(kontext) ? { kontextTokens: kontext } : {}), + }; +} + +/** + * Was in projekt.md landet: Foundry und Claude API normalisiert (leere Modelle = Standard); den + * OpenAI-kompatiblen Block nur, wenn er gewählt oder bearbeitet ist (sonst bleibt ein vorhandener erhalten). + */ +export function einstellungenAusWerten(w: EinstellungsWerte): KiEinstellungen { + const openai = openAiSauber(w); + const unberuehrt = JSON.stringify(openai) === JSON.stringify(kiMitStandards(undefined).openai); return { endpoint: endpointNormalisieren(w.endpoint), deployments: modelleSauber(w.deployments), @@ -64,29 +95,33 @@ export function einstellungenAusWerten(w: EinstellungsWerte): Required; + platzhalter: Readonly; onChange(m: KiModelle): void; }) { - const t = useTexte(); return (
{ROLLEN.map((r) => ( - + {(id) => ( onChange({ ...modelle, [r]: e.target.value })} @@ -118,6 +153,14 @@ export function SprachWahlAbschnitt() { ); } +function Hilfe({ fehler, children }: { fehler: string | null; children: string }) { + return ( +

+ {fehler ?? children} +

+ ); +} + export function EinstellungenDialog({ schliessen }: DialogKomponentenProps) { const projekt = useStore((s) => s.daten?.projekt); const backendArt = useStore((s) => s.backend?.art); @@ -128,7 +171,10 @@ export function EinstellungenDialog({ schliessen }: DialogKomponentenProps) { const schluesselSetzen = useStore((s) => s.kiSchluesselSetzen); const schluesselLoeschen = useStore((s) => s.kiSchluesselLoeschen); - const [anfang] = useState(() => kiEinstellungen()); + const [anfang] = useState(() => { + const ki = kiEinstellungen(); + return { ...ki, kontextText: ki.openai.kontextTokens ? String(ki.openai.kontextTokens) : '' }; + }); const [werte, setWerte] = useState(anfang); const [neuerSchluessel, setNeuerSchluessel] = useState(''); const [schluesselMeldung, setSchluesselMeldung] = useState(null); @@ -139,7 +185,8 @@ export function EinstellungenDialog({ schliessen }: DialogKomponentenProps) { const T = t.ki.einstellungen; const anbieter = werte.anbieter; - const name = ANBIETER_NAME[anbieter]; + const vorlage = VORLAGEN[werte.openai.vorlage]; + const name = anbieter === 'openai' ? vorlage.name : ANBIETER_NAME[anbieter]; const schluesselStatus = schluesselJe[anbieter]; useEffect(() => { @@ -147,9 +194,29 @@ export function EinstellungenDialog({ schliessen }: DialogKomponentenProps) { }, [schluesselPruefen, anbieter]); const ohneProjekt = !projekt; - const fehler = endpointFehler(anbieter === 'claude' ? werte.claude.endpoint : werte.endpoint); + const endpointFalsch = + anbieter === 'openai' + ? endpointFehler(werte.openai.endpoint, true) + : endpointFehler(anbieter === 'claude' ? werte.claude.endpoint : werte.endpoint); + const openAiFehler = + anbieter !== 'openai' + ? null + : !openAiEndpointNormalisieren(werte.openai.endpoint) && !vorlage.endpoint + ? T.openAiBasisUrlEigen + : !ROLLEN.some((r) => werte.openai.modelle[r].trim()) + ? T.keinModell + : Number.isNaN(kontextAusText(werte.kontextText)) + ? T.kontextFalsch + : null; + const fehler = endpointFalsch ?? openAiFehler; const geaendert = JSON.stringify(einstellungenAusWerten(werte)) !== JSON.stringify(einstellungenAusWerten(anfang)); + const anbieterOptionen: { wert: KiAnbieter; label: string }[] = [ + { wert: 'foundry', label: ANBIETER_NAME.foundry }, + { wert: 'claude', label: ANBIETER_NAME.claude }, + { wert: 'openai', label: T.anbieterOpenAi }, + ]; + function anbieterWaehlen(a: KiAnbieter) { if (a === anbieter) return; setWerte({ ...werte, anbieter: a }); @@ -158,6 +225,11 @@ export function EinstellungenDialog({ schliessen }: DialogKomponentenProps) { setSchluesselFehler(null); } + /** Neuer Dienst: dessen Standard-URL; Modellnamen bleiben stehen. */ + function vorlageWaehlen(v: KiVorlage) { + setWerte({ ...werte, openai: { ...werte.openai, vorlage: v, endpoint: VORLAGEN[v].endpoint } }); + } + async function sichern() { if (ohneProjekt || fehler) return; setSpeicherFehler(null); @@ -201,6 +273,22 @@ export function EinstellungenDialog({ schliessen }: DialogKomponentenProps) { } } + const platzhalterSchluessel = + schluesselStatus === 'vorhanden' + ? T.platzhalterErsetzen + : anbieter === 'claude' + ? T.platzhalterClaude + : anbieter === 'openai' + ? f(T.platzhalterOpenAi, { name }) + : T.platzhalterFoundry; + + const datenschutz = + anbieter === 'openai' && vorlage.lokal + ? T.datenschutzLokal + : anbieter === 'openai' && werte.openai.vorlage === 'openrouter' + ? `${f(T.datenschutz, { name })} ${T.datenschutzOpenRouter}` + : f(T.datenschutz, { name }); + return ( {T.anbieterAbschnitt} - + {ohneProjekt &&

{T.ohneProjekt}

} - {anbieter === 'foundry' ? ( + {anbieter === 'foundry' && ( <> {(id) => ( @@ -235,18 +323,24 @@ export function EinstellungenDialog({ schliessen }: DialogKomponentenProps) { placeholder="https://.services.ai.azure.com/anthropic" spellCheck={false} autoComplete="off" - aria-invalid={!!fehler} + aria-invalid={!!endpointFalsch} onChange={(e) => setWerte({ ...werte, endpoint: e.target.value })} /> )} -

- {fehler ?? T.endpointHilfe} -

- setWerte({ ...werte, deployments })} /> + {T.endpointHilfe} + setWerte({ ...werte, deployments })} + />

{T.foundryModelle}

- ) : ( + )} + + {anbieter === 'claude' && ( <> {(id) => ( @@ -257,22 +351,89 @@ export function EinstellungenDialog({ schliessen }: DialogKomponentenProps) { placeholder={CLAUDE_API_ENDPOINT} spellCheck={false} autoComplete="off" - aria-invalid={!!fehler} + aria-invalid={!!endpointFalsch} onChange={(e) => setWerte({ ...werte, claude: { ...werte.claude, endpoint: e.target.value } })} /> )} -

- {fehler ?? f(T.basisUrlHilfe, { url: CLAUDE_API_ENDPOINT })} -

+ {f(T.basisUrlHilfe, { url: CLAUDE_API_ENDPOINT })} setWerte({ ...werte, claude: { ...werte.claude, modelle } })} />

{T.claudeModelle}

)} + + {anbieter === 'openai' && ( + <> +
+ + {(id) => ( + + )} + + + {(id) => ( + setWerte({ ...werte, openai: { ...werte.openai, endpoint: e.target.value } })} + /> + )} + +
+ + {vorlage.endpoint ? f(T.openAiBasisUrlHilfe, { name: vorlage.name, url: vorlage.endpoint }) : T.openAiBasisUrlEigen} + + setWerte({ ...werte, openai: { ...werte.openai, modelle } })} + /> + {T.openAiModelle} +
+ + {(id) => ( + setWerte({ ...werte, kontextText: e.target.value })} + /> + )} + +
+ {vorlage.lokal ? T.kontextLokal : T.kontextCloud} + + )} {speicherFehler &&
{speicherFehler}
} @@ -289,13 +450,12 @@ export function EinstellungenDialog({ schliessen }: DialogKomponentenProps) { )}
+ {anbieter === 'openai' && vorlage.ohneSchluessel &&

{f(T.schluesselOptional, { name })}

}
void schluesselHinterlegen(e)}> {backendArt === 'tauri' ? T.hinweisTauri : T.hinweisDemo}

-

{f(T.datenschutz, { name })}

+

{datenschutz}

); } diff --git a/src/features/ki/PruefDialog.tsx b/src/features/ki/PruefDialog.tsx index a066eb6..dd7259f 100644 --- a/src/features/ki/PruefDialog.tsx +++ b/src/features/ki/PruefDialog.tsx @@ -1,9 +1,10 @@ /** Dialog „Prüfen“: Umfang, Tiefencheck, Schätzung vor dem Start, Fortschritt, Ergebnis. */ import { useEffect, useMemo, useState } from 'react'; -import { EFFORT, formatiereUsd, kiEndpoint, type PruefAuftrag, type Schaetzung } from '@core/ki/index'; +import { EFFORT, kiEndpoint, type PruefAuftrag, type Schaetzung } from '@core/ki/index'; import type { Id } from '@core/model/types'; import type { DialogKomponentenProps } from '../../app/dialogTypen'; -import { f, useTexte, zahl as zahlFormat, sprache } from '../../i18n'; +import { f, useTexte, zahl as zahlFormat } from '../../i18n'; +import { kostenBetrag, kostenWert } from './kosten'; import { kiFehlerAnzeige, type KiErgebnis, type KiFehlerAnzeige } from '../../state/slices/ki'; import { useStore } from '../../state/store'; import { Button, Checkbox, Dialog, Segment } from '../../ui'; @@ -221,7 +222,7 @@ export function PruefDialog({ param, schliessen }: DialogKomponentenProps) {
{T.ausgabe}
{f(T.ausgabeWert, { n: zahl(s.ausgabeTokens) })}
{T.kosten}
-
{f(T.kostenWert, { betrag: formatiereUsd(s.kostenUsd, sprache()) })}
+
{kostenWert(s, T.kostenWert, 'betrag')}
{!s.gezaehlt &&

{T.nichtGezaehlt}

} @@ -278,7 +279,7 @@ function ErgebnisAnsicht({ ergebnis, schliessen, hinweiseAnsehen }: { ergebnis: eingabe: zahl(tokens.eingabe), cache: zahl(tokens.cacheGelesen), ausgabe: zahl(tokens.ausgabe), - kosten: formatiereUsd(ergebnis.kostenUsd ?? 0, sprache()), + kosten: kostenBetrag(ergebnis), })} diff --git a/src/features/ki/ki.css b/src/features/ki/ki.css index b98b7b8..a65d610 100644 --- a/src/features/ki/ki.css +++ b/src/features/ki/ki.css @@ -30,6 +30,33 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } +/* OpenAI-kompatibel: Dienst | Basis-URL */ +.pw-ki-felder--url { + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); +} + +/* Auswahl im Stil von .pw-textfeld */ +.pw-ki-select { + font: inherit; + font-size: 13px; + height: 32px; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); + color: var(--ink); + min-width: 0; +} + +.pw-ki-select:focus { + border-color: var(--dotted); +} + +.pw-ki-select:focus-visible { + outline: 2px solid var(--link); + outline-offset: 1px; +} + .pw-ki-kasten { display: flex; flex-direction: column; diff --git a/src/features/ki/ki.test.tsx b/src/features/ki/ki.test.tsx index 0c01a97..afc2bd7 100644 --- a/src/features/ki/ki.test.tsx +++ b/src/features/ki/ki.test.tsx @@ -214,7 +214,7 @@ describe('Dialoge und Vorschläge (UI)', () => { }); it('Einstellungen: Anbieter umschalten ohne Datenverlust, Schlüssel je Anbieter', async () => { - useStore.setState({ kiSchluesselJe: { foundry: 'unbekannt', claude: 'unbekannt' }, kiSchluessel: 'unbekannt' }); + useStore.setState({ kiSchluesselJe: { foundry: 'unbekannt', claude: 'unbekannt', openai: 'unbekannt' }, kiSchluessel: 'unbekannt' }); const schliessen = vi.fn(); render(); const foundry = screen.getByRole('button', { name: 'Microsoft Foundry' }); @@ -280,6 +280,60 @@ describe('Dialoge und Vorschläge (UI)', () => { expect(useStore.getState().kiSchluessel).toBe('fehlt'); }); + it('Einstellungen: OpenAI-kompatibler Dienst mit Vorlage, Modell und Kontextlänge', async () => { + useStore.setState({ kiSchluesselJe: { foundry: 'unbekannt', claude: 'unbekannt', openai: 'unbekannt' }, kiSchluessel: 'unbekannt' }); + const schliessen = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'OpenAI-kompatibel' })); + + // Standard: Ollama auf diesem Mac, ohne Schlüssel, ohne Modell + expect(screen.getByLabelText('Dienst')).toHaveValue('ollama'); + expect(screen.getByLabelText('Basis-URL')).toHaveValue('http://localhost:11434/v1'); + expect(screen.getByLabelText('Prüfung')).toHaveAttribute('placeholder', 'qwen3:32b'); + expect(screen.getByText('Bitte mindestens ein Modell eintragen.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sichern' })).toBeDisabled(); + expect(screen.getByRole('heading', { name: 'API-Schlüssel · Ollama' })).toBeInTheDocument(); + expect(screen.getByText(/Für Ollama ist kein Schlüssel nötig/)).toBeInTheDocument(); + expect(screen.getByText(/verlässt den Rechner nicht/)).toBeInTheDocument(); + expect(screen.getByText(/OLLAMA_CONTEXT_LENGTH/)).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Prüfung'), { target: { value: 'qwen3:32b' } }); + fireEvent.change(screen.getByLabelText('Kontextlänge (Tokens)'), { target: { value: 'viel' } }); + expect(screen.getByText('Bitte eine ganze Zahl eintragen (oder leer lassen).')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sichern' })).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Kontextlänge (Tokens)'), { target: { value: '131.072' } }); + // http nur für localhost + fireEvent.change(screen.getByLabelText('Basis-URL'), { target: { value: 'http://192.168.1.20:11434/v1' } }); + expect(screen.getByText('Die Adresse muss mit https:// beginnen (http:// nur für localhost).')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Basis-URL'), { target: { value: 'http://127.0.0.1:11434/v1/chat/completions' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Sichern' })); + await waitFor(() => expect(schliessen).toHaveBeenCalled()); + const ki = useStore.getState().daten!.projekt.ki!; + expect(ki.anbieter).toBe('openai'); + expect(ki.openai).toEqual({ + vorlage: 'ollama', + endpoint: 'http://127.0.0.1:11434/v1', + modelle: { erkennung: '', pruefung: 'qwen3:32b', tiefencheck: '' }, + kontextTokens: 131072, + }); + expect(useStore.getState().kiEinstellungen().deployments.pruefung).toBe('claude-sonnet-5-5'); + }); + + it('Einstellungen: Dienst wechseln setzt dessen Standard-URL, Cloud-Dienste verlangen einen Schlüssel', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'OpenAI-kompatibel' })); + fireEvent.change(screen.getByLabelText('Dienst'), { target: { value: 'openrouter' } }); + expect(screen.getByLabelText('Basis-URL')).toHaveValue('https://openrouter.ai/api/v1'); + expect(screen.getByRole('heading', { name: 'API-Schlüssel · OpenRouter' })).toBeInTheDocument(); + expect(screen.queryByText(/kein Schlüssel nötig/)).toBeNull(); + expect(screen.getByPlaceholderText('API-Schlüssel von OpenRouter einfügen')).toBeInTheDocument(); + expect(screen.getByText(/keine Daten speichern oder zum Training nutzen/)).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Dienst'), { target: { value: 'eigen' } }); + expect(screen.getByLabelText('Basis-URL')).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Sichern' })).toBeDisabled(); + }); + it('endpointNormalisieren', () => { expect(endpointNormalisieren('https://x.services.ai.azure.com/anthropic/')).toBe('https://x.services.ai.azure.com/anthropic'); expect(endpointNormalisieren('https://x.services.ai.azure.com/anthropic/v1/messages/count_tokens')).toBe('https://x.services.ai.azure.com/anthropic'); diff --git a/src/features/ki/kosten.ts b/src/features/ki/kosten.ts new file mode 100644 index 0000000..ce3932f --- /dev/null +++ b/src/features/ki/kosten.ts @@ -0,0 +1,25 @@ +/** + * Kostenanzeige für KI-Läufe. Bei OpenAI-kompatiblen Diensten kennt Plotwerk keinen Listenpreis + * (`kostenBekannt: false`, lokal kostet es nichts); dann steht statt eines Betrags ein Hinweis. + */ +import { formatiereUsd } from '@core/ki/index'; +import { f, sprache, texte } from '../../i18n'; + +export interface MitKosten { + kostenUsd?: number; + kostenBekannt?: false; +} + +export function kostenUnbekannt(w: MitKosten): boolean { + return w.kostenBekannt === false || w.kostenUsd === undefined; +} + +/** „≈ 0,12 $“ bzw. der Hinweis für unbekannte Kosten. */ +export function kostenBetrag(w: MitKosten): string { + return kostenUnbekannt(w) ? texte().ki.kostenUnbekannt : formatiereUsd(w.kostenUsd!, sprache()); +} + +/** Betrag in der Vorlage des Dialogs (z. B. „{betrag} (grob, Listenpreis)“); unbekannte Kosten ohne Vorlage. */ +export function kostenWert(w: MitKosten, vorlage: string, platzhalter: string): string { + return kostenUnbekannt(w) ? texte().ki.kostenUnbekannt : f(vorlage, { [platzhalter]: formatiereUsd(w.kostenUsd!, sprache()) }); +} diff --git a/src/features/zeitleiste/ZeitVorschlagDialog.tsx b/src/features/zeitleiste/ZeitVorschlagDialog.tsx index c5ab07b..83ae85e 100644 --- a/src/features/zeitleiste/ZeitVorschlagDialog.tsx +++ b/src/features/zeitleiste/ZeitVorschlagDialog.tsx @@ -4,11 +4,12 @@ * Nichts wird automatisch übernommen. Besitzer: G1. */ import { useEffect, useMemo, useState } from 'react'; -import { EFFORT, formatiereUsd, kiEndpoint, type Schaetzung } from '@core/ki/index'; +import { EFFORT, kiEndpoint, type Schaetzung } from '@core/ki/index'; import type { Id } from '@core/model/types'; import { hauptfigur, offeneZeitVorschlaege, offeneZustandVorschlaege, zeitEinheiten } from '@core/zeit/index'; import type { DialogKomponentenProps } from '../../app/dialogTypen'; -import { f, useTexte, vergleich, zahl as zahlFormat, sprache } from '../../i18n'; +import { f, useTexte, vergleich, zahl as zahlFormat } from '../../i18n'; +import { kostenBetrag, kostenWert } from '../ki/kosten'; import { kiFehlerAnzeige, type KiFehlerAnzeige } from '../../state/slices/ki'; import type { ZeitKiArt, ZeitKiAuftrag } from '../../state/slices/zeit'; import { useStore } from '../../state/store'; @@ -208,7 +209,7 @@ export default function ZeitVorschlagDialog({ param, schliessen }: DialogKompone {ergebnis.schonEingetragen > 0 && f(t.schonEingetragen, { n: ergebnis.schonEingetragen })} {ergebnis.bereitsEntschieden > 0 && f(t.schonVerworfen, { n: ergebnis.bereitsEntschieden })} - {f(t.verworfen, { n: ergebnis.verworfen.length })} · {formatiereUsd(ergebnis.kostenUsd, sprache())} + {f(t.verworfen, { n: ergebnis.verworfen.length })} · {kostenBetrag(ergebnis)} {ergebnis.verworfen.length > 0 && ( @@ -244,7 +245,7 @@ export default function ZeitVorschlagDialog({ param, schliessen }: DialogKompone
{t.ausgabe}
{f(t.ausgabeWert, { n: zahl(s.ausgabeTokens) })}
{t.kosten}
-
{f(t.kostenWert, { kosten: formatiereUsd(s.kostenUsd, sprache()) })}
+
{kostenWert(s, t.kostenWert, 'kosten')}
)} diff --git a/src/i18n/de/backend.ts b/src/i18n/de/backend.ts index 3537290..7cce0f0 100644 --- a/src/i18n/de/backend.ts +++ b/src/i18n/de/backend.ts @@ -9,7 +9,15 @@ export const backend = { foundry: 'Kein KI-Endpoint eingetragen. Bitte in den KI-Einstellungen die Foundry-URL angeben (https://.services.ai.azure.com/anthropic).', claude: 'Keine Basis-URL für die Claude API eingetragen. Bitte in den KI-Einstellungen prüfen (Standard: https://api.anthropic.com).', + openai: 'Keine Basis-URL eingetragen. Bitte in den KI-Einstellungen die Adresse des Dienstes angeben (z. B. http://localhost:11434/v1).', }, + /** OpenAI-kompatibel: kein Modellname eingetragen */ + keinModell: 'Kein Modell eingetragen. Bitte in den KI-Einstellungen den Modellnamen des Dienstes angeben (z. B. qwen3:32b).', + /** OpenAI-kompatibel: Anfrage größer als die eingestellte Kontextlänge */ + kontextZuGross: + 'Die Anfrage ist mit geschätzt {tokens} Tokens zu groß für die eingestellte Kontextlänge von {grenze} Tokens. Bitte die Kontextlänge im Server erhöhen und in den KI-Einstellungen eintragen, ein Modell mit größerem Kontext wählen oder einen kleineren Umfang prüfen.', + /** OpenAI-kompatibel: count_tokens gibt es nicht (die App schätzt dann) */ + keinZaehlen: 'Dieser Dienst kann keine Tokens zählen.', /** Demo-KI: Anfrage abgebrochen */ anfrageAbgebrochen: 'Die Anfrage wurde abgebrochen.', }; diff --git a/src/i18n/de/ki.ts b/src/i18n/de/ki.ts index e1e943a..36913cf 100644 --- a/src/i18n/de/ki.ts +++ b/src/i18n/de/ki.ts @@ -1,11 +1,16 @@ /** Texte für den Bereich „ki“ (deutsch, Quellsprache). Schlüssel: kurze deutsche Bezeichner. */ export const ki = { kapitelAnzahl: { one: '{n} Kapitel', other: '{n} Kapitel' }, + /** Kosten bei OpenAI-kompatiblen Diensten (kein Listenpreis bekannt) */ + kostenUnbekannt: 'keine Angabe – rechnet der Dienst ab (lokal kostenlos)', einstellungen: { titel: 'Einstellungen', ungueltig: 'Das ist keine gültige Adresse.', https: 'Der Endpoint muss mit https:// beginnen.', rollen: { erkennung: 'Erkennung (Haiku)', pruefung: 'Prüfung (Sonnet)', tiefencheck: 'Tiefencheck (Opus)' }, + /** Rollen ohne Claude-Modellnamen (OpenAI-kompatible Dienste) */ + rollenAllgemein: { erkennung: 'Erkennung (klein, schnell)', pruefung: 'Prüfung', tiefencheck: 'Tiefencheck (gründlich)' }, + httpsLokal: 'Die Adresse muss mit https:// beginnen (http:// nur für localhost).', schluesselErsetzt: 'Schlüssel ersetzt.', schluesselHinterlegt: 'Schlüssel hinterlegt.', schluesselGeloescht: 'Schlüssel gelöscht.', @@ -17,6 +22,26 @@ export const ki = { foundryModelle: 'Eingetragen werden die Deployment-Namen aus Foundry. Region und Hosting sind egal.', basisUrl: 'Basis-URL', basisUrlHilfe: 'Leer lassen für {url} (ohne /v1/messages).', + anbieterOpenAi: 'OpenAI-kompatibel', + dienst: 'Dienst', + openAiBasisUrl: 'Basis-URL', + openAiBasisUrlHilfe: 'Mit /v1, ohne /chat/completions. Standard für {name}: {url}', + openAiBasisUrlEigen: 'Adresse eines Dienstes mit OpenAI-kompatibler API, mit /v1, ohne /chat/completions.', + openAiModelle: + 'Eingetragen werden die Modellnamen des Dienstes. Ein Modell reicht; leere Rollen nutzen das Prüfmodell. Andere Modelle als Claude sind nicht mit Plotwerk abgestimmt: Was nicht wörtlich zitiert, wird verworfen – es kommen eher weniger Hinweise.', + keinModell: 'Bitte mindestens ein Modell eintragen.', + kontext: 'Kontextlänge (Tokens)', + kontextPlatzhalter: 'z. B. 131072', + kontextFalsch: 'Bitte eine ganze Zahl eintragen (oder leer lassen).', + kontextLokal: + 'Das ganze Buch geht bei jeder Anfrage mit. Lokale Server arbeiten oft mit kleinem Kontext und kürzen dann still. Trag hier die Kontextlänge ein, die im Server eingestellt ist (Ollama: OLLAMA_CONTEXT_LENGTH, LM Studio: beim Laden des Modells) – größere Anfragen lehnt Plotwerk dann vorher ab.', + kontextCloud: 'Optional. Ist sie eingetragen, lehnt Plotwerk Anfragen ab, die nicht hineinpassen.', + schluesselOptional: 'Für {name} ist kein Schlüssel nötig; nur eintragen, wenn der Server einen verlangt.', + platzhalterOpenAi: 'API-Schlüssel von {name} einfügen', + datenschutzLokal: + 'Das Modell läuft auf diesem Mac: Das Manuskript verlässt den Rechner nicht. Ans Modell gehen nur Kapiteltexte und -titel, Namen und Aliase von Figuren und Orten, Gruppennamen, Gewissheit von Figuren und Stellen und die Planung von Figuren und Kapiteln – nie Wahrheit, Notizen, Anmerkungen oder Ideen.', + datenschutzOpenRouter: + 'OpenRouter leitet an wechselnde Anbieter weiter. Plotwerk verlangt Anbieter, die keine Daten speichern oder zum Training nutzen und das Antwortformat unterstützen.', claudeModelle: 'Eingetragen werden Modell-IDs der Claude API. Lehnen die Sicherheitsfilter von Sonnet oder Opus 5.5 eine Anfrage ab, antwortet automatisch ein Ersatzmodell von Anthropic (serverseitiger Fallback).', schluesselAbschnitt: 'API-Schlüssel · {name}', diff --git a/src/i18n/en/backend.ts b/src/i18n/en/backend.ts index 6ed8b7c..f0a6f13 100644 --- a/src/i18n/en/backend.ts +++ b/src/i18n/en/backend.ts @@ -8,6 +8,11 @@ export const backend: Form = { keinEndpoint: { foundry: 'No AI endpoint set. Enter the Foundry URL in the AI settings (https://.services.ai.azure.com/anthropic).', claude: 'No base URL set for the Claude API. Check the AI settings (default: https://api.anthropic.com).', + openai: 'No base URL set. Enter the address of the service in the AI settings (e.g. http://localhost:11434/v1).', }, + keinModell: 'No model set. Enter the model name of the service in the AI settings (e.g. qwen3:32b).', + kontextZuGross: + 'At an estimated {tokens} tokens, the request is too large for the configured context length of {grenze} tokens. Increase the context length in the server and enter it in the AI settings, choose a model with a larger context or check a smaller scope.', + keinZaehlen: 'This service cannot count tokens.', anfrageAbgebrochen: 'The request was cancelled.', }; diff --git a/src/i18n/en/ki.ts b/src/i18n/en/ki.ts index 1f16685..bd4c296 100644 --- a/src/i18n/en/ki.ts +++ b/src/i18n/en/ki.ts @@ -3,11 +3,14 @@ import type { ki as de } from '../de/ki'; export const ki: Form = { kapitelAnzahl: { one: '{n} chapter', other: '{n} chapters' }, + kostenUnbekannt: 'not known – billed by the service (free locally)', einstellungen: { titel: 'Settings', ungueltig: 'That isn’t a valid address.', https: 'The endpoint must start with https://.', rollen: { erkennung: 'Detection (Haiku)', pruefung: 'Check (Sonnet)', tiefencheck: 'Deep check (Opus)' }, + rollenAllgemein: { erkennung: 'Detection (small, fast)', pruefung: 'Check', tiefencheck: 'Deep check (thorough)' }, + httpsLokal: 'The address must start with https:// (http:// only for localhost).', schluesselErsetzt: 'Key replaced.', schluesselHinterlegt: 'Key stored.', schluesselGeloescht: 'Key deleted.', @@ -19,6 +22,26 @@ export const ki: Form = { foundryModelle: 'Enter the deployment names from Foundry. Region and hosting don’t matter.', basisUrl: 'Base URL', basisUrlHilfe: 'Leave empty to use {url} (without /v1/messages).', + anbieterOpenAi: 'OpenAI-compatible', + dienst: 'Service', + openAiBasisUrl: 'Base URL', + openAiBasisUrlHilfe: 'With /v1, without /chat/completions. Default for {name}: {url}', + openAiBasisUrlEigen: 'Address of a service with an OpenAI-compatible API, with /v1, without /chat/completions.', + openAiModelle: + 'Enter the model names of the service. One model is enough; empty roles use the check model. Models other than Claude aren’t tuned for Plotwerk: anything not quoted verbatim is discarded, so expect fewer findings.', + keinModell: 'Please enter at least one model.', + kontext: 'Context length (tokens)', + kontextPlatzhalter: 'e.g. 131072', + kontextFalsch: 'Please enter a whole number (or leave it empty).', + kontextLokal: + 'The whole book is sent with every request. Local servers often run with a small context and then cut it silently. Enter the context length set in the server (Ollama: OLLAMA_CONTEXT_LENGTH, LM Studio: when loading the model) – Plotwerk then rejects larger requests up front.', + kontextCloud: 'Optional. If set, Plotwerk rejects requests that don’t fit.', + schluesselOptional: '{name} doesn’t need a key; only enter one if the server asks for it.', + platzhalterOpenAi: 'Paste your API key from {name}', + datenschutzLokal: + 'The model runs on this Mac: the manuscript never leaves the computer. The model only receives chapter texts and titles, names and aliases of characters and places, group names, the certainty of characters and passages and the planning of characters and chapters – never truth, notes, comments or ideas.', + datenschutzOpenRouter: + 'OpenRouter forwards to changing providers. Plotwerk requires providers that neither store data nor train on it and that support the response format.', claudeModelle: 'Enter Claude API model IDs. If the safety filters of Sonnet or Opus 5.5 decline a request, a fallback model from Anthropic answers automatically (server-side fallback).', schluesselAbschnitt: 'API key · {name}', diff --git a/src/state/slices/figurenbogen.ts b/src/state/slices/figurenbogen.ts index 8f71d0d..d343b20 100644 --- a/src/state/slices/figurenbogen.ts +++ b/src/state/slices/figurenbogen.ts @@ -17,7 +17,7 @@ import { setzeStimmenStatus, stimmenStatus, } from '@core/figuren/index'; -import { KiFehler, pruefeStimme, schaetzeStimme, type Schaetzung, type StimmenAuftrag } from '@core/ki/index'; +import { bekannteKosten, KiFehler, pruefeStimme, schaetzeStimme, type Schaetzung, type StimmenAuftrag } from '@core/ki/index'; import type { BogenStufe, Figurenbogen as BogenDaten, HinweisEntscheidung, Id, ProjektDaten, TextAnker } from '@core/model/types'; import type { ProjektLeser } from '@core/projekt/fs'; import { sprache, texte } from '../../i18n'; @@ -39,6 +39,8 @@ export interface StimmenLaufErgebnis { vage: boolean; modell: string; kostenUsd: number; + /** false = Kosten unbekannt (OpenAI-kompatibler Dienst) */ + kostenBekannt?: false; zeit: number; } @@ -197,7 +199,7 @@ export const createFigurenbogenSlice: StateCreator; kiVorschlaege: Record; - /** projekt.ki mit Standardwerten beider Anbieter ergänzt (fehlt der Anbieter: Foundry) */ + /** projekt.ki mit Standardwerten aller Anbieter ergänzt (fehlt der Anbieter: Foundry) */ kiEinstellungen(): Required; kiEinstellungenSpeichern(ki: KiEinstellungen): Promise; kiSchaetzen(auftrag: PruefAuftrag): Promise; @@ -151,7 +151,7 @@ export const createKiSlice: StateCreator = (set, get) => kiErgebnis: null, kiFehler: null, kiSchluessel: 'unbekannt', - kiSchluesselJe: { foundry: 'unbekannt', claude: 'unbekannt' }, + kiSchluesselJe: { foundry: 'unbekannt', claude: 'unbekannt', openai: 'unbekannt' }, kiVorschlaege: {}, kiEinstellungen() { @@ -161,10 +161,11 @@ export const createKiSlice: StateCreator = (set, get) => async kiEinstellungenSpeichern(ki) { const projekt = get().daten?.projekt; if (!projekt) throw new Error(texte().state.fehler.keinProjekt); - // Die Einstellungen des anderen Anbieters bleiben erhalten, auch wenn `ki` sie nicht enthält. + // Die Einstellungen der anderen Anbieter bleiben erhalten, auch wenn `ki` sie nicht enthält. const alt = projekt.ki; const claude = ki.claude ?? alt?.claude; - await get().speichereProjekt({ ...projekt, ki: { ...ki, ...(claude ? { claude } : {}) } }); + const openai = ki.openai ?? alt?.openai; + await get().speichereProjekt({ ...projekt, ki: { ...ki, ...(claude ? { claude } : {}), ...(openai ? { openai } : {}) } }); // kiSchluessel zeigt den Status des (ggf. neu) gewählten Anbieters if (kiAnbieter(ki) !== kiAnbieter(alt)) await get().kiSchluesselPruefen(kiAnbieter(ki)); }, diff --git a/src/state/slices/kileser.ts b/src/state/slices/kileser.ts index bd4dc1d..19639c8 100644 --- a/src/state/slices/kileser.ts +++ b/src/state/slices/kileser.ts @@ -30,6 +30,8 @@ export interface FragenSchaetzung { ersteUsd: number; /** jede weitere Frage innerhalb von 5 Minuten (Buch aus dem Cache) */ weitereUsd: number; + /** false = Kosten unbekannt (OpenAI-kompatibler Dienst) */ + kostenBekannt?: false; eingabeTokens: number; gezaehlt: boolean; } @@ -139,7 +141,7 @@ export const createKiLeserSlice: StateCreator = (se const eintraege = [...alt.eintraege.filter((e) => e.kapitel !== kapitel), stand].sort( (a, b) => reihenfolge.indexOf(a.kapitel) - reihenfolge.indexOf(b.kapitel), ); - await get().speichereKiLeser({ version: 1, eintraege, lauf: { datum: r.datum, modell: r.modell, kostenUsd: Math.round(r.kostenUsd * 10000) / 10000 } }); + await get().speichereKiLeser({ version: 1, eintraege, lauf: { datum: r.datum, modell: r.modell, ...(r.kostenBekannt === false ? {} : { kostenUsd: Math.round(r.kostenUsd * 10000) / 10000 }) } }); setze({ simVerworfen: { ...zustand().simVerworfen, [kapitel]: r.verworfen.map((v) => v.grund) } }); return true; } catch (e) { @@ -178,6 +180,7 @@ export const createKiLeserSlice: StateCreator = (se weitereUsd: kostenUsd({ eingabe: s.eingabeTokens - s.prefixTokens, ausgabe: s.ausgabeTokens, cacheGelesen: s.prefixTokens, cacheGeschrieben: 0 }, preis), eingabeTokens: s.eingabeTokens, gezaehlt: s.gezaehlt, + ...(s.kostenBekannt === false ? { kostenBekannt: false as const } : {}), }; setze({ fragenSchaetzung: { schnell, wert } }); return wert; @@ -213,7 +216,7 @@ export const createKiLeserSlice: StateCreator = (se await get().speichereKiFragen({ version: 1, eintraege: [...alt.eintraege, eintrag], - lauf: { datum: r.datum, modell: r.modell, kostenUsd: eintrag.kostenUsd }, + lauf: { datum: r.datum, modell: r.modell, ...(eintrag.kostenUsd !== undefined ? { kostenUsd: eintrag.kostenUsd } : {}) }, }); const hinweis = r.ergebnis.verworfeneBelege > 0 diff --git a/src/state/slices/zeit.ts b/src/state/slices/zeit.ts index e991964..f4afd11 100644 --- a/src/state/slices/zeit.ts +++ b/src/state/slices/zeit.ts @@ -8,6 +8,7 @@ */ import type { StateCreator } from 'zustand'; import { + bekannteKosten, KiFehler, schaetzeZeitpunkte, schaetzeZustaende, @@ -70,6 +71,8 @@ export interface ZeitLaufErgebnis { kapitel: number; modell: string; kostenUsd: number; + /** false = Kosten unbekannt (OpenAI-kompatibler Dienst) */ + kostenBekannt?: false; zeit: number; } @@ -270,7 +273,7 @@ export const createZeitSlice: StateCreator = (set, get const gemischt = mischeZeitVorschlaege( daten().kiZeitVorschlaege, r.ergebnis.vorschlaege, - { datum: r.datum, modell: r.modell, kostenUsd: r.kostenUsd }, + { datum: r.datum, modell: r.modell, ...bekannteKosten(r) }, daten(), r.ergebnis.kapitel, ); @@ -285,6 +288,7 @@ export const createZeitSlice: StateCreator = (set, get kapitel: r.ergebnis.kapitel.length, modell: r.modell, kostenUsd: r.kostenUsd, + ...(r.kostenBekannt === false ? { kostenBekannt: false as const } : {}), zeit: Date.now(), }, }); @@ -295,7 +299,7 @@ export const createZeitSlice: StateCreator = (set, get const gemischt = mischeZustandVorschlaege( daten().kiZustandVorschlaege, r.ergebnis.vorschlaege, - { datum: r.datum, modell: r.modell, kostenUsd: r.kostenUsd }, + { datum: r.datum, modell: r.modell, ...bekannteKosten(r) }, daten(), za.figur, ); @@ -311,6 +315,7 @@ export const createZeitSlice: StateCreator = (set, get kapitel: r.ergebnis.kapitel.length, modell: r.modell, kostenUsd: r.kostenUsd, + ...(r.kostenBekannt === false ? { kostenBekannt: false as const } : {}), zeit: Date.now(), }, });