"""GET|POST /api/bte/proxy?path= — same-origin proxy to BTE REST. The browser hits the plugin (same origin as webui :8787), the plugin forwards to BTE REST (:6001 by default). This avoids cross-origin CORS, keeps BTE free of webui-origin allowances, and lets us whitelist exactly which BTE endpoints the panel can reach. Path A adapter: for POST /api/command/requestPhotoshoot the panel sends a recipe-shape payload (brand slug + recipeSlug + items[] + variantsPerScenario) that the current BTE backend doesn't yet accept (PRD §5.8 not shipped). The adapter translates the panel shape into BTE's canonical shape (brand Guid + fully-formed shots[]) before forwarding. All other proxied paths pass through unchanged. Configuration (read at call time, never persisted): BTE_BASE_URL BTE REST base (default http://localhost:6001) BTE_TENANT_ID default X-Tenant-Id forwarded to BTE (Plan B tenant uuid) Public API surface used: api.register_route, api.logger. Stdlib only — no urllib3, no requests. """ import json import os import random import re import urllib.error import urllib.parse import urllib.request _DEFAULT_BTE_BASE = "http://localhost:6001" _DEFAULT_TENANT = "00000000-0000-0000-0000-000000000001" # Plan B tenant # Path A adapter constants — translation tables for the recipe-driven payload # the panel sends (per static/bte.js _onGenerate) into the per-shot canonical # shape BTE's RequestPhotoshootCommand accepts today. # Mirror of sot/07-BRAND/PLANB-RECIPE-TAXONOMY.md §5 (5 families). _RECIPE_PROMPTS = { "hero-shot": "Centered hero product shot of {offering_name}, studio lighting, clean background, photoreal", "lifestyle-shot": "Lifestyle scene featuring {offering_name} on a wood kitchen counter, golden hour, hands holding", "photoshoot": "Catalog photoshoot of {offering_name}, multiple angles, neutral lighting", "recipe-sheet": "Editorial recipe sheet: {offering_name} dish + 3 ingredient flat-lays, branded margin", "montage-catalog": "Catalog grid montage featuring {offering_name}, unified treatment, top-down view", } _MODE_PREFIXES = { "polished": "Magazine-grade hero, retouched. ", "ugc": "Hand-held UGC feel, mild grain, off-axis. ", "photoreal": "Photorealistic, neutral palette, no stylization. ", "artistic": "Artistic editorial interpretation. ", } # POSITIVE-phrased Plan B hard rules — FLUX.2 negatives barely work # (per bte/CLAUDE.md "Gotchas") so brand constraints ride as positives. _BRAND_RULES = ( " Plan B brand: real plated dish on ceramic or wood serving surface; " "QC region cues (wood grain, warm autumn tones); fr-CA context. " "Strictly no steam plumes, no takeout containers, no branded labels, " "no plastic packaging." ) # Per-recipe default dimensions (panel does not send w/h yet). _RECIPE_DIMS = { "hero-shot": (1024, 1024), "photoshoot": (1024, 1024), "recipe-sheet": (1024, 1024), "lifestyle-shot": (1024, 1280), # 4:5 portrait "montage-catalog": (1536, 1024), # 3:2 landscape } # BTE RubricMode validator accepts only these two; panel modes "photoreal" / # "artistic" fall back to "polished". _RUBRIC_FALLBACK = {"polished": "polished", "ugc": "ugc"} # Brand slug -> Guid cache, populated on first translation call. _BRAND_ID_CACHE: dict = {} # Whitelist of allowed BTE paths. Static prefixes match exact path; pattern # entries match a literal `/api/assets//` form. Keep tight — the # proxy is a privilege amplifier, only the panel's needs go here. _ALLOWED_EXACT = frozenset({ "/api/command/requestPhotoshoot", "/api/command/rateAsset", "/api/query/assetGrid", # PRD §5.4 (not yet built on BTE — 404 until ships) "/api/query/assetDtos", # works today — pre-PRD grid "/api/query/recipeStats", }) # Pattern: /api/assets//(thumb|status|image). image = full bytes via /api/assets/{id}/image. _ALLOWED_PATTERN = re.compile(r"^/api/assets/[A-Za-z0-9_\-]+/(thumb|status|image)$") def register(api): """Wire the GET + POST /api/bte/proxy routes.""" log = api.logger("svrnty.routes.bte_proxy") api.register_route("/api/bte/proxy", "GET", _handle_proxy) api.register_route("/api/bte/proxy", "POST", _handle_proxy) log.info("bte proxy endpoint registered (GET+POST)") def _is_allowed(path: str) -> bool: if path in _ALLOWED_EXACT: return True if _ALLOWED_PATTERN.match(path): return True return False def _handle_proxy(handler, parsed): """Forward a single request to BTE. Returns BTE response body verbatim.""" qs = urllib.parse.parse_qs(parsed.query or "") target_path = (qs.get("path") or [""])[0].strip() if not target_path or not target_path.startswith("/api/"): return _send_json(handler, {"ok": False, "error": "missing or invalid path"}, 400) if not _is_allowed(target_path): return _send_json(handler, {"ok": False, "error": f"path not allowed: {target_path}"}, 403) base = os.environ.get("BTE_BASE_URL", _DEFAULT_BTE_BASE).rstrip("/") tenant = (handler.headers.get("X-Tenant-Id") or os.environ.get("BTE_TENANT_ID", _DEFAULT_TENANT)) target_url = base + target_path method = handler.command # 'GET' / 'POST' body = b"" content_type = handler.headers.get("Content-Type", "application/json") if method in ("POST", "PUT", "PATCH"): length = int(handler.headers.get("Content-Length", "0") or 0) if length > 0: body = handler.rfile.read(length) # Path A adapter: rewrite the panel-shape payload into BTE-canonical shape # for requestPhotoshoot only. All other endpoints pass through verbatim. if method == "POST" and target_path == "/api/command/requestPhotoshoot" and body: try: panel_payload = json.loads(body.decode("utf-8")) except (ValueError, UnicodeDecodeError) as e: return _send_json(handler, {"ok": False, "error": f"invalid JSON: {e}"}, 400) if _looks_like_panel_payload(panel_payload): try: bte_payload = _translate_panel_to_bte(panel_payload, base, tenant) except _AdapterError as e: return _send_json(handler, {"ok": False, "error": str(e)}, e.status) body = json.dumps(bte_payload).encode("utf-8") content_type = "application/json" req = urllib.request.Request(target_url, data=body if body else None, method=method) req.add_header("X-Tenant-Id", tenant) if body: req.add_header("Content-Type", content_type) auth = handler.headers.get("Authorization") if auth: req.add_header("Authorization", auth) try: with urllib.request.urlopen(req, timeout=30) as resp: resp_body = resp.read() resp_ctype = resp.headers.get("Content-Type", "application/octet-stream") resp_status = resp.status except urllib.error.HTTPError as e: # Forward BTE's own error status + body — panel renders "endpoint coming # soon" placeholders when it sees 404/501. resp_body = e.read() or b"" resp_ctype = e.headers.get("Content-Type", "application/json") if e.headers else "application/json" resp_status = e.code except urllib.error.URLError as e: return _send_json(handler, {"ok": False, "error": f"bte unreachable: {e.reason}"}, 502) except Exception as e: return _send_json(handler, {"ok": False, "error": f"proxy error: {e}"}, 500) handler.send_response(resp_status) handler.send_header("Content-Type", resp_ctype) handler.send_header("Content-Length", str(len(resp_body))) handler.send_header("Cache-Control", "no-store") handler.end_headers() handler.wfile.write(resp_body) return True def _send_json(handler, payload: dict, status: int) -> bool: body = json.dumps(payload).encode("utf-8") handler.send_response(status) handler.send_header("Content-Type", "application/json; charset=utf-8") handler.send_header("Content-Length", str(len(body))) handler.send_header("Cache-Control", "no-store") handler.end_headers() handler.wfile.write(body) return True # ── Path A adapter helpers ─────────────────────────────────────────────────── class _AdapterError(Exception): """Translation aborted — propagate status + reason back to the caller.""" def __init__(self, message: str, status: int = 502): super().__init__(message) self.status = status def _looks_like_panel_payload(payload) -> bool: """Heuristic: panel payloads have 'recipeSlug' + 'items'; canonical BTE payloads have 'shots'. Routing on field presence lets a future caller post the canonical shape directly without going through translation. """ return ( isinstance(payload, dict) and "recipeSlug" in payload and "items" in payload and "shots" not in payload ) def _resolve_brand_id(brand_slug: str, base: str, tenant: str) -> str: """Slug -> Guid via /api/query/brandDtos. Cached in-process; on cache miss we hit BTE once. If a slug we've never seen comes in we refresh the whole cache (BTE returns all brands in a single call, so this is cheap). """ if not brand_slug: raise _AdapterError("brandId (slug) is required", status=400) # UUID passthrough: panel only ever sends slugs but be permissive — if a # caller already resolved the Guid, accept it verbatim. if re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", brand_slug): return brand_slug if brand_slug in _BRAND_ID_CACHE: return _BRAND_ID_CACHE[brand_slug] _refresh_brand_cache(base, tenant) if brand_slug not in _BRAND_ID_CACHE: raise _AdapterError( f"brand slug '{brand_slug}' not found in BTE — refusing to seed", status=404, ) return _BRAND_ID_CACHE[brand_slug] def _refresh_brand_cache(base: str, tenant: str) -> None: """Repopulate _BRAND_ID_CACHE from BTE's brandDtos query. Empty body would 400, so we POST an empty filter object. """ url = base.rstrip("/") + "/api/query/brandDtos" body = b"{}" req = urllib.request.Request(url, data=body, method="POST") req.add_header("Content-Type", "application/json") req.add_header("X-Tenant-Id", tenant) try: with urllib.request.urlopen(req, timeout=10) as resp: payload = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: raise _AdapterError( f"brandDtos lookup failed: HTTP {e.code} — {e.read()[:200].decode('utf-8', 'replace')}", status=502, ) except urllib.error.URLError as e: raise _AdapterError(f"brandDtos unreachable: {e.reason}", status=502) data = payload.get("data") if isinstance(payload, dict) else None if not data: raise _AdapterError( "BTE returned no brands — DB empty? cannot seed; stop and ask JP", status=502, ) _BRAND_ID_CACHE.clear() for b in data: slug = (b.get("name") or "").strip() bid = (b.get("id") or "").strip() if slug and bid: _BRAND_ID_CACHE[slug] = bid def _build_flux2_workflow_json(prompt: str, width: int, height: int) -> str: """Port of Svrnty.Bte.Shared.Recipes.StopgapFluxWorkflow.Build. BTE's LocalFluxImageProvider rejects empty WorkflowJson with InvalidOperationException, and the recipe-driven IRecipeAssembler isn't shipped yet (PRD §5.8 pending), so the adapter inlines the same stopgap graph the C# side uses. Random seed per call: ComfyUI dedupes identical workflows (execution_cached). """ seed = random.randint(1, 2**31 - 1) graph = { "5": {"class_type": "EmptySD3LatentImage", "inputs": {"width": width, "height": height, "batch_size": 1}}, "6": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["11", 0], "text": prompt}}, "7": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["11", 0], "text": ""}}, "8": {"class_type": "VAEDecode", "inputs": {"samples": ["13", 0], "vae": ["10", 0]}}, "9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "bte", "images": ["8", 0]}}, "10": {"class_type": "VAELoader", "inputs": {"vae_name": "flux2-vae.safetensors"}}, "11": {"class_type": "CLIPLoader", "inputs": {"clip_name": "mistral_3_small_flux2_fp8.safetensors", "type": "flux2"}}, "12": {"class_type": "UNETLoader", "inputs": {"unet_name": "flux2_dev_fp8mixed.safetensors", "weight_dtype": "default"}}, "13": {"class_type": "KSampler", "inputs": { "model": ["12", 0], "positive": ["26", 0], "negative": ["7", 0], "latent_image": ["5", 0], "seed": seed, "steps": 20, "cfg": 1.0, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0, }}, "26": {"class_type": "FluxGuidance", "inputs": {"conditioning": ["6", 0], "guidance": 2.5}}, } return json.dumps(graph) def _parse_mode_from_label(recipe_label: str, default: str = "polished") -> str: """Panel sends recipeLabel = '____'. Extract mode.""" parts = (recipe_label or "").split("__") if len(parts) >= 2 and parts[1] in _MODE_PREFIXES: return parts[1] return default def _translate_panel_to_bte(panel: dict, base: str, tenant: str) -> dict: """Recipe-shape -> canonical RequestPhotoshootCommand payload.""" brand_slug = (panel.get("brandId") or "").strip() brand_guid = _resolve_brand_id(brand_slug, base, tenant) recipe_slug = (panel.get("recipeSlug") or "").strip() if recipe_slug not in _RECIPE_PROMPTS: raise _AdapterError( f"unknown recipeSlug '{recipe_slug}' (expected one of: {sorted(_RECIPE_PROMPTS)})", status=400, ) mode = _parse_mode_from_label(panel.get("recipeLabel") or "") rubric_mode = _RUBRIC_FALLBACK.get(mode, "polished") items = panel.get("items") or [] if not isinstance(items, list) or not items: raise _AdapterError("items[] is required and must be non-empty", status=400) variants = panel.get("variantsPerScenario") or 1 try: variants = max(1, min(12, int(variants))) except (TypeError, ValueError): variants = 1 width, height = _RECIPE_DIMS.get(recipe_slug, (1024, 1024)) prefix = _MODE_PREFIXES.get(mode, _MODE_PREFIXES["polished"]) template = _RECIPE_PROMPTS[recipe_slug] shots = [] for item in items: if not isinstance(item, dict): continue # Panel sends offeringName (per static/bte.js line 324); fall back to # name/offeringId so a future caller doesn't have to send both. offering_name = ( item.get("offeringName") or item.get("name") or str(item.get("offeringId") or "item") ) prompt = prefix + template.format(offering_name=offering_name) + _BRAND_RULES for _ in range(variants): shots.append({ "prompt": prompt, "workflowJson": _build_flux2_workflow_json(prompt, width, height), "width": width, "height": height, }) if not shots: raise _AdapterError("no shots produced — items[] had no usable entries", status=400) return { "brandId": brand_guid, "shots": shots, "autoEvaluate": bool(panel.get("autoEvaluate", False)), "rubricMode": rubric_mode, }