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 @@ -13,6 +13,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel
- 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
- Added optional `image.policy_tags` plus request-side image-policy hints so image routing can prefer providers tagged for `quality`, `cost`, `balanced`, `batch`, or `editing`

## v0.5.0 - 2026-03-12

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,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
- optional image-policy hints can be passed via `metadata.image_policy` or `X-FoundryGate-Image-Policy`

```bash
curl -fsS http://127.0.0.1:8090/v1/images/generations \
Expand All @@ -240,6 +241,7 @@ OpenAI-compatible image editing endpoint.
- `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
- optional image-policy hints can be passed via form field `image_policy`, `metadata.image_policy`, or `X-FoundryGate-Image-Policy`

```bash
curl -fsS http://127.0.0.1:8090/v1/images/edits \
Expand Down Expand Up @@ -296,6 +298,8 @@ If request hooks are enabled, `POST /api/route` also shows the applied hook name

`GET /api/providers` returns the current provider inventory, including capability flags and optional image metadata such as `max_outputs`, `max_side_px`, and `supported_sizes`.

For image-capable providers, `image.policy_tags` can be used as lightweight presets such as `quality`, `cost`, `balanced`, `batch`, or `editing`. When a request carries `metadata.image_policy` or `X-FoundryGate-Image-Policy`, routing prefers providers whose `image.policy_tags` match that hint.

`GET /api/stats`, `GET /api/recent`, and `GET /api/traces` also accept optional `provider`, `modality`, `client_profile`, `client_tag`, `layer`, and `success` filters. The built-in dashboard uses the same filtered endpoints.

`GET /api/traces` returns recent enriched routing records from the metrics store, including requested model, modality, resolved client profile, client tag, decision reason, confidence, and attempt order.
Expand Down
10 changes: 10 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ server:
# read_s : read/response timeout (default: 120)
# pricing : USD per 1 000 000 tokens (input / output / cache_read)
# Used for cost tracking only – not enforced.
# image : optional image-routing metadata for `contract: image-provider`
# max_outputs : maximum supported `n`
# max_side_px : largest supported edge size
# supported_sizes: optional exact allowed size strings such as 1024x1024
# policy_tags : optional routing tags such as quality | cost | balanced
#
# Sections
# ─────────
Expand Down Expand Up @@ -190,6 +195,11 @@ providers:
# capabilities:
# # image_generation is enabled automatically by the contract
# image_editing: true
# image:
# max_outputs: 4
# max_side_px: 2048
# supported_sizes: ["1024x1024", "2048x2048"]
# policy_tags: ["quality", "editing", "batch"]

# ── Anthropic ───────────────────────────────────────────────────────────
# Auth: ANTHROPIC_API_KEY (or setup-token)
Expand Down
18 changes: 18 additions & 0 deletions foundrygate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,24 @@ def _normalize_provider_image(name: str, cfg: dict[str, Any]) -> dict[str, Any]:
if normalized_sizes:
image["supported_sizes"] = normalized_sizes

policy_tags = raw.get("policy_tags", [])
if policy_tags in (None, ""):
policy_tags = []
if isinstance(policy_tags, str):
policy_tags = [policy_tags]
if not isinstance(policy_tags, list):
raise ConfigError(f"Provider '{name}' field 'image.policy_tags' must be a list")

normalized_tags = []
for value in policy_tags:
if not isinstance(value, str) or not value.strip():
raise ConfigError(
f"Provider '{name}' field 'image.policy_tags' must contain non-empty strings"
)
normalized_tags.append(value.strip().lower())
if normalized_tags:
image["policy_tags"] = normalized_tags

return image


Expand Down
29 changes: 27 additions & 2 deletions foundrygate/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ def _estimate_image_request_dimensions(body: dict[str, Any], *, capability: str)
"prompt_chars": len(str(body.get("prompt") or "")),
"requested_size": body.get("size") or "",
"requested_outputs": body.get("n") if isinstance(body.get("n"), int) else 1,
"image_policy": _collect_request_image_policy(body),
"capability": capability,
}

Expand All @@ -288,12 +289,25 @@ def _collect_request_cache_preference(body: dict[str, Any]) -> str:
return ""


def _collect_request_image_policy(body: dict[str, Any]) -> str:
"""Return one optional image-policy hint from request data."""
if isinstance(body.get("image_policy"), str) and body["image_policy"].strip():
return body["image_policy"].strip().lower()
metadata = body.get("metadata") if isinstance(body.get("metadata"), dict) else {}
if isinstance(metadata.get("image_policy"), str) and metadata["image_policy"].strip():
return metadata["image_policy"].strip().lower()
return ""


def _merge_routing_context_headers(headers: dict[str, str], body: dict[str, Any]) -> dict[str, str]:
"""Return routing headers plus request-body dimension hints."""
merged = dict(headers)
cache_preference = _collect_request_cache_preference(body)
if cache_preference:
if cache_preference and "x-foundrygate-cache" not in merged:
merged["x-foundrygate-cache"] = cache_preference
image_policy = _collect_request_image_policy(body)
if image_policy and "x-foundrygate-image-policy" not in merged:
merged["x-foundrygate-image-policy"] = image_policy
return merged


Expand Down Expand Up @@ -464,7 +478,17 @@ def _normalize_image_request_body(body: dict[str, Any], *, capability: str) -> d
if metadata is not None:
if not isinstance(metadata, dict):
raise ValueError("Field 'metadata' must be an object when provided")
normalized["metadata"] = metadata
normalized["metadata"] = dict(metadata)

image_policy = body.get("image_policy")
if image_policy in (None, "") and isinstance(normalized.get("metadata"), dict):
image_policy = normalized["metadata"].get("image_policy")
if image_policy not in (None, ""):
if not isinstance(image_policy, str) or not image_policy.strip():
raise ValueError("Field 'image_policy' must be a non-empty string when provided")
cleaned_policy = image_policy.strip().lower()
normalized["image_policy"] = cleaned_policy
normalized.setdefault("metadata", {})["image_policy"] = cleaned_policy

return normalized

Expand Down Expand Up @@ -503,6 +527,7 @@ async def _resolve_image_route_preview(
"""Resolve one image-generation request without calling a provider."""
body, hook_state = await _apply_request_hooks(body, headers)
body = _normalize_image_request_body(body, capability=capability)
headers = _merge_routing_context_headers(headers, body)
prompt = body["prompt"]

model_requested = str(body.get("model", "auto"))
Expand Down
19 changes: 19 additions & 0 deletions foundrygate/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ async def route(
requested_image_outputs=1,
requested_image_side_px=0,
requested_image_size="",
requested_image_policy="",
required_capability="",
cache_preference=(headers or {}).get("x-foundrygate-cache", "").strip().lower(),
model_requested=model_requested.lower().strip(),
Expand Down Expand Up @@ -289,6 +290,9 @@ def route_capability_request(
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 "",
requested_image_policy=(
(headers or {}).get("x-foundrygate-image-policy", "").strip().lower()
),
required_capability=capability,
cache_preference=(headers or {}).get("x-foundrygate-cache", "").strip().lower(),
model_requested=model_requested.lower().strip(),
Expand Down Expand Up @@ -662,9 +666,12 @@ def _provider_dimension_details(
)

image_score = 0
image_policy_score = 0
image_outputs_fit = True
image_size_fit = True
image_supported_size = True
image_policy_match = not bool(ctx.requested_image_policy)
image_policy_tags = image_cfg.get("policy_tags", [])
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)
Expand Down Expand Up @@ -694,6 +701,12 @@ def _provider_dimension_details(
elif ctx.requested_image_size:
image_score += 1

if ctx.requested_image_policy:
image_policy_match = ctx.requested_image_policy in image_policy_tags
image_policy_score = 12 if image_policy_match else 0
elif image_policy_tags:
image_policy_score = 1

fit = self._provider_fits_request_dimensions(name, provider, ctx)
score_total = (
health_score
Expand All @@ -705,6 +718,7 @@ def _provider_dimension_details(
+ input_score
+ output_score
+ image_score
+ image_policy_score
)
return {
"fit": fit,
Expand All @@ -718,13 +732,17 @@ def _provider_dimension_details(
"input_score": input_score,
"output_score": output_score,
"image_score": image_score,
"image_policy_score": image_policy_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,
"image_policy_match": image_policy_match,
"requested_image_policy": ctx.requested_image_policy,
"image_policy_tags": image_policy_tags,
"requested_image_outputs": ctx.requested_image_outputs,
"requested_image_size": ctx.requested_image_size,
"max_image_outputs": image_cfg.get("max_outputs"),
Expand Down Expand Up @@ -1015,6 +1033,7 @@ class _RoutingContext:
"requested_image_outputs",
"requested_image_side_px",
"requested_image_size",
"requested_image_policy",
"required_capability",
"cache_preference",
"model_requested",
Expand Down
21 changes: 21 additions & 0 deletions tests/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,24 @@ def test_image_provider_contract_rejects_non_openai_backend(tmp_path):

with pytest.raises(ConfigError, match="image-provider"):
load_config(path)


def test_image_provider_policy_tags_are_normalized(tmp_path):
path = _write_config(
tmp_path,
(
" image-cloud:\n"
" contract: image-provider\n"
" backend: openai-compat\n"
' base_url: "https://api.example.com/v1"\n'
' api_key: "secret"\n'
' model: "gpt-image-1"\n'
" image:\n"
' policy_tags: ["Quality", " editing "]\n'
),
)

cfg = load_config(path)
provider = cfg.provider("image-cloud")

assert provider["image"]["policy_tags"] == ["quality", "editing"]
48 changes: 48 additions & 0 deletions tests/test_route_introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ def preview_config(tmp_path, monkeypatch):
max_outputs: 1
max_side_px: 1024
supported_sizes: ["1024x1024"]
policy_tags: ["balanced", "cost", "editing"]
image-large:
contract: image-provider
backend: openai-compat
Expand All @@ -181,6 +182,7 @@ def preview_config(tmp_path, monkeypatch):
max_outputs: 4
max_side_px: 2048
supported_sizes: ["1024x1024", "2048x2048"]
policy_tags: ["quality", "batch"]
client_profiles:
enabled: true
default: generic
Expand Down Expand Up @@ -236,6 +238,7 @@ def preview_config(tmp_path, monkeypatch):
"max_outputs": 1,
"max_side_px": 1024,
"supported_sizes": ["1024x1024"],
"policy_tags": ["balanced", "cost", "editing"],
},
),
"image-large": _ProviderStub(
Expand All @@ -254,6 +257,7 @@ def preview_config(tmp_path, monkeypatch):
"max_outputs": 4,
"max_side_px": 2048,
"supported_sizes": ["1024x1024", "2048x2048"],
"policy_tags": ["quality", "batch"],
},
),
},
Expand Down Expand Up @@ -414,6 +418,47 @@ async def test_image_route_preview_prefers_provider_that_fits_size_and_count(
assert ranking[0]["image_size_fit"] is True
assert ranking[0]["image_outputs_fit"] is True

@pytest.mark.asyncio
async def test_image_route_preview_prefers_matching_policy_tag(self, preview_config):
response = await preview_image_route(
_json_request(
"/api/route/image",
{
"model": "auto",
"capability": "image_generation",
"prompt": "Create a polished product render.",
"size": "1024x1024",
"metadata": {"image_policy": "quality"},
},
)
)

assert response["effective_request"]["image_policy"] == "quality"
assert response["decision"]["provider"] == "image-large"
ranking = response["decision"]["details"]["candidate_ranking"]
assert ranking[0]["provider"] == "image-large"
assert ranking[0]["image_policy_match"] is True
assert ranking[0]["requested_image_policy"] == "quality"

@pytest.mark.asyncio
async def test_image_route_preview_header_policy_overrides_metadata(self, preview_config):
response = await preview_image_route(
_json_request(
"/api/route/image",
{
"model": "auto",
"capability": "image_generation",
"prompt": "Create a cheap concept sketch.",
"size": "1024x1024",
"metadata": {"image_policy": "quality"},
},
headers={"x-foundrygate-image-policy": "cost"},
)
)

assert response["routing_headers"]["x-foundrygate-image-policy"] == "cost"
assert response["decision"]["provider"] == "image-cloud"

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 All @@ -427,6 +472,7 @@ def test_extract_image_edit_request_fields_parses_scalars(self):
"size": "1024x1024",
"response_format": "b64_json",
"user": "tester",
"image_policy": "editing",
}
)

Expand All @@ -436,6 +482,8 @@ def test_extract_image_edit_request_fields_parses_scalars(self):
assert payload["size"] == "1024x1024"
assert payload["response_format"] == "b64_json"
assert payload["user"] == "tester"
assert payload["image_policy"] == "editing"
assert payload["metadata"]["image_policy"] == "editing"

def test_normalize_image_request_body_validates_size_and_n(self):
payload = _normalize_image_request_body(
Expand Down
Loading