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
5 changes: 4 additions & 1 deletion app/agents/language/easy_korean.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,10 @@ def generate_fn(is_correction: bool, payload: dict[str, object]) -> EasyKoreanDr
WarningItem(
component="easy_korean",
code=WarningCode.EASY_KOREAN_GENERATION_FAILED,
message="Easy Korean generation failed",
message=(
"Easy Korean generation failed: "
f"{correction_result.generation_error_code or 'GENERATION_FAILED'}"
),
),
WarningItem(
component="easy_korean",
Expand Down
2 changes: 2 additions & 0 deletions app/agents/language/generation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .openai_compatible import (
GenerationError,
GenerationHTTPError,
GenerationRefusalError,
GenerationResponseTooLargeError,
GenerationSchemaError,
GenerationTransportError,
Expand All @@ -20,6 +21,7 @@
"EasyKoreanDraft",
"GenerationError",
"GenerationHTTPError",
"GenerationRefusalError",
"GenerationResponseTooLargeError",
"GenerationSchemaError",
"GenerationTransportError",
Expand Down
12 changes: 3 additions & 9 deletions app/agents/language/generation/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,14 @@ def generate(
response = client.post(url, headers=headers, json=request_body)

if len(response.content) > 1_048_576:
raise GenerationResponseTooLargeError(
"Response content exceeds 1 MiB limit"
)
raise GenerationResponseTooLargeError("Response content exceeds 1 MiB limit")

if response.status_code == 200:
try:
response_json = response.json()
content = response_json.get("message", {}).get("content")
if not isinstance(content, str):
raise GenerationSchemaError(
"Content is missing or not a string"
)
raise GenerationSchemaError("Content is missing or not a string")
except (json.JSONDecodeError, AttributeError) as err:
raise GenerationSchemaError(
f"Invalid Ollama response wrapper: {err}"
Expand All @@ -124,9 +120,7 @@ def generate(
continue
raise last_error

raise GenerationHTTPError(
f"HTTP generation request failed with status {response.status_code}"
)
raise GenerationHTTPError(status_code=response.status_code)
except (httpx.TimeoutException, httpx.NetworkError, httpx.TransportError) as err:
last_error = GenerationTransportError(
f"Network transport error: {type(err).__name__}"
Expand Down
170 changes: 158 additions & 12 deletions app/agents/language/generation/openai_compatible.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import json
import logging
import re
from collections.abc import Mapping
from typing import TypeVar

Expand All @@ -11,6 +13,9 @@
from app.agents.language.resources.prompts import load_prompt

T = TypeVar("T", bound=BaseModel)
logger = logging.getLogger(__name__)

_SAFE_PROVIDER_CODE = re.compile(r"^[A-Za-z0-9_.-]{1,100}$")


def _sanitize_payload(payload: Mapping[str, object]) -> dict[str, object]:
Expand All @@ -24,34 +29,134 @@ def _sanitize_payload(payload: Mapping[str, object]) -> dict[str, object]:
result[key] = sanitize_user_input(value)
elif isinstance(value, list):
result[key] = [
sanitize_user_input(item) if isinstance(item, str) else item
for item in value
sanitize_user_input(item) if isinstance(item, str) else item for item in value
]
else:
result[key] = value
return result


def _strict_json_schema(response_model: type[BaseModel]) -> dict[str, object]:
"""Normalize Pydantic JSON Schema to the OpenAI strict-output subset."""
schema = response_model.model_json_schema(mode="validation")

def normalize(node: object) -> None:
if isinstance(node, dict):
node.pop("default", None)
properties = node.get("properties")
if isinstance(properties, dict):
node["required"] = list(properties)
for value in node.values():
normalize(value)
elif isinstance(node, list):
for value in node:
normalize(value)

normalize(schema)
return schema


class GenerationError(Exception):
"""Base exception for generation errors."""

code = "GENERATION_FAILED"

def __init__(self, message: str, *, request_id: str | None = None) -> None:
super().__init__(message)
self.request_id = request_id


class GenerationHTTPError(GenerationError):
"""Raised for non-retryable HTTP error statuses (e.g. 400, 401, 403, 404)."""

def __init__(
self,
*,
status_code: int,
provider_error_code: str | None = None,
request_id: str | None = None,
) -> None:
if status_code == 400:
self.code = "PROVIDER_REQUEST_INVALID"
elif status_code in (401, 403):
self.code = "PROVIDER_AUTH_FAILED"
else:
self.code = "PROVIDER_HTTP_ERROR"
self.status_code = status_code
self.provider_error_code = provider_error_code
provider_suffix = f" provider_code={provider_error_code}" if provider_error_code else ""
super().__init__(
f"HTTP generation request failed with status {status_code}{provider_suffix}",
request_id=request_id,
)


class GenerationTransportError(GenerationError):
"""Raised for retryable transport errors (429, 5xx, timeouts)."""

code = "PROVIDER_UNAVAILABLE"


class GenerationSchemaError(GenerationError):
"""Raised when LLM output violates JSON schema or contract."""

code = "STRUCTURED_OUTPUT_INVALID"


class GenerationRefusalError(GenerationError):
"""Raised when the provider explicitly refuses a structured-output request."""

code = "PROVIDER_REFUSED"


class GenerationResponseTooLargeError(GenerationError):
"""Raised when response body exceeds 1 MiB size cap."""

code = "PROVIDER_RESPONSE_TOO_LARGE"


def _request_id(response: httpx.Response) -> str | None:
value = response.headers.get("x-request-id")
if value and _SAFE_PROVIDER_CODE.fullmatch(value):
return value
return None


def _provider_error_code(response: httpx.Response) -> str | None:
"""Extract only a bounded provider error identifier, never the raw error body."""
try:
payload = response.json()
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(payload, dict):
return None
error = payload.get("error")
if not isinstance(error, dict):
return None
for key in ("code", "type"):
value = error.get(key)
if isinstance(value, str) and _SAFE_PROVIDER_CODE.fullmatch(value):
return value
return None


def _log_generation_failure(
*,
operation: GenerationOperation,
model: str,
error: GenerationError,
) -> None:
"""Emit metadata only; prompts, response bodies and credentials stay excluded."""
logger.warning(
"structured_generation_failed operation=%s model=%s error_code=%s "
"error_type=%s provider_request_id=%s",
operation,
model,
error.code,
type(error).__name__,
error.request_id or "unavailable",
)


class OpenAICompatibleGenerationPort(StructuredGenerationPort):
def __init__(
Expand Down Expand Up @@ -88,14 +193,13 @@ def generate(
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"

json_schema = response_model.model_json_schema(mode="validation")
json_schema = _strict_json_schema(response_model)
request_body = {
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(safe_payload, ensure_ascii=False)},
],
"temperature": 0,
"response_format": {
"type": "json_schema",
"json_schema": {
Expand All @@ -122,44 +226,85 @@ def generate(
raise GenerationResponseTooLargeError("Response content exceeds 1 MiB limit")

if response.status_code == 200:
request_id = _request_id(response)
try:
resp_json = response.json()
choices = resp_json.get("choices", [])
if not choices or not isinstance(choices, list):
raise GenerationSchemaError("Missing or invalid choices in response")
content = choices[0].get("message", {}).get("content")
raise GenerationSchemaError(
"Missing or invalid choices in response",
request_id=request_id,
)
message = choices[0].get("message", {})
if not isinstance(message, dict):
raise GenerationSchemaError(
"Message is missing or not an object",
request_id=request_id,
)
refusal = message.get("refusal")
if isinstance(refusal, str) and refusal.strip():
raise GenerationRefusalError(
"Provider refused the structured-output request",
request_id=request_id,
)
content = message.get("content")
if not isinstance(content, str):
raise GenerationSchemaError("Content is missing or not a string")
raise GenerationSchemaError(
"Content is missing or not a string",
request_id=request_id,
)
except (json.JSONDecodeError, AttributeError) as err:
raise GenerationSchemaError(
f"Invalid completion JSON wrapper: {err}"
f"Invalid completion JSON wrapper: {type(err).__name__}",
request_id=request_id,
) from err

try:
return response_model.model_validate_json(content)
except Exception as err:
raise GenerationSchemaError(
f"Model validation error for {response_model.__name__}: {err}"
f"Model validation error for {response_model.__name__}: "
f"{type(err).__name__}",
request_id=request_id,
) from err

if response.status_code in (429, 500, 502, 503, 504):
last_error = GenerationTransportError(
f"HTTP transport status {response.status_code}"
f"HTTP transport status {response.status_code}",
request_id=_request_id(response),
)
if attempt < max_attempts:
continue
_log_generation_failure(
operation=operation,
model=self.model,
error=last_error,
)
raise last_error

raise GenerationHTTPError(
f"HTTP generation request failed with status {response.status_code}"
http_error = GenerationHTTPError(
status_code=response.status_code,
provider_error_code=_provider_error_code(response),
request_id=_request_id(response),
)
_log_generation_failure(
operation=operation,
model=self.model,
error=http_error,
)
raise http_error

except (httpx.TimeoutException, httpx.NetworkError, httpx.TransportError) as err:
last_error = GenerationTransportError(
f"Network transport error: {type(err).__name__}"
)
if attempt < max_attempts:
continue
_log_generation_failure(
operation=operation,
model=self.model,
error=last_error,
)
raise last_error from None

if last_error:
Expand All @@ -170,6 +315,7 @@ def generate(
__all__ = [
"GenerationError",
"GenerationHTTPError",
"GenerationRefusalError",
"GenerationResponseTooLargeError",
"GenerationSchemaError",
"GenerationTransportError",
Expand Down
5 changes: 4 additions & 1 deletion app/agents/language/translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,10 @@ def generate_fn(is_correction: bool, payload: dict[str, object]) -> TranslationD
WarningItem(
component="translation",
code=WarningCode.TRANSLATION_GENERATION_FAILED,
message="Translation generation failed completely",
message=(
"Translation generation failed: "
f"{correction_result.generation_error_code or 'GENERATION_FAILED'}"
),
)
)
translation_result = TranslationResult(
Expand Down
Loading
Loading