diff --git a/CHANGELOG.md b/CHANGELOG.md index 37847d4..2d80151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 485a748..5e169dd 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ OpenAI-compatible image generation endpoint. - `model: "auto"` selects the best loaded provider with `capabilities.image_generation: true` - `model: ""` 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 \ @@ -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: ""` 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 \ diff --git a/foundrygate/main.py b/foundrygate/main.py index 07157a4..4cc963d 100644 --- a/foundrygate/main.py +++ b/foundrygate/main.py @@ -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 x") + 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( @@ -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( @@ -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: diff --git a/tests/test_route_introspection.py b/tests/test_route_introspection.py index 9f96dd2..9789358 100644 --- a/tests/test_route_introspection.py +++ b/tests/test_route_introspection.py @@ -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, @@ -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 x"): + _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