"""User-facing helpers for cmhub hosted model aliases.""" from __future__ import annotations import copy from decimal import Decimal, InvalidOperation import threading import time TIER_DEFAULT = "default" TIER_HIGH_QUALITY = "high_quality" TIER_ECONOMICAL = "economical" TIER_LABELS = { TIER_DEFAULT: "默认档", TIER_HIGH_QUALITY: "高质量档", TIER_ECONOMICAL: "省点档", } TIER_DESCRIPTIONS = { TIER_DEFAULT: "日常批量,质量、速度和成本平衡", TIER_HIGH_QUALITY: "重点商品或失败重试,质量优先,成本可能更高", TIER_ECONOMICAL: "低价值 SKU 或初稿,省点优先,上线前需人工复核", } # The catalog is deliberately process-local. It only caches public model metadata, # never the API key or a price estimate chosen by a user. MODEL_CATALOG_CACHE_TTL_SECONDS = 300 _MODEL_CATALOG_CACHE = {} _MODEL_CATALOG_CACHE_LOCK = threading.RLock() _CATALOG_ALL_ALIASES_KEY = "*" _PRICE_CONDITION_KEYS = ( "resolution", "name", "quality", "size", "ratio", "aspect_ratio", "tier", "condition", "conditions", "min_images", "max_images", "image_count", ) def cache_model_catalog(base_url, models, *, now=None): """Cache one fetched catalog in memory for all aliases it contains.""" normalized_base_url = _catalog_base_url(base_url) if not normalized_base_url: return copied_models = [copy.deepcopy(model) for model in list(models or []) if isinstance(model, dict)] fetched_at = float(time.monotonic() if now is None else now) entry = (fetched_at, copied_models) aliases = { str(model.get("alias") or "").strip() for model in copied_models if str(model.get("alias") or "").strip() } with _MODEL_CATALOG_CACHE_LOCK: _MODEL_CATALOG_CACHE[(normalized_base_url, _CATALOG_ALL_ALIASES_KEY)] = entry for alias in aliases: _MODEL_CATALOG_CACHE[(normalized_base_url, alias)] = entry def cached_model_catalog(base_url, alias="", *, max_age_seconds=None, now=None): """Return a fresh catalog copy for a normalized gateway/alias pair, if available.""" normalized_base_url = _catalog_base_url(base_url) normalized_alias = str(alias or "").strip() if not normalized_base_url: return None max_age = MODEL_CATALOG_CACHE_TTL_SECONDS if max_age_seconds is None else max_age_seconds try: max_age = float(max_age) except (TypeError, ValueError): return None current = float(time.monotonic() if now is None else now) keys = [(normalized_base_url, normalized_alias)] if normalized_alias else [] keys.append((normalized_base_url, _CATALOG_ALL_ALIASES_KEY)) with _MODEL_CATALOG_CACHE_LOCK: for key in keys: entry = _MODEL_CATALOG_CACHE.get(key) if entry is None: continue fetched_at, models = entry if current - fetched_at > max_age: continue return [copy.deepcopy(model) for model in models] return None def clear_model_catalog_cache(): """Clear the transient catalog cache. Intended for tests and gateway changes.""" with _MODEL_CATALOG_CACHE_LOCK: _MODEL_CATALOG_CACHE.clear() def unambiguous_points_cost(models, alias, operation_type, *, requires_image=None): """Return the unique unconditional price for one configured alias, otherwise ``None``.""" normalized_alias = str(alias or "").strip() normalized_operation = str(operation_type or "").strip().lower() matches = [ model for model in list(models or []) if isinstance(model, dict) and str(model.get("alias") or "").strip() == normalized_alias ] if len(matches) != 1: return None model = matches[0] if str(model.get("operation_type") or "").strip().lower() != normalized_operation: return None if str(model.get("pricing_status") or "").strip().lower() != "priced": return None if requires_image is True and model.get("requires_image") is not True: return None if requires_image is False and model.get("requires_image") is not False: return None prices = model.get("prices") if not isinstance(prices, list) or len(prices) != 1 or not isinstance(prices[0], dict): return None price = prices[0] if any(_has_value(price.get(key)) for key in _PRICE_CONDITION_KEYS): return None return _points_cost_value(price) def format_points_cost(value): """Format a catalog points value without inventing precision.""" if value is None: return "" decimal_value = _points_cost_decimal(value) if decimal_value is None: return "" text = format(decimal_value.normalize(), "f") if "." in text: text = text.rstrip("0").rstrip(".") return text or "0" def _catalog_base_url(base_url): return str(base_url or "").strip().rstrip("/").lower() def _has_value(value): return value not in (None, "", [], {}, ()) def _points_cost_value(price): if not isinstance(price, dict): return None if "points_cost" not in price: return None return _points_cost_decimal(price.get("points_cost")) def _points_cost_decimal(value): if isinstance(value, bool) or value is None: return None try: decimal_value = Decimal(str(value)) except (InvalidOperation, ValueError): return None if not decimal_value.is_finite() or decimal_value < 0: return None return decimal_value def normalize_tier(value): text = str(value or "").strip().lower().replace("-", "_").replace(" ", "_") if text in {"high", "quality", "high_quality", "premium", "pro", "sol"}: return TIER_HIGH_QUALITY if text in {"low", "cheap", "economy", "economical", "saving", "lite", "luna"}: return TIER_ECONOMICAL if text in {"default", "standard", "balanced", "balance", "terra"}: return TIER_DEFAULT return "" def model_tier(model): model = model if isinstance(model, dict) else {} for key in ("tier", "model_tier", "quality_tier", "cmhub_tier", "recommended_tier"): tier = normalize_tier(model.get(key)) if tier: return tier haystack = " ".join(_model_text_values(model)).lower() if any(token in haystack for token in ("省点", "低成本", "高频", "high-volume", "luna", "economy", "cheap", "lite")): return TIER_ECONOMICAL if any(token in haystack for token in ("高质量", "旗舰", "重点", "失败重试", "premium", "high-quality", "sol")): return TIER_HIGH_QUALITY if any(token in haystack for token in ("平衡", "默认", "日常", "standard", "balanced", "terra")): return TIER_DEFAULT alias = str(model.get("alias") or "").lower() if alias.endswith("-hd") or alias.endswith("_hd") or alias.endswith(".hd") or alias == "hd": return TIER_HIGH_QUALITY return TIER_DEFAULT def tier_label(tier): return TIER_LABELS.get(normalize_tier(tier) or tier, TIER_LABELS[TIER_DEFAULT]) def tier_description(tier): return TIER_DESCRIPTIONS.get(normalize_tier(tier) or tier, TIER_DESCRIPTIONS[TIER_DEFAULT]) def model_display_name(model): model = model if isinstance(model, dict) else {} for key in ("display_name", "name", "label"): value = str(model.get(key) or "").strip() if value: return value return str(model.get("alias") or "").strip() def price_text(prices): if not isinstance(prices, list): return "" parts = [] for item in prices[:3]: if not isinstance(item, dict): continue cost = item.get("points_cost") if cost is None: cost = item.get("cost") if cost is None: continue resolution = item.get("resolution") or item.get("name") or "" parts.append(f"{resolution}:{cost}点" if resolution else f"{cost}点") return "/".join(parts) def alias_label(model): model = model if isinstance(model, dict) else {} alias = str(model.get("alias") or "").strip() display_name = model_display_name(model) tier = model_tier(model) parts = [tier_label(tier)] if display_name and display_name != alias: parts.append(display_name) elif alias: parts.append(alias) prices = price_text(model.get("prices")) if prices: parts.append(prices) if model.get("requires_image"): parts.append("需参考图") return " · ".join(parts) def alias_tooltip(model): model = model if isinstance(model, dict) else {} alias = str(model.get("alias") or "").strip() tier = model_tier(model) usage = _recommended_for_text(model) lines = [f"{tier_label(tier)}:{usage or tier_description(tier)}"] if alias: lines.append(f"cmhub 别名:{alias}") prices = price_text(model.get("prices")) if prices: lines.append(f"扣点:{prices}") return "\n".join(lines) def configured_alias_summary(alias, *, model=None): alias = str(alias or "").strip() if model is None: model = {"alias": alias} else: model = dict(model) model.setdefault("alias", alias) tier = model_tier(model) if alias: return f"cmhub 托管{tier_label(tier)},生图别名 {alias},扣点以返回结果为准" return "cmhub 托管默认档,未配置生图别名;请先到⑤设置选择生图别名" def _recommended_for_text(model): for key in ("recommended_for", "usage", "description"): value = model.get(key) if isinstance(value, (list, tuple)): text = "、".join(str(item).strip() for item in value if str(item).strip()) else: text = str(value or "").strip() if text: return text return "" def _model_text_values(model): for key in ( "alias", "display_name", "name", "label", "tier", "model_tier", "quality_tier", "recommended_tier", "recommended_for", "description", ): value = model.get(key) if isinstance(value, (list, tuple, set)): for item in value: text = str(item or "").strip() if text: yield text elif isinstance(value, dict): for item in value.values(): text = str(item or "").strip() if text: yield text else: text = str(value or "").strip() if text: yield text tags = model.get("tags") if isinstance(tags, (list, tuple, set)): for tag in tags: text = str(tag or "").strip() if text: yield text