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

18 KiB
Raw Blame History

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:

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:

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:

{ "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 >`
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:

// 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

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.