diff --git a/CHANGELOG.md b/CHANGELOG.md index e9458eb..4bcae09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel - Added modality-aware metrics and filters so stats, traces, recent requests, and the dashboard can distinguish `chat`, `image_generation`, and `image_editing` - 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` ## v0.5.0 - 2026-03-12 diff --git a/README.md b/README.md index 27b983d..8efd544 100644 --- a/README.md +++ b/README.md @@ -579,6 +579,7 @@ What the current runtime guarantees for `image-provider`: - backend must be `openai-compat` - `capabilities.image_generation` is normalized to `true` - explicit `image_editing: true` enables `POST /v1/images/edits` +- optional `image.max_outputs`, `image.max_side_px`, and `image.supported_sizes` help the router choose the best image-capable provider for `n` and `size` - `model: "auto"` on `POST /v1/images/generations` selects only providers with image-generation capability - `model: "auto"` on `POST /v1/images/edits` selects only providers with image-editing capability @@ -594,6 +595,10 @@ providers: model: "gpt-image-1" capabilities: image_editing: true + image: + max_outputs: 4 + max_side_px: 2048 + supported_sizes: ["1024x1024", "2048x2048"] ``` ### Routing Policy Schema diff --git a/foundrygate/config.py b/foundrygate/config.py index 8d2812c..f3511a8 100644 --- a/foundrygate/config.py +++ b/foundrygate/config.py @@ -306,6 +306,50 @@ def _normalize_provider_cache(name: str, cfg: dict[str, Any]) -> dict[str, Any]: return {"mode": mode, "read_discount": read_discount} +def _normalize_provider_image(name: str, cfg: dict[str, Any]) -> dict[str, Any]: + """Validate optional provider image metadata.""" + raw = cfg.get("image") or {} + if not isinstance(raw, dict): + raise ConfigError(f"Provider '{name}' field 'image' must be a mapping") + + image: dict[str, Any] = {} + max_outputs = _normalize_positive_int( + raw.get("max_outputs"), + field_name="image.max_outputs", + provider_name=name, + ) + if max_outputs is not None: + image["max_outputs"] = max_outputs + + max_side_px = _normalize_positive_int( + raw.get("max_side_px"), + field_name="image.max_side_px", + provider_name=name, + ) + if max_side_px is not None: + image["max_side_px"] = max_side_px + + supported_sizes = raw.get("supported_sizes", []) + if supported_sizes in (None, ""): + supported_sizes = [] + if isinstance(supported_sizes, str): + supported_sizes = [supported_sizes] + if not isinstance(supported_sizes, list): + raise ConfigError(f"Provider '{name}' field 'image.supported_sizes' must be a list") + + normalized_sizes = [] + for value in supported_sizes: + if not isinstance(value, str) or not value.strip(): + raise ConfigError( + f"Provider '{name}' field 'image.supported_sizes' must contain non-empty strings" + ) + normalized_sizes.append(value.strip()) + if normalized_sizes: + image["supported_sizes"] = normalized_sizes + + return image + + def _normalize_provider(name: str, cfg: Any) -> dict[str, Any]: """Validate a provider definition and attach normalized capability metadata.""" if not isinstance(cfg, dict): @@ -392,6 +436,7 @@ def _normalize_provider(name: str, cfg: Any) -> dict[str, Any]: normalized["limits"]["max_output_tokens"] = max_tokens normalized["cache"] = _normalize_provider_cache(name, normalized) + normalized["image"] = _normalize_provider_image(name, normalized) normalized["capabilities"] = _normalize_provider_capabilities(name, normalized) return normalized diff --git a/foundrygate/main.py b/foundrygate/main.py index 9464a0f..4ecb6a2 100644 --- a/foundrygate/main.py +++ b/foundrygate/main.py @@ -416,6 +416,8 @@ async def _resolve_image_route_preview( decision = _router.route_capability_request( capability=capability, request_text=prompt, + requested_outputs=body.get("n") if isinstance(body.get("n"), int) else 1, + requested_size=str(body.get("size") or ""), model_requested=model_requested, client_profile=client_profile, profile_hints=profile_hints, diff --git a/foundrygate/router.py b/foundrygate/router.py index 6763e45..10ee657 100644 --- a/foundrygate/router.py +++ b/foundrygate/router.py @@ -58,6 +58,21 @@ def _score_capacity_ratio(ratio: float, *, strong: float = 2.0, ideal: float = 4 return 10 +def _score_image_fit_ratio(ratio: float) -> int: + """Return a score for image limits that prefers a close fit over excess headroom.""" + if ratio <= 0 or ratio > 1: + return 0 + if ratio >= 0.9: + return 10 + if ratio >= 0.7: + return 8 + if ratio >= 0.5: + return 6 + if ratio >= 0.25: + return 4 + return 2 + + def _merge_select_constraints(*selects: dict[str, Any]) -> dict[str, Any]: """Merge policy-like select mappings without dropping list/dict constraints.""" merged: dict[str, Any] = { @@ -125,6 +140,19 @@ def _extract_text(messages: list[dict]) -> tuple[str, str, str]: return system, last_user, "\n".join(full) +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) + + class Router: """Layered routing engine.""" @@ -166,6 +194,10 @@ async def route( stable_prefix_tokens=stable_prefix_tokens, requested_output_tokens=requested_output_tokens, total_requested_tokens=total_requested_tokens, + requested_image_outputs=1, + requested_image_side_px=0, + requested_image_size="", + required_capability="", cache_preference=(headers or {}).get("x-foundrygate-cache", "").strip().lower(), model_requested=model_requested.lower().strip(), has_tools=has_tools, @@ -232,6 +264,8 @@ def route_capability_request( *, capability: str, request_text: str = "", + requested_outputs: int | None = None, + requested_size: str = "", model_requested: str = "", client_profile: str = "generic", profile_hints: dict[str, Any] | None = None, @@ -252,6 +286,10 @@ def route_capability_request( stable_prefix_tokens=0, requested_output_tokens=0, total_requested_tokens=total_tokens, + requested_image_outputs=requested_outputs or 1, + requested_image_side_px=_parse_image_size_max_side(requested_size), + requested_image_size=requested_size.strip().lower() if requested_size else "", + required_capability=capability, cache_preference=(headers or {}).get("x-foundrygate-cache", "").strip().lower(), model_requested=model_requested.lower().strip(), has_tools=False, @@ -517,6 +555,25 @@ def _provider_fits_request_dimensions( return False if context_window and ctx.total_requested_tokens > context_window: return False + if ctx.required_capability in {"image_generation", "image_editing"}: + image_cfg = provider.get("image", {}) + max_outputs = int(image_cfg.get("max_outputs") or 0) + max_side_px = int(image_cfg.get("max_side_px") or 0) + supported_sizes = image_cfg.get("supported_sizes", []) + if max_outputs and ctx.requested_image_outputs > max_outputs: + return False + if ( + max_side_px + and ctx.requested_image_side_px + and ctx.requested_image_side_px > max_side_px + ): + return False + if ( + supported_sizes + and ctx.requested_image_size + and ctx.requested_image_size not in supported_sizes + ): + return False return True def _provider_dimension_details( @@ -526,6 +583,7 @@ def _provider_dimension_details( provider = self.config.provider(name) or {} limits = provider.get("limits", {}) cache = provider.get("cache", {}) + image_cfg = provider.get("image", {}) capabilities = provider.get("capabilities", {}) health = ctx.provider_health.get(name, {}) if ctx else {} @@ -603,6 +661,39 @@ def _provider_dimension_details( 2 if capabilities.get("local") else 1 if capabilities.get("cloud") else 0 ) + image_score = 0 + image_outputs_fit = True + image_size_fit = True + image_supported_size = True + if ctx.required_capability in {"image_generation", "image_editing"}: + max_outputs = int(image_cfg.get("max_outputs") or 0) + max_side_px = int(image_cfg.get("max_side_px") or 0) + supported_sizes = image_cfg.get("supported_sizes", []) + requested_outputs = max(ctx.requested_image_outputs, 1) + requested_side = ctx.requested_image_side_px + + if max_outputs: + image_outputs_fit = requested_outputs <= max_outputs + ratio = requested_outputs / max_outputs + image_score += _score_image_fit_ratio(ratio) if image_outputs_fit else 0 + else: + image_score += 2 + + if max_side_px and requested_side: + image_size_fit = requested_side <= max_side_px + ratio = requested_side / max_side_px + image_score += _score_image_fit_ratio(ratio) if image_size_fit else 0 + elif requested_side: + image_score += 2 + + if supported_sizes: + image_supported_size = ( + not ctx.requested_image_size or ctx.requested_image_size in supported_sizes + ) + image_score += 6 if image_supported_size else 0 + elif ctx.requested_image_size: + image_score += 1 + fit = self._provider_fits_request_dimensions(name, provider, ctx) score_total = ( health_score @@ -613,6 +704,7 @@ def _provider_dimension_details( + context_score + input_score + output_score + + image_score ) return { "fit": fit, @@ -625,10 +717,19 @@ def _provider_dimension_details( "context_score": context_score, "input_score": input_score, "output_score": output_score, + "image_score": image_score, "headroom": headroom, "context_ratio": round(context_ratio, 3), "input_ratio": round(input_ratio, 3), "output_ratio": round(output_ratio, 3) if requested_output else 0.0, + "image_outputs_fit": image_outputs_fit, + "image_size_fit": image_size_fit, + "image_supported_size": image_supported_size, + "requested_image_outputs": ctx.requested_image_outputs, + "requested_image_size": ctx.requested_image_size, + "max_image_outputs": image_cfg.get("max_outputs"), + "max_image_side_px": image_cfg.get("max_side_px"), + "supported_image_sizes": image_cfg.get("supported_sizes", []), "avg_latency_ms": avg_latency_ms, "consecutive_failures": consecutive_failures, "cache_mode": cache.get("mode", "none"), @@ -911,6 +1012,10 @@ class _RoutingContext: "stable_prefix_tokens", "requested_output_tokens", "total_requested_tokens", + "requested_image_outputs", + "requested_image_side_px", + "requested_image_size", + "required_capability", "cache_preference", "model_requested", "has_tools", diff --git a/tests/test_route_introspection.py b/tests/test_route_introspection.py index c5bb4a6..87c2cd0 100644 --- a/tests/test_route_introspection.py +++ b/tests/test_route_introspection.py @@ -159,6 +159,23 @@ def preview_config(tmp_path, monkeypatch): tier: default capabilities: image_editing: true + image: + max_outputs: 1 + max_side_px: 1024 + supported_sizes: ["1024x1024"] + image-large: + contract: image-provider + backend: openai-compat + base_url: "https://api.example.com/v1" + api_key: "secret" + model: "gpt-image-1-hd" + tier: default + capabilities: + image_editing: true + image: + max_outputs: 4 + max_side_px: 2048 + supported_sizes: ["1024x1024", "2048x2048"] client_profiles: enabled: true default: generic @@ -211,6 +228,19 @@ def preview_config(tmp_path, monkeypatch): "image_editing": True, }, ), + "image-large": _ProviderStub( + name="image-large", + model="gpt-image-1-hd", + contract="image-provider", + tier="default", + capabilities={ + "local": False, + "cloud": True, + "network_zone": "public", + "image_generation": True, + "image_editing": True, + }, + ), }, raising=False, ) @@ -345,6 +375,30 @@ async def test_image_route_preview_endpoint_reports_modality(self, preview_confi assert response["decision"]["provider"] == "image-cloud" assert response["selected_provider"]["contract"] == "image-provider" + @pytest.mark.asyncio + async def test_image_route_preview_prefers_provider_that_fits_size_and_count( + self, preview_config + ): + response = await preview_image_route( + _json_request( + "/api/route/image", + { + "model": "auto", + "capability": "image_generation", + "prompt": "Create a high-resolution architectural render.", + "size": "2048x2048", + "n": 2, + }, + ) + ) + + assert response["decision"]["provider"] == "image-large" + ranking = response["decision"]["details"]["candidate_ranking"] + assert len(ranking) == 1 + assert ranking[0]["provider"] == "image-large" + assert ranking[0]["image_size_fit"] is True + assert ranking[0]["image_outputs_fit"] is True + def test_extract_image_edit_request_fields_requires_prompt(self): with pytest.raises(ValueError, match="non-empty 'prompt'"): _extract_image_edit_request_fields({"model": "auto"})