Contexto
Cada provider de LLM devuelve respuestas en formatos completamente diferentes. OpenAI usa choices[0].message.content, Gemini usa candidates[0].content.parts[0].text, DeepSeek sigue el formato OpenAI pero con campos extra. Si tu codigo de aplicacion trabaja directamente con estas estructuras, terminas con condicionales por todos lados y cada nuevo provider requiere cambios en multiples archivos. Necesitaba una capa de normalizacion en el boundary del gateway.
Lo que aprendi
El patron es simple: normalizar en el punto de entrada, antes de que la respuesta llegue al codigo de aplicacion. Todo se mapea a un NormalizedResponse con los campos que realmente importan.
Respuestas raw de cada provider
Asi se ven las respuestas reales (simplificadas) de 3 providers distintos:
# OpenAI - choices[0].message.content
openai_raw = {
"id": "chatcmpl-abc123",
"object": "chat.completion",
"model": "gpt-4-turbo",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "La respuesta va aqui."},
"finish_reason": "stop",
}],
"usage": {"prompt_tokens": 50, "completion_tokens": 12, "total_tokens": 62},
}
# Gemini - candidates[0].content.parts[0].text
gemini_raw = {
"candidates": [{
"content": {
"parts": [{"text": "La respuesta va aqui."}],
"role": "model",
},
"finishReason": "STOP",
}],
"usageMetadata": {
"promptTokenCount": 48,
"candidatesTokenCount": 14,
"totalTokenCount": 62,
},
}
# DeepSeek - formato compatible con OpenAI pero con campos extra
deepseek_raw = {
"id": "ds-abc123",
"object": "chat.completion",
"model": "deepseek-chat",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "La respuesta va aqui."},
"finish_reason": "stop",
}],
"usage": {
"prompt_tokens": 50,
"completion_tokens": 12,
"total_tokens": 62,
"prompt_cache_hit_tokens": 40, # Campo exclusivo de DeepSeek
},
}
Dataclass unificado
from dataclasses import dataclass, field
from typing import Any
@dataclass
class NormalizedResponse:
"""Respuesta unificada independiente del provider."""
content: str
model: str
provider: str
tokens_used: int
latency_ms: float
finish_reason: str = "stop"
raw_response: dict[str, Any] = field(default_factory=dict, repr=False)
@property
def cost_estimate(self) -> float:
"""Estimacion de costo basada en tokens y provider."""
rates = {
"openai": 0.01,
"google": 0.0,
"deepseek": 0.0014,
}
rate = rates.get(self.provider, 0.01)
return (self.tokens_used / 1000) * rate
Normalizadores por provider
import time
from typing import Callable
# Tipo para funciones normalizadoras
Normalizer = Callable[[dict[str, Any], float], NormalizedResponse]
def normalize_openai(raw: dict[str, Any], latency_ms: float) -> NormalizedResponse:
"""Normaliza respuesta de OpenAI/compatible."""
choice = raw["choices"][0]
usage = raw.get("usage", {})
return NormalizedResponse(
content=choice["message"]["content"],
model=raw["model"],
provider="openai",
tokens_used=usage.get("total_tokens", 0),
latency_ms=latency_ms,
finish_reason=choice.get("finish_reason", "unknown"),
raw_response=raw,
)
def normalize_gemini(raw: dict[str, Any], latency_ms: float) -> NormalizedResponse:
"""Normaliza respuesta de Google Gemini."""
candidate = raw["candidates"][0]
text = candidate["content"]["parts"][0]["text"]
usage = raw.get("usageMetadata", {})
return NormalizedResponse(
content=text,
model="gemini",
provider="google",
tokens_used=usage.get("totalTokenCount", 0),
latency_ms=latency_ms,
finish_reason=candidate.get("finishReason", "unknown").lower(),
raw_response=raw,
)
def normalize_deepseek(raw: dict[str, Any], latency_ms: float) -> NormalizedResponse:
"""Normaliza respuesta de DeepSeek (formato OpenAI con extras)."""
response = normalize_openai(raw, latency_ms)
response.provider = "deepseek"
return response
# Registry de normalizadores
NORMALIZERS: dict[str, Normalizer] = {
"openai": normalize_openai,
"google": normalize_gemini,
"deepseek": normalize_deepseek,
}
Uso en el router
def call_provider(
provider_key: str,
prompt: str,
) -> NormalizedResponse:
"""Llama al provider y retorna respuesta normalizada."""
provider_name = provider_key.split(":")[0]
normalizer = NORMALIZERS.get(provider_name)
if normalizer is None:
raise ValueError(f"Sin normalizador para provider: {provider_name}")
start = time.perf_counter()
raw_response = _send_request(provider_key, prompt) # Llamada HTTP real
latency_ms = (time.perf_counter() - start) * 1000
return normalizer(raw_response, latency_ms)
# El codigo de aplicacion NUNCA toca el formato raw
response = call_provider("openai:gpt-4-turbo", "Explica circuit breakers")
print(response.content) # Siempre igual
print(response.tokens_used) # Siempre igual
print(response.cost_estimate) # Calculo automatico
Por que normalizar en el boundary
Si dejas que los formatos raw se filtren al codigo de aplicacion, terminas con if provider == "openai" en docenas de archivos. Normalizar en el boundary significa que agregar un nuevo provider (Anthropic, Mistral, Cohere) requiere una sola funcion normalizadora y cero cambios en el resto del codigo.