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
-
{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) {
)}
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(),
},
});