Files
doctate/docs/ionos-llm-api.md
T
Brummel 0848e9581f Fix gpt-oss-120b with reasoning effort
This commit adjusts the configuration for the `gpt-oss-120b` model to
run in plain-text mode. This is necessary because when
`reasoning_effort` is set to "medium", the reasoning tokens combined
with schema-constrained decoding can exhaust the `max_completion_tokens`
budget, leading to an empty content response.

The change restores the previous behavior where `gpt-oss-120b` was
configured without `response_format` or `top_p`, ensuring the request
body sent to Ionos remains byte-identical to the pre-multi-backend
state. This prevents regressions and maintains stability for existing
production workflows.
2026-05-03 16:43:17 +02:00

390 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ionos LLM API: Quirks und Workarounds
**Stand:** 2026-05-03 (zweite Session, in Doctate umgesetzt) — empirisch
ermittelt gegen `https://openai.inference.de-txl.ionos.com/v1/chat/completions`.
Verhalten kann sich aendern; Datum oben mit dem aktuellen Test-Befund
abgleichen, bevor man auf diese Notizen baut.
## TL;DR
- Die **Marketing-Doku** unter `docs.ionos.com/cloud/ai/...` ist
unvollstaendig. Die dort gezeigten Beispiele fuer Llama 3.1 405B FP8
laufen in der Praxis in einen Gateway-Timeout, weil sie wesentliche
Parameter weglassen.
- Die **echte aktuelle API-Spezifikation** liegt unter
`https://api.ionos.com/docs/inference-openai/v1/`. Das ist eine
Redoc-Seite mit eingebetteter OpenAPI-3.0.3-Spec — die einzige
verlaessliche Quelle fuer unterstuetzte Parameter, Defaults und Limits.
- Llama 3.1 405B FP8 hat ein bekanntes **End-of-Turn-Termination-Problem**:
in der Sandbox kam `<|eot_id|>` als ASCII-`assistant\n\n` zurueck, was
Quelle der ersten Diagnose war. Aber das eigentliche Problem ist breiter
und modell-inhaerent: Llama 3.1 verfaellt mit Greedy-Decoding und/oder
konflikthaltigen Prompts in Endlos-Schleifen (bestaetigt von Ionos selbst,
HuggingFace-Discussion #32, vLLM-Issues #13530, #13828).
- **Empfohlene Loesung fuer Doctate (Drei-Komponenten-Kombi):**
`temperature: 0.6 + top_p: 0.9` (Sampling) + `response_format:
json_schema` (Form-Garantie) + expliziter Format-Hinweis als zweite
`system`-Message (sagt dem Modell WAS in `document` zu schreiben ist).
Alle drei sind notwendig; jede einzelne weggelassen kippt in einen
anderen Failure-Mode (Timeout, Endlos-Schleife, oder silent
`{"document": ""}`).
- **Wichtig**: gpt-oss-120b laeuft mit `reasoning_effort: medium`
am besten im **plain-text mode** (kein `response_format`, kein
`top_p`, keine zweite system-Message). Reasoning-Tokens zaehlen gegen
`max_completion_tokens`, und schema-constrained decoding zusaetzlich
zum Reasoning kann das Budget so leersaugen, dass `content: null`
zurueckkommt (Test T7 / Production-Failure auf Case c414cf52,
6 recordings, ~3.8k chars). Doctate setzt deshalb fuer gpt-oss
`use_json_schema: false` — Body ist byte-identisch zum
pre-multi-backend-Stand (commit bf6464d).
- Die Drei-Komponenten-Loesung ist also **Llama-spezifisch**, nicht
universell. Backends mit Reasoning brauchen das Gegenteil: schema OFF.
## So kommt man an die richtige API-Info
### Verfuegbare Modelle abfragen
Die Marketing-Doku listet Modelle, ist aber nicht immer synchron mit dem
Backend. Authoritative Liste:
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
https://openai.inference.de-txl.ionos.com/v1/models | jq .
```
Aktuell (2026-05-03) verfuegbar:
| Modell | Kategorie | Anmerkung |
|---|---|---|
| `meta-llama/Meta-Llama-3.1-8B-Instruct` | Chat | schnell, klein |
| `meta-llama/Meta-Llama-3.1-405B-Instruct-FP8` | Chat | siehe Quirks unten |
| `meta-llama/Llama-3.3-70B-Instruct` | Chat | stoppt sauber, <1s |
| `mistralai/Mistral-Nemo-Instruct-2407` | Chat | ungetestet |
| `mistralai/Mistral-Small-24B-Instruct` | Chat | siehe Doctate-Memory |
| `openai/gpt-oss-120b` | Reasoning | stoppt sauber, vorherige Doctate-Wahl |
| `BAAI/bge-m3`, `bge-large-en-v1.5` | Embedding | |
| `sentence-transformers/paraphrase-multilingual-mpnet-base-v2` | Embedding | |
| `meta-llama/CodeLlama-13b-Instruct-hf` | Code | |
| `Qwen/Qwen3-Coder-Next` | Code | |
| `lightonai/LightOnOCR-2-1B` | OCR | |
| `black-forest-labs/FLUX.1-schnell` | Bild | |
### Aktuelle OpenAPI-Spec extrahieren
Die Redoc-Seite rendert die Spec clientseitig — `openapi.json` ist nicht
unter einer stabilen URL erreichbar, sondern liegt eingebettet im HTML
als JavaScript-Variable `__redoc_state`. Extraktion mit Brace-Balancing,
weil ein Regex auf `</script>` zu viel mitnimmt:
```python
import json, re
import urllib.request
html = urllib.request.urlopen(
"https://api.ionos.com/docs/inference-openai/v1/").read().decode()
start = html.find('{"menu":{"activeItemIdx":-1}')
depth, in_str, esc = 0, False, False
for i, c in enumerate(html[start:], start):
if in_str:
if esc: esc = False
elif c == '\\': esc = True
elif c == '"': in_str = False
else:
if c == '"': in_str = True
elif c == '{': depth += 1
elif c == '}':
depth -= 1
if depth == 0:
end = i + 1; break
spec = json.loads(html[start:end])['spec']['data']
print(json.dumps(spec['paths']['/v1/chat/completions']['post'], indent=2))
```
### Parameter-Schema fuer `chat/completions` (Stand 2026-05-03)
Wichtige Defaults und Limits aus der Spec (Auszug, fett = abweichend von
OpenAI):
| Parameter | Typ | Default | Anmerkung |
|---|---|---|---|
| `model` | string | required | |
| `messages` | array | required | |
| `temperature` | number | **1** | |
| `top_p` | number | **-1** | Sentinel fuer "deaktiviert" |
| `n` | int | 1 | |
| `stream` | bool | false | |
| `stop` | array | | **max 4 Sequenzen** |
| `max_tokens` | int | **16** | **deprecated** zugunsten `max_completion_tokens` |
| `max_completion_tokens` | int | **16** | |
| `presence_penalty` | number | 0 | |
| `frequency_penalty` | number | 0 | |
| `logit_bias` | object | | Token-ID -> Bias |
| `response_format` | object | | `text` / `json_object` / `json_schema` |
| `tools`, `tool_choice` | | | Function Calling |
**Nicht exposed** (wuerde es mit anderen vLLM-Backends geben):
`repetition_penalty`, `min_p`, `top_k`, `skip_special_tokens`,
`ignore_eos`, `stop_token_ids`. Wer diese vLLM-Backdoors braucht, ist
auf Ionos falsch.
### Warum die Marketing-Doku nicht reicht
`https://docs.ionos.com/cloud/ai/ai-model-hub/models/llms/meta-llama-3-1-405b`
zeigt das Beispiel:
```json
{ "model": "...405B-FP8", "messages": [...],
"temperature": 0.7, "max_tokens": 100 }
```
Das funktioniert nur deshalb, weil `max_tokens: 100` zufaellig klein
genug ist, um vor Quirk B (siehe unten) zurueckzukommen. Sobald man
realistische Ausgabelaengen will (z.B. 2k+ Tokens fuer einen Arztbrief),
laeuft die Combo in den Gateway-Timeout, weil die Doku den Tokenizer-Bug
nicht erwaehnt und die Empfehlungen nicht praxistauglich validiert sind.
## Llama 3.1 405B FP8: Drei Quirks
### Quirk A — `<|eot_id|>` wird als ASCII geleakt
Das Llama-3.1-Chat-Template nutzt `<|eot_id|>` (End-of-Turn) als
Stop-Marker. Auf Ionos's vLLM-Deployment ist dieses Token nicht
korrekt im Tokenizer als Special-Token registriert. Folge: das Modell
emittiert es als die einzelnen ASCII-Zeichen `a s s i s t a n t \n \n`
und labert weiter. Beobachtbar an Outputs der Form:
```
Hallo! Wie kann ich helfen?assistant\n\n
Oder moechtest du einfach plaudern?assistant\n\n
Entschuldigung, ich habe mich wiederholt...assistant\n\n
```
Konsequenz: der `stop`-Parameter mit `["<|eot_id|>"]` greift nie, weil
diese String-Sequenz nicht im Output auftaucht. Workarounds:
| Workaround | Funktioniert | Sauber? |
|---|---|---|
| `stop: ["assistant"]` zusaetzlich | ja, 0.79 s fuer "Sag hallo." | unsauber, Provider-Bug-Patch |
| System-Prompt instruiert eigenen End-Marker, `stop` matcht ihn | ja, 0.89 s | mittel, Instruction-Drift moeglich |
| `response_format: json_schema` | ja, 0.84 s | sauber, offizielles Feature |
### Quirk B — Ohne `max_tokens` laeuft das Modell bis Context-Ceiling
Da das Modell selbst keinen Stop-Token emittiert (Quirk A) und
`max_tokens` Default = 16 nur theoretisch greift, generiert FP8-405B
auf vielen Pfaden bis zum Kontext-Limit (128k Tokens) weiter. Bei
~20 tok/s Generierungsrate sind das ~107 Minuten — laenger als jeder
Gateway-Timeout. Beobachtung: HTTP 504 "stream timeout" nach ~3
Minuten, oder schon `HTTP 000` clientseitig.
Konsequenz: **immer** `max_tokens` (oder `max_completion_tokens`)
explizit setzen. Realistische Werte fuer Doctate-Arztbriefe: 2048-4096.
### Quirk C — Generierungsrate ~20-23 tok/s
Empirisch ueber 6 Runs ermittelt (siehe Tabelle unten). Das ist deutlich
langsamer als `Llama-3.3-70B-Instruct` (~1 s fuer 10 Tokens) und
`gpt-oss-120b` (~0.5 s fuer 10 Tokens) auf demselben Endpoint.
Konsequenz: praktischer Hardlimit bei ~3000 Output-Tokens, sonst
Gateway-Timeout.
### Empirische Datentabelle
#### Session 1 (Diagnose, Sandbox-Inputs)
Alle Tests mit `meta-llama/Meta-Llama-3.1-405B-Instruct-FP8`,
Input `"Sag hallo."` (ausser T22 = realer Doctate-Body, ~1.5k Prompt-Tokens):
| Test | Parameter | Ergebnis | Tok/s |
|---|---|---|---|
| T1 | `temp=0`, `max_tokens=8192` | Timeout 25 s | |
| T3 | `temp=0`, `max_tokens=100` | 5.1 s, finish=length, 100 Tokens | 19.6 |
| T8 | `temp=0`, `max_tokens=500` | 23 s, finish=length, 500 Tokens | 21.7 |
| T9 | `temp=0`, `max_tokens=1500` | 114 s, finish=length, 1500 Tokens | 13.2 |
| T10 | `temp=0`, `max_tokens=3000` | 183 s, finish=length, 3000 Tokens | 16.4 |
| T11 | `temp=0`, `max_tokens=4500` | Gateway-Timeout (curl 200 s) | |
| T17 | Doku-Combo + `stop:["...","assistant"]` | **0.79 s, finish=stop, 11 Tokens** | |
| T19 | Doku-Combo + System-Marker `<|12345|>` | **0.89 s, finish=stop, 14 Tokens** | |
| T21 | `response_format: json_schema` (nur `document:string`) | **0.84 s, finish=stop, 8 Tokens** | |
| T22 | T21 + realer Doctate-Body (1539 Prompt-Tokens) | **22.77 s, finish=stop, 482 Tokens** | 22.7 |
Konsistenz: **`finish_reason: "length"` heisst Modell wollte
weiter** — kein sauberer Stop.
#### Session 2 (Reproduktion gegen Production-Pipeline, 30 s Timeout)
Alle Tests gegen den realen Doctate-Body von Case `aecf5890` (236
char user_content, ~1040-1115 Prompt-Tokens). Skripte unter
`/tmp/doctate-llama-test/run{1..6}.py`. Production-`LLAMA_SYSTEM_PROMPT`
verbatim (2819 chars).
| Test | Sampling | json_schema | Format-Hinweis | Wallclock | finish | Output |
|---|---|---|---|---|---|---|
| T1 | nein (`temp=0.5`) | ja | nein | **30 s Timeout** | | nichts geliefert |
| T2 | nein (`temp=0.5`) | ja | im Prompt inline | 3.26 s | stop | sauber, 61 Tokens |
| T3 | ja (`0.6 + 0.9 + freq=0.1 + stop`) | nein (Ionos-Doku verbatim) | nein | **30 s Timeout** | | nichts geliefert |
| T4 | ja (`0.6 + 0.9`) | ja | nein | 0.98 s | stop | **`{"document":""}` (silent deletion)** |
| T5 | ja (`0.6 + 0.9`) | ja | im Prompt inline | 3× ⌀ 3.05 s | stop | sauber, 61 Tokens, 3× stabil |
| T6 | ja (`0.6 + 0.9`) | ja | als zweite `system`-Message | 3× ⌀ 3.13 s | stop | sauber, 61 Tokens, 3× stabil — **Production-Konfig** |
**Wichtige Befunde aus Session 2:**
1. **json_schema allein reicht nicht (T1)**: Production-Pfad mit
`temperature: 0.5` ohne `top_p` timeoutet, weil Llama Schema-Resampling
als Endlos-Schleife verkraftet.
2. **Sampling allein reicht nicht (T3)**: Selbst die exakte Ionos-Doku-
Empfehlung mit `stop: ["<|eot_id|>", "<|end_of_text|>"]` timeoutet
gegen unseren 2.8k-Char-Prompt. Die Doku-Empfehlung ist fuer kuerzere
Prompts validiert.
3. **Sampling + Schema ohne Hinweis kippt in silent deletion (T4)**:
Modell respektiert die Form, weiss aber inhaltlich nicht was tun.
`{"document": ""}` waere fuer Doctate als leere Notiz im UI gelandet
— gefaehrlicher als Timeout, weil der Worker `remove_failure_marker`
aufruft. **Worker.rs hat seither einen Empty-Guard.**
4. **Drei-Komponenten-Kombi laeuft 3x stabil (T5, T6)**: Sampling +
Schema + Format-Hinweis. T6 (separate `system`-Message) ist
architektonisch sauberer und gewaehlt fuer die Doctate-Implementation.
#### Session 3 (gpt-oss-Regression vom Multi-Backend-Default, 30 s Timeout)
Modell `openai/gpt-oss-120b` mit `reasoning_effort: "medium"`,
`max_completion_tokens: 4096`. Input: realer Doctate-Body Case
`c414cf52` (6 Aufnahmen, 3851 char user_content, ~1900 prompt_tokens
nach DEFAULT_SYSTEM_PROMPT).
| Test | response_format | Wallclock | finish | content | completion_tokens |
|---|---|---|---|---|---|
| Production 14:23 | `json_schema` (DEFAULT_USE_JSON_SCHEMA) | 58 s | | **null** (`LlmError::Parse`) | |
| T7 (nach Fix) | weggelassen | 15.68 s | stop | sauberer Arztbrief, 3685 chars | 1692 |
**Ursache**: Mit `reasoning_effort: medium` zaehlen Reasoning-Tokens
gegen `max_completion_tokens`. Schema-constrained decoding (vLLM
grammar) belastet zusaetzlich das CPU-Budget. Bei Bodies dieser
Groesse fressen Reasoning + Schema-Resampling die 4096 Tokens auf,
bevor das Modell zur Inhalts-Generierung kommt — Resultat:
`choices[0].message.content == null`.
**Pre-Multi-Backend** (commit bf6464d) lief gpt-oss ohne
`response_format`, also reasoning + plain text → content kam immer
zurueck. Der Multi-Backend-Layer hat `DEFAULT_USE_JSON_SCHEMA = true`
universell aktiviert, was fuer Llama noetig, fuer gpt-oss aber
schaedlich war. Fix: `gpt_oss_120b` setzt jetzt explizit
`with_use_json_schema(false)` — Body byte-identisch zu pre-bf6464d.
## Loesungswege im Vergleich
| Loesung | Speed | Sauber | Modell-agnostisch | Kommentar |
|---|---|---|---|---|
| `stop: ["assistant"]` | sehr schnell | nein | nein | Patch fuer Provider-Bug; bricht im englischen Text |
| Eigener End-Marker via System-Prompt | sehr schnell | mittel | ja | Modell kann Marker bei langem Output vergessen |
| `response_format: json_schema` allein | | nein | nein | **In Production NICHT ausreichend** (T1, T4) — kippt entweder in Timeout oder silent deletion |
| **Drei-Komponenten-Kombi** (Sampling + Schema + Format-Hinweis) | schnell (3 s) | ja | | **In Doctate umgesetzt fuer Llama** (T5, T6) |
| Modellwechsel auf `gpt-oss-120b` / `Llama-3.3-70B` | sehr schnell | ja | | Schema-aware, brauchen die Drei-Komponenten-Kombi nicht |
**Empfehlung pro Modell-Familie:**
- **Reasoning-Modelle mit `reasoning_effort != null`** (gpt-oss-120b
und Verwandte): **plain-text mode** — kein `response_format`, kein
`top_p`. Reasoning + Schema kollidieren am Token-Budget, Resultat
ist `content: null`. Doctate-Default fuer dieses Profil:
`temperature: 0.5 + reasoning_effort: medium + max_completion_tokens:
4096`.
- **Llama 3.1 405B FP8**: nur als **Drei-Komponenten-Kombi** brauchbar
`temperature: 0.6 + top_p: 0.9 + response_format: json_schema +
expliziter Format-Hinweis als zweite system-Message`. Wer eine
Komponente weglaesst, faengt sich Timeout (T1, T3) oder silent
deletion (T4) ein.
- **Schema-aware Nicht-Reasoning-Modelle** (vermutlich Llama 3.3 70B,
Mistral Nemo): Default plain-text + `response_format: json_schema`
ist der Erwartungswert, aber **noch nicht empirisch verifiziert**
— vor Aktivierung gegen den Doctate-Body testen.
## Doctate-Implementation (`server/src/analyze/backend.rs`)
Pro-Backend-Konfiguration mit zwei verschiedenen Profilen:
```rust
// gpt-oss: schema OFF, weil Reasoning + Schema das Token-Budget aufessen.
ionos_backend("gpt_oss_120b", "GPT OSS 120b", "openai/gpt-oss-120b")
.with_reasoning_effort("medium")
.with_use_json_schema(false),
// Llama 3.1 405B FP8: Drei-Komponenten-Kombi.
const LLAMA_TEMPERATURE: f32 = 0.6;
const LLAMA_TOP_P: f32 = 0.9;
const LLAMA_FORMAT_INSTRUCTION: &str = "AUSGABEFORMAT: ...";
ionos_backend("llama_3_1_405b", "Llama 3.1 405B", "...405B-Instruct-FP8")
.with_system_prompt(LLAMA_SYSTEM_PROMPT)
.with_temperature(LLAMA_TEMPERATURE)
.with_top_p(LLAMA_TOP_P)
.with_format_instruction(LLAMA_FORMAT_INSTRUCTION),
```
`chat_once` baut den Body dynamisch:
- `response_format: json_schema` wird gesendet **gdw**
`backend.use_json_schema == true` (Llama: ja, gpt-oss: nein).
- `top_p` wird per `Option::is_none`-Skip nur gesendet wenn `Some`
(Llama: ja, gpt-oss: nein).
- Eine zweite `system`-Message wird ans `messages`-Array gehaengt,
falls `format_instruction.is_some()` (Llama: ja, gpt-oss: nein).
Damit ist der gpt-oss-Body **byte-identisch** zum pre-Multi-Backend-
Stand (commit bf6464d). Gepinnt durch
`gpt_oss_runs_in_plain_text_mode_no_schema_no_top_p_no_format_hint`-Test.
`worker.rs` hat einen **Empty-Guard** nach `vocab.replace`: leere
`document`-Strings (Failure-Mode T4 fuer Llama) loesen einen
Failure-Marker statt einer leeren `document.md` aus.
## Entscheidungsbaum
```mermaid
graph TD
A[Neuer Backend-Profil-Eintrag in backend.rs] --> B{Reasoning-Modell?<br/>reasoning_effort != null?}
B -->|Ja, gpt-oss-120b| C[plain-text mode:<br/>with_use_json_schema false,<br/>kein top_p, keine format_instruction<br/>Body byte-stabil zu pre-bf6464d]
B -->|Nein| D{Schema-aware trainiert?}
D -->|Ja, vermutlich Llama 3.3 70B,<br/>Mistral Nemo| E[Default: temp 0.5,<br/>use_json_schema=true<br/>VOR Aktivierung gegen<br/>Doctate-Body testen]
D -->|Nein, Llama 3.1 405B FP8| F[Drei-Komponenten-Kombi:<br/>temp 0.6 + top_p 0.9 + schema<br/>+ format_instruction]
F --> G[Worker-Empty-Guard schuetzt<br/>vor silent deletion]
```
## Quellen und Verweise
- API-Endpoint: `https://openai.inference.de-txl.ionos.com/v1/chat/completions`
- Authoritative API-Doku (Redoc):
`https://api.ionos.com/docs/inference-openai/v1/`
- Marketing-Doku (oft veraltet, mit Vorsicht geniessen):
`https://docs.ionos.com/cloud/ai/ai-model-hub`
- vLLM-Tokenizer-Hintergrund (warum `<|eot_id|>` als ASCII leakt):
Llama-3.1-Chat-Template + `add_special_tokens=False`-Detokenization-Pfad.
- Verwandte Doctate-Memories: `project_canary_ollama_vram_conflict`,
`project_mistral_24b_silent_deletion`,
`feedback_no_inline_llm_in_handlers`.
## Wartungshinweis
Diese Datei dokumentiert Verhalten zu einem Zeitpunkt. Bevor man bei
einem neuen Bug auf diese Workarounds setzt, immer zuerst pruefen, ob:
1. Die Modellliste sich geaendert hat (`/v1/models`).
2. `<|eot_id|>` mittlerweile im Output sauber als Token statt als
`assistant\n\n` ankommt — dann ist Quirk A behoben und die
String-Hacks koennen weg.
3. Die OpenAPI-Spec neue/geaenderte Parameter zeigt.
4. Die Drei-Komponenten-Kombi fuer Llama 3.1 weiterhin noetig ist:
schnellster Check ist, das `format_instruction`-Feld testweise auf
`None` zu setzen und einen Doctate-Case neu zu triggern. Wenn der
Output sauber kommt, kann der Format-Hinweis weg. Wenn er
`{"document":""}` ist, gilt diese Doku noch.
5. Der gpt-oss-Token-Budget-Konflikt mit `response_format: json_schema`
weiterhin existiert: testweise `with_use_json_schema(true)` fuer
gpt-oss setzen, einen 6-Aufnahmen-Case (~3.8k chars) triggern. Wenn
`LlmError::Parse: missing choices[0].message.content` im Log
erscheint, gilt diese Doku noch. Wenn der Output sauber durchkommt,
hat Ionos das Reasoning/Schema-Budget-Verhalten geaendert und
`use_json_schema=true` koennte fuer gpt-oss reaktiviert werden.
Datum oben aktualisieren, wenn man verifiziert hat, dass diese
Dokumentation noch dem realen Backend-Verhalten entspricht.