Skip to content
Merged
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
62 changes: 60 additions & 2 deletions tee_gateway/image_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,64 @@
# Shared keyless client for fetching provider-hosted image URLs into the enclave.
_image_fetch_client: Optional[httpx.Client] = None

# Cap on how much of a provider error body is copied into an exception message
# (and from there into logs and the client-facing error payload).
_MAX_ERROR_DETAIL_CHARS = 600


def _provider_error_detail(resp: httpx.Response) -> str:
"""Extract a compact human-readable detail from a provider error response.

Providers put the actual rejection reason (invalid parameter, prompt too
long, content-policy refusal, …) in the response body, typically as
``{"error": {"code": ..., "message": ...}}``. Prefer that; fall back to the
raw body. Always truncated so a pathological body can't flood logs or the
client error payload.
"""
try:
resp.read() # no-op when already loaded; loads the body of a streamed response
body = resp.text.strip()
except Exception:
return "(unreadable response body)"
if not body:
return "(empty response body)"
try:
parsed = json.loads(body)
except ValueError:
parsed = None
if isinstance(parsed, dict):
err = parsed.get("error")
if isinstance(err, dict):
parts = [str(v) for v in (err.get("code"), err.get("message")) if v]
if parts:
return ": ".join(parts)[:_MAX_ERROR_DETAIL_CHARS]
elif isinstance(err, str) and err:
return err[:_MAX_ERROR_DETAIL_CHARS]
return body[:_MAX_ERROR_DETAIL_CHARS]


def _raise_for_status_with_detail(resp: httpx.Response) -> None:
"""``raise_for_status``, but with the provider's error body in the message.

``httpx.HTTPStatusError``'s default message is just the status code and URL
("Client error '400 Bad Request' for url …"), which hides the provider's
stated reason and makes failures like an ARK content-filter rejection
undiagnosable from logs or the client error payload. Re-raise with the body
detail appended so the existing ``str(e)`` error plumbing surfaces it.
"""
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
detail = _provider_error_detail(e.response)
# Keep only the first line of httpx's message ("Client error '400 Bad
# Request' for url '…'"), dropping the MDN-link footer.
base = str(e).splitlines()[0] if str(e) else f"HTTP {resp.status_code}"
raise httpx.HTTPStatusError(
f"{base} — provider response: {detail}",
request=e.request,
response=e.response,
) from None


def _validate_fetch_url(url: str) -> None:
"""Guard the image fetch against SSRF / egress abuse.
Expand Down Expand Up @@ -118,7 +176,7 @@ def _fetch_url_as_data_uri(url: str) -> str:
)

with _image_fetch_client.stream("GET", url) as resp:
resp.raise_for_status()
_raise_for_status_with_detail(resp)
declared = resp.headers.get("content-length")
if declared and declared.isdigit() and int(declared) > _MAX_IMAGE_BYTES:
raise ValueError(
Expand Down Expand Up @@ -304,7 +362,7 @@ def generate_images(
_IMAGE_GENERATION_PATH,
json=_build_generations_payload(cfg, prompt, count, json_refs),
)
resp.raise_for_status()
_raise_for_status_with_detail(resp)
data = resp.json().get("data", []) or []

images: list[str] = []
Expand Down
80 changes: 80 additions & 0 deletions tee_gateway/test/test_image_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,86 @@ def test_uninitialized_client_raises(self):
generate_images(GROK_IMAGE, "p", n=1)


class TestProviderErrorDetail(unittest.TestCase):
"""Provider error bodies must surface in the raised error message.

httpx's default HTTPStatusError message is just the status and URL, which
hides the provider's stated rejection reason (invalid parameter, prompt
limit, content filter, …) — the only way to diagnose a 400 like ARK's
/images/generations rejections.
"""

@staticmethod
def _response(status: int, *, content: bytes = b"", json_body=None):
import httpx

request = httpx.Request(
"POST", "https://ark.ap-southeast.bytepluses.com/api/v3/images/generations"
)
if json_body is not None:
return httpx.Response(status, json=json_body, request=request)
return httpx.Response(status, content=content, request=request)

def test_generate_images_surfaces_provider_error_body(self):
import httpx

body = {
"error": {
"code": "InvalidParameter",
"message": "The request parameter `prompt` exceeds the limit.",
}
}
client = MagicMock()
client.post.return_value = self._response(400, json_body=body)
with patch.object(llm_backend, "bytedance_http_client", client):
with self.assertRaises(httpx.HTTPStatusError) as ctx:
generate_images(SEEDREAM_5_LITE, "p", n=1)

msg = str(ctx.exception)
self.assertIn("400", msg)
self.assertIn("InvalidParameter", msg)
self.assertIn("exceeds the limit", msg)
# The MDN-link footer from httpx's default message is dropped.
self.assertNotIn("developer.mozilla.org", msg)

def test_detail_falls_back_to_raw_body_for_non_json(self):
detail = image_generation._provider_error_detail(
self._response(400, content=b"plain text failure")
)
self.assertEqual(detail, "plain text failure")

def test_detail_handles_string_error_field(self):
detail = image_generation._provider_error_detail(
self._response(400, json_body={"error": "quota exhausted"})
)
self.assertEqual(detail, "quota exhausted")

def test_detail_handles_empty_body(self):
detail = image_generation._provider_error_detail(self._response(400))
self.assertEqual(detail, "(empty response body)")

def test_detail_is_truncated(self):
detail = image_generation._provider_error_detail(
self._response(400, content=b"x" * 10_000)
)
self.assertEqual(len(detail), image_generation._MAX_ERROR_DETAIL_CHARS)

def test_url_fetch_surfaces_error_body(self):
import httpx

resp = self._response(403, content=b"Access denied by CDN policy")
ctx_mgr = MagicMock()
ctx_mgr.__enter__.return_value = resp
ctx_mgr.__exit__.return_value = False
client = MagicMock()
client.stream.return_value = ctx_mgr
with patch.object(image_generation, "_image_fetch_client", client):
with self.assertRaises(httpx.HTTPStatusError) as ctx:
image_generation._fetch_url_as_data_uri("https://cdn/x.png")

self.assertIn("Access denied by CDN policy", str(ctx.exception))


def _mock_stream_client(headers: dict, chunks: list[bytes]) -> MagicMock:
"""A fake httpx client whose .stream(...) yields a response with these bytes."""
resp = MagicMock()
Expand Down
Loading