diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d80151..72deba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5e169dd..eefd4b2 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,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 +- 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 \ @@ -240,6 +241,7 @@ OpenAI-compatible image editing endpoint. - `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 +- 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 \ @@ -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. diff --git a/config.yaml b/config.yaml index a2bbefc..c9703e9 100644 --- a/config.yaml +++ b/config.yaml @@ -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 # ───────── @@ -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) diff --git a/foundrygate/config.py b/foundrygate/config.py index f3511a8..bf7368e 100644 --- a/foundrygate/config.py +++ b/foundrygate/config.py @@ -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 diff --git a/foundrygate/main.py b/foundrygate/main.py index 4cc963d..53a593b 100644 --- a/foundrygate/main.py +++ b/foundrygate/main.py @@ -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, } @@ -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 @@ -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 @@ -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")) diff --git a/foundrygate/router.py b/foundrygate/router.py index 10ee657..58a57fb 100644 --- a/foundrygate/router.py +++ b/foundrygate/router.py @@ -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(), @@ -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(), @@ -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) @@ -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 @@ -705,6 +718,7 @@ def _provider_dimension_details( + input_score + output_score + image_score + + image_policy_score ) return { "fit": fit, @@ -718,6 +732,7 @@ 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), @@ -725,6 +740,9 @@ def _provider_dimension_details( "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"), @@ -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", diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 001c7d0..d0a04ed 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -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"] diff --git a/tests/test_route_introspection.py b/tests/test_route_introspection.py index 9789358..d52d57e 100644 --- a/tests/test_route_introspection.py +++ b/tests/test_route_introspection.py @@ -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 @@ -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 @@ -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( @@ -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"], }, ), }, @@ -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"}) @@ -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", } ) @@ -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(