Building against more than one foundation model means coping with incompatible response contracts. If you want to normalize llm api responses gpt-5 gemini and Claude Opus 4.8 into a single internal type, you need a deliberate mapping layer, not ad-hoc json.dumps in your request handlers. This guide walks an ordered path from raw provider payloads to a stable schema your application can depend on.
Why provider responses diverge
GPT-5 speaks the OpenAI chat completion dialect: a choices array, each with a message and finish_reason, wrapped in an object carrying usage. Gemini 3 answers with candidates, where text lives inside content.parts, and token counts sit under usageMetadata. Neither is wrong; they reflect different lineage.
The friction shows up in four places:
- Text location and multiplicity (one string vs. an array of parts)
- Tool/function call encoding
- Streaming chunk shape
- Error envelope and retry hints
If you call providers directly, you absorb all of this. If you sit behind a gateway that adapts responses to the OpenAI shape, you still hit edges the adapter can’t safely hide.
Define your canonical response shape
Start by writing the type you wish you had. Keep it minimal. Anything provider-specific goes into a meta dict you can ignore downstream.
from typing import TypedDict, Optional, List, Dict, Any
class CanonicalMessage(TypedDict):
role: str
content: str
tool_calls: Optional[List[Dict[str, Any]]]
finish_reason: Optional[str]
class CanonicalResponse(TypedDict):
id: str
model: str
message: CanonicalMessage
usage: Dict[str, int] # prompt_tokens, completion_tokens
meta: Dict[str, Any] # raw provider extras
This shape deliberately collapses Gemini’s candidate array into a single primary message. If you need multiple candidates, extend messages: List[CanonicalMessage] later, but most app code wants the top pick.
Map GPT-5 chat completions
The OpenAI-style payload is close to canonical already. The trap is content being None when the model emits tool calls.
def from_gpt5(resp: dict) -> CanonicalResponse:
choice = resp["choices"][0]
msg = choice["message"]
return {
"id": resp["id"],
"model": resp["model"],
"message": {
"role": msg.get("role", "assistant"),
"content": msg.get("content") or "",
"tool_calls": msg.get("tool_calls"),
"finish_reason": choice.get("finish_reason"),
},
"usage": {
"prompt_tokens": resp["usage"]["prompt_tokens"],
"completion_tokens": resp["usage"]["completion_tokens"],
},
"meta": {"raw": resp},
}
Pitfall: finish_reason values differ (stop vs length vs tool_calls). Normalize them to a lowercase enum in your mapper so the rest of the app never branches on provider strings.
Map Gemini 3 generateContent
Gemini nests text in parts and uses finishReason (camelCase). Function calls appear as functionCall parts rather than a dedicated array.
def from_gemini(resp: dict) -> CanonicalResponse:
cand = resp["candidates"][0]
parts = cand["content"]["parts"]
text = "".join(p.get("text", "") for p in parts)
tool_calls = []
for p in parts:
if "functionCall" in p:
fc = p["functionCall"]
tool_calls.append({
"id": fc.get("name"), # Gemini has no call id; synthesize
"type": "function",
"function": {"name": fc["name"], "arguments": fc.get("args", {})},
})
um = resp.get("usageMetadata", {})
return {
"id": resp.get("responseId", ""),
"model": resp.get("modelVersion", ""),
"message": {
"role": cand["content"].get("role", "model"),
"content": text,
"tool_calls": tool_calls or None,
"finish_reason": (cand.get("finishReason") or "").lower(),
},
"usage": {
"prompt_tokens": um.get("promptTokenCount", 0),
"completion_tokens": um.get("candidatesTokenCount", 0),
},
"meta": {"raw": resp},
}
Gemini function call IDs
Gemini does not return a stable tool call ID. If your orchestration loop needs to correlate a call with a later result, generate a UUID at mapping time and store it in meta. Don’t pretend the provider supplied one.
Normalize streaming chunks
When you normalize llm api responses gpt-5 gemini for streaming, treat deltas as immutable events. Map each chunk to a tiny CanonicalDelta.
def delta_from_gpt5(chunk: dict) -> dict:
d = chunk["choices"][0]["delta"]
return {
"content": d.get("content", ""),
"tool_call_chunk": d.get("tool_calls"),
"finish_reason": chunk["choices"][0].get("finish_reason"),
}
def delta_from_gemini(chunk: dict) -> dict:
# Gemini SSE sends candidates with parts incrementally
parts = chunk["candidates"][0]["content"]["parts"]
text = "".join(p.get("text", "") for p in parts)
return {
"content": text,
"tool_call_chunk": None,
"finish_reason": chunk["candidates"][0].get("finishReason", "").lower(),
}
Accumulate content in the consumer, not the mapper. The mapper stays pure: input chunk, output delta.
Unify tool calls and function results
GPT-5 serializes arguments as a JSON string. Gemini passes a native object. Convert both to a canonical arguments_json string at the boundary so your executor can json.loads without type checks.
For results, both providers accept a tool role message, but OpenAI wants tool_call_id; Gemini wants functionResponse parts. Keep a separate to_provider_tool_result function per backend. The normalization layer only promises a common read shape, not a common write shape.
Error and rate-limit normalization
OpenAI returns {"error": {"type": "rate_limit_error", "message": "..."}} with HTTP 429. Gemini returns {"error": {"code": 429, "status": "RESOURCE_EXHAUSTED", "message": "..."}}. Wrap both:
class ModelError(Exception):
def __init__(self, status: int, code: str, message: str):
self.status = status
self.code = code
self.message = message
A gateway such as n4n.ai will automatically fallback when a provider is rate-limited or degraded and meter per-token usage, but your error wrapper should still present a uniform exception so retry logic doesn’t branch on vendor strings.
Test against recorded fixtures
Record one real (or sandbox) response from each provider and commit it. Map it in a pytest case.
def test_gpt5_mapping():
with open("fixtures/gpt5_sample.json") as f:
resp = json.load(f)
out = from_gpt5(resp)
assert out["message"]["role"] == "assistant"
assert out["usage"]["prompt_tokens"] > 0
Do the same for Gemini, plus a streaming fixture that asserts concatenated deltas equal the non-streaming text. This catches schema drift when providers ship a new minor version.
Tradeoffs and when to skip unification
Full normalization costs you provider-specific features. GPT-5 logprobs, Gemini’s safetyRatings, and Claude’s citation objects don’t fit the canonical mold. If your product is a debugging console for a single model, a unified schema adds noise.
Also, normalization is not free at runtime. Two extra dict copies per request are negligible, but if you proxy high-volume traffic, keep the meta raw passthrough and let callers opt into mapping.
Rollout checklist
- Write
CanonicalResponseand freeze the field names. - Implement
from_gpt5andfrom_geminiwith recorded fixtures green. - Add
delta_from_*and a streaming accumulator test. - Wrap errors in
ModelError; map HTTP and body codes. - Feature-flag the mapper so a bad mapping can be bypassed without deploy.
- Log
modelandfinish_reasonfrom the canonical shape only—never frommeta.
Following this order keeps the surface area small and lets you add Claude Opus 4.8 or Llama 4 by writing one more mapper function, not refactoring the app.