Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions core/ki/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- |
Expand All @@ -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 `<think>…</think>` 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 <Schlüssel>` (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
Expand Down
63 changes: 61 additions & 2 deletions core/ki/anbieter.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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: '' } },
});
});

Expand Down Expand Up @@ -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');
Expand Down
167 changes: 158 additions & 9 deletions core/ki/anbieter.ts
Original file line number Diff line number Diff line change
@@ -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<KiAnbieter, string> = {
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. */
Expand All @@ -30,11 +32,110 @@ export const STANDARD_CLAUDE: Readonly<KiClaudeEinstellungen> = {
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<Record<string, unknown>>;
/** Beispiel-Modellnamen (nur Platzhalter, keine Standardwerte) */
beispiele: Readonly<KiModelle>;
}

export const VORLAGEN: Readonly<Record<KiVorlage, VorlageInfo>> = {
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<KiModelle> | undefined): KiModelle {
Expand All @@ -43,7 +144,20 @@ function modelleMitStandard(m: Partial<KiModelle> | 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<KiOpenAiEinstellungen> | 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<KiEinstellungen> {
return {
endpoint: ki?.endpoint ?? '',
Expand All @@ -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 };
}
Loading
Loading