Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions tee_gateway/controllers/chat_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
create_image_generation_response,
create_image_generation_streaming_response,
)
from tee_gateway.model_errors import (
is_upstream_model_not_served,
upstream_model_not_served_payload,
)
from tee_gateway.model_registry import get_model_config
from tee_gateway.pricing import compute_session_cost

Expand Down Expand Up @@ -367,6 +371,8 @@ def _create_non_streaming_response(chat_request: CreateChatCompletionRequest):

except Exception as e:
logger.error(f"Chat completion error: {str(e)}", exc_info=True)
if is_upstream_model_not_served(e):
return upstream_model_not_served_payload(chat_request.model, e), 404
return {
"error": str(e) or "Request processing failed",
"exception_type": type(e).__name__,
Expand Down Expand Up @@ -805,10 +811,15 @@ def generate():
# event IS the terminal marker for the error path — we do NOT
# emit a trailing `[DONE]`, which conventionally signals a
# clean completion and would mis-signal an errored stream.
error_payload = {
"error": str(e) or "Stream processing failed",
"exception_type": type(e).__name__,
}
if is_upstream_model_not_served(e):
error_payload = upstream_model_not_served_payload(
chat_request.model, e
)
else:
error_payload = {
"error": str(e) or "Stream processing failed",
"exception_type": type(e).__name__,
}
yield f"data: {json.dumps(error_payload)}\n\n"

return Response(
Expand All @@ -822,6 +833,8 @@ def generate():

except Exception as e:
logger.error(f"Stream setup error: {str(e)}", exc_info=True)
if is_upstream_model_not_served(e):
return upstream_model_not_served_payload(chat_request.model, e), 404
return {
"error": str(e) or "Stream setup failed",
"exception_type": type(e).__name__,
Expand Down
6 changes: 6 additions & 0 deletions tee_gateway/controllers/completions_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
extract_web_search_count,
extract_usage,
)
from tee_gateway.model_errors import (
is_upstream_model_not_served,
upstream_model_not_served_payload,
)
from tee_gateway.pricing import compute_session_cost

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -116,6 +120,8 @@ def create_completion(body):

except Exception as e:
logger.error(f"Completion error: {str(e)}", exc_info=True)
if is_upstream_model_not_served(e):
return upstream_model_not_served_payload(body.model, e), 404
return {
"error": str(e) or "Request processing failed",
"exception_type": type(e).__name__,
Expand Down
15 changes: 11 additions & 4 deletions tee_gateway/image_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
from tee_gateway.models.create_chat_completion_response import (
CreateChatCompletionResponse,
)
from tee_gateway.model_errors import (
is_upstream_model_not_served,
upstream_model_not_served_payload,
)
from tee_gateway.model_registry import get_model_config
from tee_gateway.pricing import compute_session_cost
from tee_gateway.tee_manager import get_tee_keys, compute_tee_msg_hash
Expand Down Expand Up @@ -498,10 +502,13 @@ def generate():
# Surface the real exception detail to the client (matching the chat
# streaming path) so browser-side logs show the actual cause instead
# of an opaque generic string.
error_payload = {
"error": str(e) or "Stream processing failed",
"exception_type": type(e).__name__,
}
if is_upstream_model_not_served(e):
error_payload = upstream_model_not_served_payload(chat_request.model, e)
else:
error_payload = {
"error": str(e) or "Stream processing failed",
"exception_type": type(e).__name__,
}
yield f"data: {json.dumps(error_payload)}\n\n"

return Response(
Expand Down
81 changes: 81 additions & 0 deletions tee_gateway/model_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Helpers for turning provider model failures into client-facing errors."""

from __future__ import annotations

from typing import Any

from tee_gateway.model_registry import get_model_config


def exception_status_code(exc: Exception) -> int | None:
"""Extract an HTTP/gRPC-ish status code from common provider SDK exceptions."""
status = getattr(exc, "status_code", None)
if isinstance(status, int):
return status

response = getattr(exc, "response", None)
response_status = getattr(response, "status_code", None)
if isinstance(response_status, int):
return response_status

code_attr = getattr(exc, "code", None)
code_value: Any
if callable(code_attr):
try:
code_value = code_attr()
except Exception:
code_value = None
else:
code_value = code_attr

if isinstance(code_value, int):
return code_value
if getattr(code_value, "name", None) == "NOT_FOUND":
return 404

return None


def is_upstream_model_not_served(exc: Exception) -> bool:
"""Return True when a provider says a registered model is not available."""
if exception_status_code(exc) == 404:
return True

exc_type = type(exc).__name__.lower()
if "notfound" in exc_type or "not_found" in exc_type:
return True

message = str(exc).lower()
model_missing_phrases = (
"model not found",
"model is not found",
"model does not exist",
"model_not_found",
"not found for model",
"not served",
"is not supported for",
)
return any(phrase in message for phrase in model_missing_phrases)


def upstream_model_not_served_payload(model: str, exc: Exception) -> dict[str, Any]:
"""Build an OpenAI-style-ish payload for a provider model availability miss."""
payload: dict[str, Any] = {
"error": (
f"Upstream provider does not currently serve model '{model}'. "
"The model may have been removed, renamed, or disabled upstream."
),
"code": "model_not_served_upstream",
"model": model,
"exception_type": type(exc).__name__,
"upstream_error": str(exc) or type(exc).__name__,
}

try:
cfg = get_model_config(model)
except Exception:
return payload

payload["provider"] = cfg.provider
payload["api_name"] = cfg.api_name
return payload
97 changes: 7 additions & 90 deletions tee_gateway/model_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,30 +287,6 @@ class SupportedModel(Enum):
)

# ── Google Gemini ───────────────────────────────────────────────────
# Note: gemini-2.5-flash, gemini-2.5-pro, and gemini-2.5-flash-lite are scheduled
# for deprecation on June 17, 2026 (flash-lite: July 22, 2026). Use the Gemini 3
# replacements below for new integrations.
GEMINI_2_5_FLASH = ModelConfig(
provider="google",
api_name="gemini-2.5-flash",
input_price_usd=Decimal("0.0000003"),
output_price_usd=Decimal("0.0000025"),
thinking_budget=0,
)
GEMINI_2_5_PRO = ModelConfig(
provider="google",
api_name="gemini-2.5-pro",
input_price_usd=Decimal("0.00000125"),
output_price_usd=Decimal("0.00001"),
thinking_budget=128,
)
GEMINI_2_5_FLASH_LITE = ModelConfig(
provider="google",
api_name="gemini-2.5-flash-lite",
input_price_usd=Decimal("0.0000001"),
output_price_usd=Decimal("0.0000004"),
thinking_budget=0,
)
GEMINI_3_FLASH_PREVIEW = ModelConfig(
provider="google",
api_name="gemini-3-flash-preview",
Expand All @@ -324,9 +300,9 @@ class SupportedModel(Enum):
output_price_usd=Decimal("0.000012"),
thinking_budget=128,
)
GEMINI_3_1_FLASH_LITE_PREVIEW = ModelConfig(
GEMINI_3_1_FLASH_LITE = ModelConfig(
provider="google",
api_name="gemini-3.1-flash-lite-preview",
api_name="gemini-3.1-flash-lite",
input_price_usd=Decimal("0.00000025"),
output_price_usd=Decimal("0.0000015"),
thinking_budget=0,
Expand Down Expand Up @@ -374,53 +350,23 @@ class SupportedModel(Enum):
input_price_usd=Decimal("0.00000125"),
output_price_usd=Decimal("0.0000025"),
)
GROK_4 = ModelConfig(
provider="x-ai",
api_name="grok-4",
input_price_usd=Decimal("0.000003"),
output_price_usd=Decimal("0.000015"),
)
GROK_4_FAST = ModelConfig(
provider="x-ai",
api_name="grok-4-fast",
input_price_usd=Decimal("0.0000002"),
output_price_usd=Decimal("0.0000005"),
)
GROK_4_1_FAST = ModelConfig(
provider="x-ai",
api_name="grok-4-1-fast",
input_price_usd=Decimal("0.0000002"),
output_price_usd=Decimal("0.0000005"),
)
GROK_4_1_FAST_NON_REASONING = ModelConfig(
provider="x-ai",
api_name="grok-4-1-fast-non-reasoning",
input_price_usd=Decimal("0.0000002"),
output_price_usd=Decimal("0.0000005"),
)
GROK_4_20_REASONING = ModelConfig(
provider="x-ai",
api_name="grok-4.20-reasoning",
api_name="grok-4.20-0309-reasoning",
input_price_usd=Decimal("0.000002"),
output_price_usd=Decimal("0.000006"),
)
GROK_4_20_NON_REASONING = ModelConfig(
provider="x-ai",
api_name="grok-4.20-non-reasoning",
api_name="grok-4.20-0309-non-reasoning",
input_price_usd=Decimal("0.000002"),
output_price_usd=Decimal("0.000006"),
)
GROK_CODE_FAST_1 = ModelConfig(
provider="x-ai",
api_name="grok-code-fast-1",
input_price_usd=Decimal("0.0000002"),
output_price_usd=Decimal("0.0000015"),
)
# Image generation via xAI's OpenAI-compatible /images/generations endpoint
# (Aurora). Billed at a flat $0.07 per generated image; token prices unused.
GROK_2_IMAGE = ModelConfig(
provider="x-ai",
api_name="grok-2-image-1212",
api_name="grok-imagine-image",
input_price_usd=Decimal("0"),
output_price_usd=Decimal("0"),
image_generation=True,
Expand Down Expand Up @@ -554,20 +500,6 @@ class SupportedModel(Enum):
image_extra_params={"size": "1280x1280"},
)

# ── Legacy models (not in current SDK — retained for older SDK versions) ──
GROK_3_MINI = ModelConfig(
provider="x-ai",
api_name="grok-3-mini",
input_price_usd=Decimal("0.0000003"),
output_price_usd=Decimal("0.0000005"),
)
GROK_3 = ModelConfig(
provider="x-ai",
api_name="grok-3-latest",
input_price_usd=Decimal("0.000003"),
output_price_usd=Decimal("0.000015"),
)


# Canonical lookup: user-facing model name → SupportedModel
# The "user-facing name" is what callers pass in the `model` field of requests.
Expand Down Expand Up @@ -605,30 +537,20 @@ class SupportedModel(Enum):
"claude-opus-4-8": SupportedModel.CLAUDE_OPUS_4_8,
"claude-fable-5": SupportedModel.CLAUDE_FABLE_5,
# Google
"gemini-2.5-flash": SupportedModel.GEMINI_2_5_FLASH,
"gemini-2.5-pro": SupportedModel.GEMINI_2_5_PRO,
"gemini-2.5-flash-lite": SupportedModel.GEMINI_2_5_FLASH_LITE,
"gemini-3-flash-preview": SupportedModel.GEMINI_3_FLASH_PREVIEW,
"gemini-3.1-pro-preview": SupportedModel.GEMINI_3_1_PRO_PREVIEW,
"gemini-3.1-flash-lite-preview": SupportedModel.GEMINI_3_1_FLASH_LITE_PREVIEW,
"gemini-3.1-flash-lite": SupportedModel.GEMINI_3_1_FLASH_LITE,
"gemini-2.5-flash-image": SupportedModel.GEMINI_2_5_FLASH_IMAGE,
"gemini-3.1-flash-image": SupportedModel.GEMINI_3_1_FLASH_IMAGE,
"gemini-3.5-flash": SupportedModel.GEMINI_3_5_FLASH,
# xAI
"grok-4.5": SupportedModel.GROK_4_5,
"grok-4.5-latest": SupportedModel.GROK_4_5,
"grok-4.3": SupportedModel.GROK_4_3,
"grok-4": SupportedModel.GROK_4,
"grok-4-fast": SupportedModel.GROK_4_FAST,
"grok-4-1-fast": SupportedModel.GROK_4_1_FAST,
"grok-4.1-fast": SupportedModel.GROK_4_1_FAST,
"grok-4-1-fast-non-reasoning": SupportedModel.GROK_4_1_FAST_NON_REASONING,
"grok-4.20-reasoning": SupportedModel.GROK_4_20_REASONING,
"grok-4.20-non-reasoning": SupportedModel.GROK_4_20_NON_REASONING,
"grok-code-fast-1": SupportedModel.GROK_CODE_FAST_1,
"grok-imagine-image": SupportedModel.GROK_2_IMAGE,
"grok-2-image": SupportedModel.GROK_2_IMAGE,
"grok-2-image-1212": SupportedModel.GROK_2_IMAGE,
"grok-2-image-latest": SupportedModel.GROK_2_IMAGE,
# ByteDance
"seed-1-6-250615": SupportedModel.SEED_1_6,
"seed-1.6": SupportedModel.SEED_1_6,
Expand Down Expand Up @@ -656,11 +578,6 @@ class SupportedModel(Enum):
# Z.ai
"glm-5.2": SupportedModel.GLM_5_2,
"glm-image": SupportedModel.GLM_IMAGE,
# Legacy — not in current SDK, retained for older SDK versions
"grok-3-mini-beta": SupportedModel.GROK_3_MINI, # old beta alias
"grok-3-mini": SupportedModel.GROK_3_MINI,
"grok-3-beta": SupportedModel.GROK_3, # old beta alias
"grok-3": SupportedModel.GROK_3,
}

# Build the rate card automatically from the enum (for backward compat with util.py)
Expand Down
14 changes: 7 additions & 7 deletions tee_gateway/test/test_tee_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,8 +445,8 @@ def test_all_four_providers_reachable(self):
providers = {
get_model_config("gpt-4.1").provider,
get_model_config("claude-sonnet-4-5").provider,
get_model_config("gemini-2.5-flash").provider,
get_model_config("grok-4").provider,
get_model_config("gemini-3.5-flash").provider,
get_model_config("grok-4.3").provider,
}
self.assertEqual(providers, {"openai", "anthropic", "google", "x-ai"})

Expand All @@ -460,11 +460,11 @@ def test_anthropic_model_lookup(self):
self.assertEqual(cfg.provider, "anthropic")

def test_google_model_lookup(self):
cfg = get_model_config("gemini-2.5-flash")
cfg = get_model_config("gemini-3.5-flash")
self.assertEqual(cfg.provider, "google")

def test_xai_model_lookup(self):
cfg = get_model_config("grok-4")
cfg = get_model_config("grok-4.3")
self.assertEqual(cfg.provider, "x-ai")

def test_unknown_model_raises_value_error(self):
Expand Down Expand Up @@ -493,7 +493,7 @@ def test_model_alias_and_dated_name_resolve_identically(self):
def test_models_with_force_temperature_have_positive_value(self):
"""Any model that forces a temperature must set it to a positive float.
We don't pin the exact value — just confirm the field is plausible if set."""
cfg = get_model_config("o4-mini")
cfg = get_model_config("o3")
if cfg.force_temperature is not None:
self.assertGreater(cfg.force_temperature, 0)

Expand Down Expand Up @@ -810,7 +810,7 @@ def test_plain_text_request_passes(self):
def test_image_blocked_when_model_lacks_support(self):
with mock.patch(self.CAPS, return_value={"image_inputs": False}):
with self.assertRaises(AttachmentValidationError):
validate_attachments(self._image_msg("aGVsbG8="), "grok-4")
validate_attachments(self._image_msg("aGVsbG8="), "grok-4.3")

def test_image_allowed_when_model_supports(self):
with mock.patch(self.CAPS, return_value={"image_inputs": True}):
Expand All @@ -826,7 +826,7 @@ def test_pdf_blocked_when_model_lacks_support(self):
self.CAPS, return_value={"image_inputs": True, "pdf_inputs": False}
):
with self.assertRaises(AttachmentValidationError):
validate_attachments(self._pdf_msg("JVBERi0="), "grok-4")
validate_attachments(self._pdf_msg("JVBERi0="), "grok-4.3")


# ---------------------------------------------------------------------------
Expand Down
Loading
Loading