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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel
- Added `POST /api/route/image` for dry-run preview of image-generation and image-editing routing decisions
- Added optional `image` provider metadata (`max_outputs`, `max_side_px`, `supported_sizes`) so image-capable providers can be ranked against `n` and `size`
- Added top-level capability coverage to `GET /health` plus `GET /api/providers` for filtered provider inventory and dashboard coverage views
- Added shared request validation for image-generation, image-editing, and image-route preview payloads so invalid `size`, `n`, and scalar fields fail fast before provider calls

## v0.5.0 - 2026-03-12

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ OpenAI-compatible image generation endpoint.

- `model: "auto"` selects the best loaded provider with `capabilities.image_generation: true`
- `model: "<provider-id>"` routes directly to a loaded image-capable provider
- validates `prompt`, `n`, and `size` before any provider call

```bash
curl -fsS http://127.0.0.1:8090/v1/images/generations \
Expand All @@ -238,6 +239,7 @@ OpenAI-compatible image editing endpoint.
- currently supports one required `image` upload plus an optional `mask`
- `model: "auto"` selects the best loaded provider with `capabilities.image_editing: true`
- `model: "<provider-id>"` routes directly to a loaded image-edit-capable provider
- validates scalar fields such as `prompt`, `n`, and `size` before any provider call

```bash
curl -fsS http://127.0.0.1:8090/v1/images/edits \
Expand Down
93 changes: 76 additions & 17 deletions foundrygate/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,33 +389,89 @@ def _parse_optional_positive_int(value: Any, *, field_name: str) -> int | None:
return parsed


def _extract_image_edit_request_fields(form_data: dict[str, Any]) -> dict[str, Any]:
"""Return the validated scalar fields for one image-edit request."""
prompt = form_data.get("prompt")
def _parse_image_size_max_side(value: str) -> int:
"""Return the larger dimension from a WxH image size string."""
parts = value.lower().split("x", 1)
if len(parts) != 2:
return 0
try:
width = int(parts[0])
height = int(parts[1])
except ValueError:
return 0
return max(width, height)


def _normalize_image_size(value: Any, *, field_name: str = "size") -> str | None:
"""Return one normalized WxH image size string."""
if value in (None, ""):
return None
if not isinstance(value, str) or not value.strip():
raise ValueError(f"Field '{field_name}' must be a non-empty string")
cleaned = value.strip().lower()
max_side = _parse_image_size_max_side(cleaned)
if max_side <= 0:
raise ValueError(f"Field '{field_name}' must use the form <width>x<height>")
return cleaned


def _normalize_image_request_body(body: dict[str, Any], *, capability: str) -> dict[str, Any]:
"""Validate and normalize one JSON image request body."""
if not isinstance(body, dict):
raise ValueError("Image request body must be a JSON object")

prompt = body.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
raise ValueError("Image editing requires a non-empty 'prompt' field")
raise ValueError("Image request requires a non-empty 'prompt' string")

model = form_data.get("model")
model = body.get("model")
if model is None:
model = "auto"
elif not isinstance(model, str) or not model.strip():
raise ValueError("Field 'model' must be a non-empty string when provided")

payload: dict[str, Any] = {
normalized: dict[str, Any] = {
"prompt": prompt.strip(),
"model": model.strip() if isinstance(model, str) else "auto",
"model": model.strip(),
}

n = _parse_optional_positive_int(form_data.get("n"), field_name="n")
n = _parse_optional_positive_int(body.get("n"), field_name="n")
if n is not None:
payload["n"] = n
normalized["n"] = n

for key in ("size", "response_format", "user"):
value = form_data.get(key)
if isinstance(value, str) and value.strip():
payload[key] = value.strip()
size = _normalize_image_size(body.get("size"))
if size is not None:
normalized["size"] = size

for key in ("response_format", "user"):
value = body.get(key)
if value in (None, ""):
continue
if not isinstance(value, str) or not value.strip():
raise ValueError(f"Field '{key}' must be a non-empty string when provided")
normalized[key] = value.strip()

if capability == "image_generation":
for key in ("quality", "style", "background"):
value = body.get(key)
if value in (None, ""):
continue
if not isinstance(value, str) or not value.strip():
raise ValueError(f"Field '{key}' must be a non-empty string when provided")
normalized[key] = value.strip()

metadata = body.get("metadata")
if metadata is not None:
if not isinstance(metadata, dict):
raise ValueError("Field 'metadata' must be an object when provided")
normalized["metadata"] = metadata

return payload
return normalized


def _extract_image_edit_request_fields(form_data: dict[str, Any]) -> dict[str, Any]:
"""Return the validated scalar fields for one image-edit request."""
return _normalize_image_request_body(form_data, capability="image_editing")


async def _read_uploaded_file(
Expand Down Expand Up @@ -446,9 +502,8 @@ async def _resolve_image_route_preview(
) -> tuple[RoutingDecision, str, str, list[str], str, AppliedHooks, dict[str, Any]]:
"""Resolve one image-generation request without calling a provider."""
body, hook_state = await _apply_request_hooks(body, headers)
prompt = body.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
raise ValueError("Image request requires a non-empty 'prompt' string")
body = _normalize_image_request_body(body, capability=capability)
prompt = body["prompt"]

model_requested = str(body.get("model", "auto"))
client_profile, profile_hints = _resolve_client_profile(
Expand Down Expand Up @@ -828,6 +883,10 @@ async def image_generations(request: Request):
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
try:
body = _normalize_image_request_body(body, capability="image_generation")
except ValueError as exc:
return _invalid_request_response("Invalid image generation request", exc=exc)

headers = _collect_routing_headers(request)
try:
Expand Down
46 changes: 46 additions & 0 deletions tests/test_route_introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ async def aclose(self):
from foundrygate.config import load_config
from foundrygate.main import (
_extract_image_edit_request_fields,
_normalize_image_request_body,
_refresh_local_worker_probes,
_resolve_image_route_preview,
_resolve_route_preview,
Expand Down Expand Up @@ -436,6 +437,51 @@ def test_extract_image_edit_request_fields_parses_scalars(self):
assert payload["response_format"] == "b64_json"
assert payload["user"] == "tester"

def test_normalize_image_request_body_validates_size_and_n(self):
payload = _normalize_image_request_body(
{
"model": "auto",
"prompt": " Render a scene ",
"n": 2,
"size": " 2048x2048 ",
"quality": " high ",
},
capability="image_generation",
)

assert payload["prompt"] == "Render a scene"
assert payload["model"] == "auto"
assert payload["n"] == 2
assert payload["size"] == "2048x2048"
assert payload["quality"] == "high"

def test_normalize_image_request_body_rejects_invalid_size(self):
with pytest.raises(ValueError, match="must use the form <width>x<height>"):
_normalize_image_request_body(
{
"model": "auto",
"prompt": "Render a scene",
"size": "wide",
},
capability="image_generation",
)

@pytest.mark.asyncio
async def test_image_route_preview_rejects_invalid_size(self, preview_config):
response = await preview_image_route(
_json_request(
"/api/route/image",
{
"model": "auto",
"capability": "image_generation",
"prompt": "Draw a gateway diagram.",
"size": "invalid",
},
)
)

assert response.status_code == 400


class TestLocalWorkerProbeRefresh:
@pytest.mark.asyncio
Expand Down
Loading