n4nAI

One schema, many models: normalizing GPT-5 and Gemini 3 replies

Step-by-step guide to normalize LLM API responses from GPT-5 and Gemini 3 into one schema, with Python mappers, streaming, and pitfalls.

n4n Team3 min read710 words

Audio narration

Coming soon — every post will get a voice note here.

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

  1. Write CanonicalResponse and freeze the field names.
  2. Implement from_gpt5 and from_gemini with recorded fixtures green.
  3. Add delta_from_* and a streaming accumulator test.
  4. Wrap errors in ModelError; map HTTP and body codes.
  5. Feature-flag the mapper so a bad mapping can be bypassed without deploy.
  6. Log model and finish_reason from the canonical shape only—never from meta.

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.

Tagsunified-apigpt-5gemini-3normalization

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All integrating gpt-5, claude opus 4.8, gemini 3, llama 4 & more via one api posts →