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 @@ -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

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions foundrygate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions foundrygate/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
105 changes: 105 additions & 0 deletions foundrygate/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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 {}

Expand Down Expand Up @@ -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
Expand All @@ -613,6 +704,7 @@ def _provider_dimension_details(
+ context_score
+ input_score
+ output_score
+ image_score
)
return {
"fit": fit,
Expand All @@ -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"),
Expand Down Expand Up @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions tests/test_route_introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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"})
Expand Down
Loading