„OpenAI-kompatibel" ist die überfrachtetste Floskel im KI-Infrastrukturmarkt. Sie kann „du kannst das OpenAI-SDK auf unsere URL richten und ein einfacher Chat-Call kommt zurück" bedeuten — das sind die leichten 80% — oder sie kann „jedes Feld, jedes Streaming-Event, jeder Tool-Call-Roundtrip und jede usage-Zahl verhält sich so, wie dein Code es ohnehin schon erwartet" bedeuten. Genau in der Lücke zwischen beidem wohnen Produktionsstörungen. Dieser Beitrag ist der Feldführer: was wirklich passen muss, damit dein bestehender Code unverändert läuft, was sich über Upstreams hinweg identisch verhält und was still und leise abweicht, wenn das Modell hinter der OpenAI-Form in Wahrheit Claude (Anthropic) oder Gemini (Google) statt GPT ist.
Brievio ist ein OpenAI-kompatibles Gateway vor den echten First-Party-Modellen, also ist das hier aus dem Sitz des Übersetzers geschrieben — der Schicht, die Anthropics Messages-API und Googles Vertex-API beide aus der OpenAI-geformten Pipe herauskommen lassen muss. Ich werde konkret sagen, wo die Abstraktion sauber ist und wo sie leckt, denn so zu tun, als würde sie nie lecken, ist die Methode, mit der man nachts um 2 Uhr ein Pager-Alarm bekommt.
Was „kompatibel" tatsächlich bedeuten muss
Kompatibilität ist kein Marketing-Häkchen; es ist ein Vertrag mit dem SDK, das du bereits importiert hast. Die OpenAI-Bibliotheken für Python und Node treffen harte Annahmen über das Wire-Format. Ein Gateway ist nur dann kompatibel, wenn es sie alle einhält:
- Das Request-Schema.
POST /v1/chat/completionsmitmodel,messages(eine Liste aus Role-/Content-Objekten) und den optionalen Stellschrauben —temperature,max_tokens,top_p,stop,tools,response_format. Unbekannte Parameter sollten akzeptiert und ignoriert werden, nicht mit einem 400 abgewiesen. - Der Response-Envelope. Ein Objekt mit
id,object: "chat.completion",model,choices[](jedes mitmessage,index,finish_reason) und einemusage-Block. SDKs deserialisieren in typisierte Objekte; fehlt ein Feld, wirftresp.choices[0].message.contentauf dem Rechner von jemand anderem eine Exception. - Das Streaming-Protokoll. Server-Sent Events mit dem
data: [DONE]-Sentinel und Per-Token-delta-Objekten. Das ist die mit Abstand häufigste Sache, die „kompatible" Gateways subtil falsch machen. - Fehlerformen und HTTP-Codes. Ein 429 muss wie ein Rate-Limit aussehen, ein 400 muss ein
error-Objekt mit einemtypeund einermessagetragen. Die Retry- und Backoff-Logik im SDK hängt sich daran auf.
Hier ist die Basislinie — der Teil, den alle richtig hinbekommen. Zwei Zeilen ändern sich, und der Call gibt ein ganz normales Completion-Objekt zurück:
# Der ganze Sinn: zwei Zeilen ändern, deinen Code behalten.
# Gleiches SDK, gleiche Request-Form, gleiches Response-Objekt — anderes Modell dahinter.
from openai import OpenAI
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1", # war https://api.openai.com/v1
)
resp = client.chat.completions.create(
model="claude-sonnet-4-6", # war gpt-4o
messages=[
{"role": "system", "content": "You are concise."},
{"role": "user", "content": "Summarize the CAP theorem in two sentences."},
],
temperature=0.2,
max_tokens=300,
)
print(resp.choices[0].message.content)
print(resp.usage) # prompt_tokens / completion_tokens / total_tokens — gleiche Felder
# resp.id, resp.model, resp.choices[0].finish_reason sind alle vorhanden und wie bei OpenAI geformt.Wenn ein Gateway nicht mindestens das kann, dreh dich um und geh. Aber das ist die Mindestanforderung, nicht die Ziellinie. Die spannende Frage ist, was passiert, wenn du die Features einschaltest, die deine echte App wirklich nutzt.
Streaming: wo Kompatibilität still und leise leckt
Streaming ist das Feature, das am ehesten technisch vorhanden und praktisch kaputt ist. Der Streaming-Iterator des SDK erwartet drei Dinge: einen text/event-stream-Content-Type, Deltas, die inkrementell auf choices[0].delta.content ankommen, und eine wörtliche data: [DONE]-Zeile, die den Stream schließt. Mach eines davon falsch, und das Symptom treibt dich in den Wahnsinn — funktioniert in deinem curl-Test, hängt in Produktion.
# Beim Streaming leckt naive „Kompatibilität". Der Vertrag, auf den du dich verlässt:
# - Content-Type: text/event-stream
# - jedes Event ist "data: {json}\n\n", Deltas kommen auf choices[0].delta.content an
# - der Stream endet mit einem wörtlichen "data: [DONE]\n\n"-Sentinel
stream = client.chat.completions.create(
model="gemini-2-5-pro",
messages=[{"role": "user", "content": "Explain B-trees in one paragraph."}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
print(delta.content, end="", flush=True)
# Was Clients kaputtmacht, wenn ein Gateway es falsch handhabt:
# - die ganze Antwort puffern und dann als ein Chunk rauswerfen (kein echtes Streaming)
# - den [DONE]-Sentinel weglassen (manche SDKs hängen und warten darauf)
# - usage nur am Ende — übergib stream_options={"include_usage": True}, um es zu bekommen.Der häufigste „Fake-Streaming"-Fehler ist ein Gateway, das den Upstream aufruft, auf die komplette Antwort wartet und sie dann als einen oder zwei große Chunks ausgibt. Das SDK wirft keinen Fehler — du verlierst nur den ganzen Sinn des Streamings (die Time-to-First-Token bleibt grottenschlecht). Ein echtes Gateway hält die Verbindung zum Upstream offen und leitet jedes Token weiter, sobald es ankommt. Für Claude heißt das, Anthropics content_block_delta-Events on the fly in OpenAI- chat.completion.chunk-Events zu übersetzen; für Gemini dieselbe Aufgabe gegen das Streaming-Format von Vertex. Die Ausgabe sieht für deinen Code identisch aus, aber die Maschinerie darunter leistet echte Übersetzung pro Event.
Ein echter Unterschied, den du kennen solltest: usage in gestreamten Antworten. OpenAI fügt den usage-Block nur dann auf dem letzten Chunk ein, wenn du stream_options={"include_usage": true} übergibst. Ein gutes Gateway respektiert dieses Flag gegen jeden Upstream, sodass dein Token-Accounting-Code keinen Sonderfall pro Modell bauen muss. Den vollständigen Streaming-Vertrag findest du in den Chat-Completions-Docs.
Tools und Function-Calling: gleiche Form, anderer Motor
Tool-Calling ist das Feature, bei dem sich die OpenAI-Abstraktion ihr Geld verdient — denn die drei Anbieter haben völlig unterschiedliche native Formate, und ein Gateway versteckt das alles. Du schickst OpenAIs tools-Array; du bekommst tool_calls auf der Nachricht zurück. Was dazwischen passiert, ist eine echte Übersetzung:
# Tool- / Function-Calling: die Request-Seite passt exakt zu OpenAI.
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
resp = client.chat.completions.create(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
tools=tools,
tool_choice="auto",
)
# Das Modell liefert choices[0].message.tool_calls — eine Liste, jeder Eintrag mit einer id,
# .function.name und .function.arguments (ein JSON-*String*, den du mit json.loads parsen musst).
for call in resp.choices[0].message.tool_calls or []:
print(call.id, call.function.name, call.function.arguments)
# Danach hängst du eine {"role": "tool", "tool_call_id": call.id, "content": result}-
# Nachricht an und rufst erneut auf. Diese Schleife ist identisch, egal ob der Upstream
# Claude oder Gemini ist — das Gateway mappt das native Tool-Format jedes Anbieters
# auf dem Weg raus nach OpenAIs tool_calls und auf dem Weg rein zurück.Unter der Haube gibt Anthropic tool_use-Content-Blöcke mit einem input-Objekt zurück; Gemini gibt functionCall-Parts mit args zurück. Das Gateway mappt beides auf OpenAIs tool_calls[]-Form — inklusive des Details, dass OpenAI arguments als JSON-String liefert, den du mit json.loads parsen musst, nicht als bereits geparstes Objekt. Deine Tool-Ausführungsschleife — die Calls lesen, die Funktionen ausführen, role: "tool"-Nachrichten anhängen, erneut aufrufen — ist Byte für Byte dieselbe, egal welche Familie du anvisierst. Das ist das ganze Wertversprechen: den Agenten einmal schreiben, Modelle per String tauschen.
Die ehrlichen Vorbehalte, denn es gibt sie:
- Parallele Tool-Calls. Alle drei Familien können in einer Runde mehrere Tools anfordern, aber sie unterscheiden sich darin, wie aggressiv sie das für einen gegebenen Prompt tun. Geh nicht davon aus, dass die exakte Anzahl oder Reihenfolge über Modelle hinweg portiert — verarbeite eine Liste, keine feste Zahl.
- Strikte / strukturierte Tool-Schemata. OpenAIs
strict: true-Erzwingung des JSON-Schemas ist ein Feature der OpenAI-Modelle. Bei Claude und Gemini reicht das Gateway dein Schema als Tool-Definition durch, und das Modell hält sich eng daran, aber die Garantie liegt beim Upstream, nicht in einer Magie, die das Gateway erfinden kann. tool_choice-Feinheiten.autound das Erzwingen einer bestimmten Funktion werden überall gut unterstützt; exotische Kombinationen sind einen schnellen Test auf jedem Modell wert, das du tatsächlich ausspielst.
Vision und JSON-Modus: Pass-through, mit Kanten
Vision nutzt OpenAIs multimodales Content-Parts-Format — eine Liste, die text- und image_url-Einträge mischt. Gegen ein Modell, das Bilder nativ sieht (Gemini 2.5 Pro/Flash, die Claude-Familie), reicht das Gateway das Bild weiter, und der multimodale Call funktioniert einfach. Der JSON-Modus — response_format: { type: "json_object" } — beschränkt die Ausgabe auf ein parsebares Objekt:
# Vision: OpenAIs multimodales Content-Parts-Format, durchgereicht an ein
# Modell, das Bilder nativ unterstützt. URL oder base64 Data-URI, beides funktioniert.
resp = client.chat.completions.create(
model="gemini-2-5-pro",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "What's in this chart? Give me the trend."},
{"type": "image_url", "image_url": {
"url": "https://example.com/q3-revenue.png",
}},
],
}],
)
print(resp.choices[0].message.content)
# JSON-Modus — fordere ein garantiert parsebares Objekt an:
resp = client.chat.completions.create(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "Extract name and email as JSON."}],
response_format={"type": "json_object"},
)
import json
data = json.loads(resp.choices[0].message.content) # parst, jedes Mal.Wo die Kanten sind: Limits für Bild-Input (maximale Abmessungen, maximale Anzahl Bilder pro Request, akzeptierte MIME-Typen) werden von jedem Upstream gesetzt, nicht vom Gateway erfunden — also wird ein 50-MB-TIFF, das Gemini ablehnt, auch hinter der OpenAI-Form abgelehnt, mit einem übersetzten Fehler. Und der json_object-Modus garantiert valides JSON, nicht JSON, das zu deinem spezifischen Schema passt; wenn du eine bestimmte Struktur brauchst, beschreibe sie im Prompt und validiere nach dem Parsen. Das sind keine Gateway-Bugs — es ist der Vertrag des darunterliegenden Modells, der durchscheint, und genau das willst du von einem treuen Übersetzer erhalten wissen.
Embeddings und die Dinge, die wirklich nicht portieren
Zwei weitere Flächen, die man ehrlich benennen sollte. Embeddings (/v1/embeddings) sind einfach und stabil — aber Vektoren sind nicht über Modelle hinweg austauschbar. Ein Gemini-Embedding und ein OpenAI-Embedding leben in unterschiedlichen Räumen mit unterschiedlicher Dimensionalität; du kannst sie nicht in einem Index mischen oder ihre Kosinus-Ähnlichkeiten vergleichen. Wähle ein Embedding-Modell und re-embedde deinen gesamten Korpus, wenn du wechselst. Die API ist kompatibel; die Mathematik nicht.
Und die Lecks, die kein noch so geschicktes Kompatibilitäts-Shimming überdecken kann — die anbieterspezifischen Features, für die es schlicht kein OpenAI-Feld gibt, das sie tragen könnte:
- Anthropics Prompt-Caching. Die nativen
cache_control-Breakpoints leben auf Anthropics Messages-API. Über die OpenAI-Form bekommst du stattdessen OpenAIs automatisches Präfix-Caching; um Caching explizit zu steuern, nutzt du den nativen/v1/messages-Endpunkt. (Beides funktioniert auf Brievio — siehe die API-Docs.) - Tokenizer unterscheiden sich je Familie. „1.000 Tokens" entspricht nicht derselben String-Länge über GPT, Claude und Gemini hinweg — jedes hat seinen eigenen Tokenizer. Also verschieben sich
max_tokens-Budgets und deine Kostenschätzungen, wenn du Modelle tauschst, obwohl sich der Feldname nicht geändert hat. Ein gutes Gateway meldet die ehrlichen Token-Zahlen jedes Upstreams inusage; es kann drei Tokenizer nicht zur Übereinstimmung zwingen, und du solltest keinem trauen, der so tut, als täte er es. - Extended Thinking / Reasoning. Claudes Extended Thinking und Geminis Thinking-Modi treten anders zutage als OpenAIs Reasoning. Der Inhalt kommt durch; die exakte Feldverdrahtung ist modellspezifisch, also hartkodiere nicht die Reasoning-Form eines Anbieters über alle hinweg.
- System-Prompt-Semantik. Alle drei akzeptieren eine System-Nachricht, aber sie gewichten und kürzen sie leicht unterschiedlich. Das Verhalten portiert; es ist nicht bit-identisch. Teste deine Prompts pro Modell.
Wie ein gutes Gateway all das normalisiert
Die Aufgabe der Kompatibilitätsschicht ist, im gemeinsamen Pfad ein treuer, verlustfreier Übersetzer zu sein und an den Kanten ein ehrlicher. Konkret heißt das: das Request-Schema in beide Richtungen mappen; Streaming-Events Token für Token übersetzen, Sentinel inklusive; das native Tool-Format jedes Anbieters zu und von tool_calls konvertieren; die finish_reason-Semantik bewahren; echte Bilder an Vision-fähige Modelle durchreichen; und — der Teil, bei dem es leicht ist zu schummeln — die tatsächlichen Token-Zahlen des Upstreams melden statt einer aufgepolsterten Zahl. Auf Brievio sind die Modelle hinter der Form die echten First-Party-Modelle, rückverfolgbar zu AWS Bedrock und Google Vertex, sodass das Verhalten, das du normalisierst, das Verhalten des echten Modells ist und nicht das eines billigeren Ersatzes. Wenn du das selbst bestätigen willst: die vier Tests in is your Claude really Claude dauern etwa eine Minute.
Zwei Prinzipien fallen für jeden ab, der auf einem „kompatiblen" Endpunkt baut. Erstens: teste die Features, die du tatsächlich nutzt — ein erfolgreicher Chat-Call sagt dir nichts darüber, ob Streaming inkrementell ausgibt oder ob Tool-Call-IDs sauber den Roundtrip überstehen. Zweitens: respektiere die Lecks: Tokenizer, Embedding-Räume, Caching-Syntax und Reasoning-Formen sind Upstream-Eigenschaften, und das Gateway, das ehrlich damit umgeht, ist das, dem du in Produktion vertrauen kannst. Kompatibilität ist ein Spektrum, und der nützliche Teil davon ist der, der deine echte Last übersteht — nicht der, der eine Demo übersteht.
Das konkrete Fazit
Richte das OpenAI-SDK auf https://api.brievio.com/v1, ändere den Modell-String und lass deine bestehende Test-Suite laufen — kein Hello-World, deine Suite. Übe Streaming mit include_usage, fahre einen Tool-Call-Roundtrip, schicke ein Bild, fordere ein json_object an. Wenn alle vier auf dem Modell durchlaufen, das du ausspielen willst, ist die Migration tatsächlich zwei Zeilen. Wo du ein anbieterspezifisches Feature brauchst — explizites Anthropic-Caching, native Reasoning-Steuerung — wechsle für diesen Pfad auf die nativen Endpunkte und behalte die OpenAI-Form überall sonst. Willst du den Schritt-für-Schritt-Port aus einer bestehenden OpenAI-Codebasis? Fang mit calling Claude with the OpenAI SDK an und stöbere dann durch die Modellliste, um auszuwählen, was hinter der Form läuft.