diff --git a/app/control_store.py b/app/control_store.py index d806f4a..219dd50 100644 --- a/app/control_store.py +++ b/app/control_store.py @@ -51,12 +51,46 @@ def buddy_claim_reserved(record): def validate_model(source, rule, models=None, known_models=(), *, legacy_scopes=False): _identifier(source, "模型规则 ID") - if not isinstance(rule, dict) or set(rule) - {"public_id", "upstream_id", "custom", "enabled", "keep_original", "region", "profile", "credential_ids", "credits_override"}: + if not isinstance(rule, dict) or set(rule) - {"public_id", "upstream_id", "routes", "custom", "enabled", "keep_original", "region", "profile", "credential_ids", "credits_override"}: raise ValueError("模型规则字段无效") clean = {"public_id": source, "upstream_id": source, "custom": False, "enabled": True, "keep_original": False, "region": None, "profile": None, "credential_ids": [], **rule} _identifier(clean["upstream_id"], "上游模型 ID") + routes = clean.get("routes") + if routes is not None: + if not isinstance(routes, list) or not routes or len(routes) > 100: + raise ValueError("routes 必须是 1–100 条路由列表") + normalized_routes = [] + seen_routes = set() + weighted = False + for route in routes: + if not isinstance(route, dict) or set(route) - {"upstream_id", "weight", "enabled", "source"}: + raise ValueError("路由字段无效") + route_id = route.get("upstream_id") + _identifier(route_id, "路由上游模型 ID") + if route_id in seen_routes: + raise ValueError("路由上游模型重复") + seen_routes.add(route_id) + enabled = route.get("enabled", True) + if type(enabled) is not bool: + raise ValueError("路由 enabled 必须为布尔值") + source_kind = route.get("source", "manual") + if source_kind not in ("builtin", "cloud", "manual"): + raise ValueError("路由 source 无效") + weight = route.get("weight") + if weight is not None: + if isinstance(weight, bool) or not isinstance(weight, (int, float)) \ + or not math.isfinite(weight) or weight < 0 or weight > 1000: + raise ValueError("路由 weight 必须是 0–1000 的有限数字") + weight = float(weight) + weighted = True + normalized_routes.append({"upstream_id": route_id, "weight": weight, + "enabled": enabled, "source": source_kind}) + if weighted and not any((route["weight"] or 0) > 0 and route["enabled"] + for route in normalized_routes): + raise ValueError("加权路由至少需要一条正权重路线") + clean["routes"] = normalized_routes if type(clean["custom"]) is not bool or (clean["custom"] and clean["keep_original"]): raise ValueError("自建模型不能公开内部规则标识") _identifier(clean["public_id"], "公开模型 ID") diff --git a/app/gateway_management.py b/app/gateway_management.py index fb14ddf..fc6c2e3 100644 --- a/app/gateway_management.py +++ b/app/gateway_management.py @@ -372,9 +372,12 @@ def admin_model_inventory(self): rates = model_rates.details(self.gateway, upstream, sorted(served.get(upstream, ()))) for profile, value in (declared.get(upstream) or {}).items(): rates[profile]["declared"] = value - result.append({**target, **metadata, **prices, **rule, "id": source, "rate_details": rates, - "available_credentials": len(preview["candidates"]), - "available": bool(preview["candidates"])}) + row = {**target, **metadata, **prices, **rule, "id": source, "rate_details": rates, + "available_credentials": len(preview["candidates"]), + "available": bool(preview["candidates"])} + if "routes" not in rule and source in (self.CONFIG.get("model_route_groups") or {}): + row["routes"] = self.CONFIG["model_route_groups"][source] + result.append(row) return sorted(result, key=lambda row: row["id"]) def admin_rate_probe_model(self, source): diff --git a/app/media_store.py b/app/media_store.py index d11c02a..f449f82 100644 --- a/app/media_store.py +++ b/app/media_store.py @@ -114,13 +114,13 @@ def _create_schema(self): model TEXT, created REAL NOT NULL, updated REAL NOT NULL, status TEXT NOT NULL, media_id TEXT, error TEXT, credit REAL, public_model TEXT, api_key TEXT); CREATE INDEX IF NOT EXISTS tasks_created ON tasks(created); - CREATE INDEX IF NOT EXISTS tasks_api_key ON tasks(api_key, created); """) def _migrate_schema(self): """Apply additive task metadata and remove the obsolete asset expiry column.""" version = self._db.execute("PRAGMA user_version").fetchone()[0] if version >= SCHEMA_VERSION: + self._db.execute("CREATE INDEX IF NOT EXISTS tasks_api_key ON tasks(api_key, created)") return if version < 1: columns = {row[1] for row in self._db.execute("PRAGMA table_info(assets)").fetchall()} diff --git a/app/model_policy.py b/app/model_policy.py index 2ce5e5b..ed26309 100644 --- a/app/model_policy.py +++ b/app/model_policy.py @@ -1,5 +1,7 @@ """Local model policies layered over account-owned upstream capabilities.""" from contextvars import ContextVar +from math import gcd +import threading from fastapi import HTTPException @@ -7,6 +9,8 @@ from app.model_capabilities import route_metadata from app.api_keys import api_principal _request_policy = ContextVar("gateway_model_policy", default=None) +_route_counter_lock = threading.Lock() +_route_counters = {} def _principal(): @@ -126,13 +130,87 @@ def rule_for(config, source, policy=None): return {**default_rule(source), **_policy(config, policy)["models"].get(source, {})} -def source_for(config, name): +def _route_candidates_for_rule(rule: dict, auto_routes=None, counter_key=None) -> list[str]: + routes = rule.get("routes") + if not isinstance(routes, list) and isinstance(auto_routes, list): + routes = auto_routes + if not isinstance(routes, list): + return [rule["upstream_id"]] + enabled = [route for route in routes if route.get("enabled", True)] + if not enabled: + return [] + weighted = any(route.get("weight") is not None for route in enabled) + if not weighted: + return [route["upstream_id"] for route in enabled] + weights = [route.get("weight") if route.get("weight") is not None else 1.0 for route in enabled] + scale = 1000 + while scale < 1_000_000 and any(abs(weight * scale - round(weight * scale)) > 1e-9 for weight in weights): + scale *= 10 + units = [max(0, int(round(weight * scale))) for weight in weights] + total = sum(units) + if total <= 0: + return [] + key = counter_key or rule.get("public_id") or rule.get("upstream_id") + step = units[0] + for unit in units[1:]: + step = gcd(step, unit) + with _route_counter_lock: + offset = (_route_counters.get(key, 0) * step) % total + _route_counters[key] = _route_counters.get(key, 0) + 1 + cursor = 0 + selected = 0 + for index, weight in enumerate(units): + cursor += weight + if offset < cursor: + selected = index + break + return [enabled[selected]["upstream_id"]] + [route["upstream_id"] for i, route in enumerate(enabled) if i != selected] + + +def route_candidates(config, public_model: str, policy=None) -> list[str]: + """Return enabled upstream candidates, preserving order or applying route weights.""" + state = _request_policy.get() + if state and public_model in (state.get("public_model"), state.get("upstream")): + return list(state.get("route_candidates") or ()) + source = source_for(config, public_model, policy) + return _route_candidates_for_rule(rule_for(config, source, policy), + (config.get("model_route_groups") or {}).get(source), + counter_key=public_model) + + +def route_ids(config, public_model: str, policy=None) -> list[str]: + """Return all enabled route IDs without applying weighted selection.""" + source = source_for(config, public_model, policy) + rule = rule_for(config, source, policy) + routes = rule.get("routes") + if not isinstance(routes, list): + routes = (config.get("model_route_groups") or {}).get(source) + if not isinstance(routes, list): + return [rule["upstream_id"]] + return [route["upstream_id"] for route in routes if route.get("enabled", True)] + + +def bind_route(config, upstream: str, index: int | None = None) -> None: + """Bind a retry attempt to one upstream while retaining the aggregate policy rule.""" + state = _request_policy.get() + if state is not None: + state["upstream"] = upstream + if index is not None: + state["route_index"] = index + + +def source_for(config, name, policy=None): """Resolve a public model name or automatic alias to its policy source.""" if name == "default-model": return "auto" if name in (embeddings.MODEL_ID, embeddings.PUBLIC_MODEL_ID): return embeddings.MODEL_ID - data = snapshot(config) + data = policy.data if policy is not None else snapshot(config) + aliases = config.get("model_route_aliases") or {} + if name in aliases: + saved = data.get("models", {}).get(name) or {} + if not saved.get("custom") and not isinstance(saved.get("routes"), list): + return aliases[name] for source, rule in data.get("models", {}).items(): if rule.get("public_id", source) == name: return source @@ -180,9 +258,10 @@ def resolve(config, public_model): return state["upstream"] data = snapshot(config) source = next((key for key, value in data["models"].items() - if value.get("public_id", key) == public_model), - embeddings.MODEL_ID if public_model in (embeddings.MODEL_ID, embeddings.PUBLIC_MODEL_ID) - else public_model) + if value.get("public_id", key) == public_model), None) + if source is None: + source = (embeddings.MODEL_ID if public_model in (embeddings.MODEL_ID, embeddings.PUBLIC_MODEL_ID) + else (config.get("model_route_aliases") or {}).get(public_model, public_model)) rule = {**default_rule(source), **data["models"].get(source, {})} if principal is not None and principal.models is not None and _restriction_source(source) not in principal.models: raise HTTPException(status_code=403, detail={"error": { @@ -192,12 +271,17 @@ def resolve(config, public_model): _deny("模型已停用") if source == public_model and (rule["custom"] or (rule["public_id"] != source and not rule["keep_original"])): _deny("原模型 ID 已停用,请使用已配置的对外 ID", "model_renamed") - if state is None and rule["upstream_id"] != source: + candidates = _route_candidates_for_rule(rule, (config.get("model_route_groups") or {}).get(source), + counter_key=public_model) + if state is None and (not candidates or candidates[0] != source) and rule["upstream_id"] != source: raise HTTPException(status_code=503, detail={"error": {"message": "模型路由上下文未初始化", "type": "server_error", "code": "model_policy_context_missing"}}) + if not candidates: + _deny("模型路由已全部停用") if state is not None: - state.update(source=source, public_model=public_model, upstream=rule["upstream_id"], rule=rule) - return rule["upstream_id"] + state.update(source=source, public_model=public_model, upstream=candidates[0], + route_candidates=candidates, route_index=0, rule=rule) + return candidates[0] def check_resolved(config, upstream): @@ -257,11 +341,18 @@ def public_details(gateway, region=None): entries = pool.entries() if pool is not None else [] out = [] for source in dict.fromkeys([*known, *policies]): + if source in (config.get("model_route_aliases") or {}): + saved = policies.get(source) or {} + if not saved.get("custom") and not isinstance(saved.get("routes"), list): + continue rule = rule_for(config, source, policy) upstream = rule["upstream_id"] - item = known.get(upstream) + route_names = route_ids(config, source, policy) + item = next((known.get(route) for route in route_names if known.get(route) is not None), None) if not rule["enabled"] or item is None: continue + if not route_names: + continue media = bool(item.get("media_kinds")) embedding = bool(item.get("embedding")) reranking = bool(item.get("rerank")) @@ -301,9 +392,10 @@ def public_details(gateway, region=None): and route_allowed(config, entry, upstream, rule=rule, policy=policy)): candidates = [entry] else: - candidates = [entry for entry in entries if route_allowed( - config, entry, upstream, rule=rule, policy=policy) - and pool._eligible(entry, upstream, region=region)] + candidates = [entry for entry in entries if any( + route_allowed(config, entry, route, rule=rule, policy=policy) + and pool._eligible(entry, route, region=region, rule=rule, policy=policy) + for route in route_names)] finally: _request_policy.reset(token) if not candidates and (pool is not None or media or embedding or reranking): @@ -313,16 +405,18 @@ def public_details(gateway, region=None): if accounts is not None: for entry in candidates: profile = entry.get("profile") - for model in gateway._published_account_scope( - accounts.get(entry.get("account_key")) or {}, "serves", model_id=gateway._upstream_model(upstream, profile)) or []: - if model.get("id") == gateway._upstream_model(upstream, profile): - price = gateway._multiplier_value(model.get("credits")) - if price is not None and price <= prices.get(profile, price): - prices[profile] = price - if model.get("credits_source") == "measured": - measured.add(profile) - else: - measured.discard(profile) + for route in route_names: + route_id = gateway._upstream_model(route, profile) + for model in gateway._published_account_scope( + accounts.get(entry.get("account_key")) or {}, "serves", model_id=route_id) or []: + if model.get("id") == route_id: + price = gateway._multiplier_value(model.get("credits")) + if price is not None and price <= prices.get(profile, price): + prices[profile] = price + if model.get("credits_source") == "measured": + measured.add(profile) + else: + measured.discard(profile) else: allowed_profiles = {entry.get("profile") for entry in candidates} prices = {profile: price for profile, price in item["credits_by_profile"].items() @@ -337,6 +431,8 @@ def public_details(gateway, region=None): best = min(prices.values(), default=None) row = {"id": name, "credits": best, "credits_by_profile": prices, **route_metadata(gateway, upstream, entries=candidates, rule=rule, region=region)} + if source in (config.get("model_route_groups") or {}) and "routes" not in rule: + row["routes"] = config["model_route_groups"][source] for key in ("media_kinds", "media_sources", "media_defaults", "embedding", "rerank"): if key in item: row[key] = item[key] diff --git a/converter.py b/converter.py index e2e894b..23ab2ad 100644 --- a/converter.py +++ b/converter.py @@ -2249,6 +2249,18 @@ def _publish_model_cache(): CONFIG["account_catalogs"] = accounts CONFIG["model_catalogs"] = catalogs CONFIG["models_remote"], CONFIG["models_intl"] = catalogs["cn-cli"], catalogs["intl-cli"] + route_groups, route_aliases = _build_catalog_route_groups(accounts) + for alias, canonical in list(route_aliases.items()): + saved = model_policy.snapshot(CONFIG)["models"].get(alias, {}) + if saved.get("custom") or isinstance(saved.get("routes"), list): + route_aliases.pop(alias, None) + continue + if saved.get("enabled") is False: + for route in route_groups.get(canonical, []): + if route["upstream_id"] == alias: + route["enabled"] = False + CONFIG["model_route_groups"] = route_groups + CONFIG["model_route_aliases"] = route_aliases invalidate_model_table() # Drop the stale table first, then install the fresh index. _publish_catalog_index(accounts) @@ -2258,6 +2270,66 @@ def _account_catalog_key(profile, headers): headers.get("X-Enterprise-Id"))) +def _catalog_model_signature(model: dict, region: str): + """Return the conservative signature used for automatic same-cost variant grouping.""" + if not isinstance(model, dict) or model.get("supportsToolCall") is not True: + return None + model_id = model.get("id") + if not isinstance(model_id, str) or model_id in {"auto", "default-model"} \ + or model_id.startswith("custom"): + return None + tags = tuple(sorted(tag for tag in (model.get("tags") or []) + if isinstance(tag, str) and not tag.startswith("badge:"))) + if {"text-to-image", "image-to-image", "text-to-video", "image-to-video"}.intersection(tags): + return None + credits = model.get("credits") + if not isinstance(credits, str) or not credits.strip(): + return None + reasoning = model.get("reasoning") + reasoning = json.dumps(reasoning, sort_keys=True, ensure_ascii=False) if isinstance(reasoning, dict) else reasoning + context = model.get("contextWindow") + context = json.dumps(context, sort_keys=True, ensure_ascii=False) if isinstance(context, dict) else context + return (region, str(model.get("name") or model_id).strip().casefold(), credits.strip(), + model.get("maxInputTokens"), model.get("maxOutputTokens"), context, + model.get("supportsImages"), model.get("supportsReasoning"), + model.get("supportsToolCall"), reasoning, tags) + + +def _build_catalog_route_groups(accounts: dict) -> tuple[dict, dict]: + """Group cloud variants only when their declared cost and capability signature match.""" + grouped = {} + for account in accounts.values(): + profile = account.get("profile") + region = profile_region(profile) if profile else None + if region is None: + continue + for model in account.get("serves") or account.get("models") or []: + signature = _catalog_model_signature(model, region) + if signature is not None: + grouped.setdefault(signature, []).append(model["id"]) + routes = {key: [dict(route) for route in values] + for key, values in BUILTIN_MODEL_ROUTE_GROUPS.items()} + aliases = {route["upstream_id"]: key + for key, values in routes.items() for route in values if route["upstream_id"] != key} + cloud_signatures = {} + for signature, ids in grouped.items(): + ordered = list(dict.fromkeys(ids)) + if len(ordered) < 2: + continue + canonical = ordered[0] + if canonical in cloud_signatures and cloud_signatures[canonical] != signature: + continue + cloud_signatures[canonical] = signature + cloud_routes = [{"upstream_id": model_id, "weight": None, + "enabled": True, "source": "cloud"} for model_id in ordered] + existing = {route["upstream_id"] for route in routes.get(canonical, [])} + routes.setdefault(canonical, []).extend(route for route in cloud_routes + if route["upstream_id"] not in existing) + for model_id in ordered[1:]: + aliases[model_id] = canonical + return routes, aliases + + def _fetch_catalog(headers): """Fetch both catalog scopes for one account token; the only upstream call involved.""" with _endpoint_semaphore("models"): @@ -2822,18 +2894,29 @@ def _housekeeper_loop(pool: CredentialPool, ledger) -> None: # Fallback models for legacy domestic deployments without a cloud catalog. DEFAULT_MODELS = [ - "hy4-preview", "hy4-preview-x", + "hy4-preview", "hy4-preview-f", "hy4-preview-x", "hy3", "hy3-x", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4.1-flash", "deepseek-v3-2-volc", "glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5.0", "glm-5.0-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.6", "glm-4.6v", "minimax-m3", "minimax-m2.7", "minimax-m2.5", - "kimi-k3-1", "kimi-k2.7", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", + "kimi-k3", "kimi-k3-1", "kimi-k2.7", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "hunyuan-chat", "default", "auto", # Backend-selected model alias ] - +# Built-in variants that share one public route in legacy deployments. +BUILTIN_MODEL_ROUTE_GROUPS = { + "hy4-preview": [ + {"upstream_id": "hy4-preview", "weight": None, "enabled": True, "source": "builtin"}, + {"upstream_id": "hy4-preview-f", "weight": None, "enabled": True, "source": "builtin"}, + {"upstream_id": "hy4-preview-x", "weight": None, "enabled": True, "source": "builtin"}, + ], + "kimi-k3": [ + {"upstream_id": "kimi-k3", "weight": None, "enabled": True, "source": "builtin"}, + {"upstream_id": "kimi-k3-1", "weight": None, "enabled": True, "source": "builtin"}, + ], +} # Supported optional upstream request fields. PASSTHROUGH_BODY_KEYS = { "model", "messages", "tools", "tool_choice", "temperature", @@ -2906,6 +2989,11 @@ async def _protocol_http_exception(request: Request, exc: HTTPException): "desensitize": False, "no_compact": False, "keep_tool_metadata": False, "prompt_mode": "empty"} # None prices use module defaults. +CONFIG.setdefault("model_route_groups", BUILTIN_MODEL_ROUTE_GROUPS) +CONFIG.setdefault("model_route_aliases", { + "hy4-preview-f": "hy4-preview", "hy4-preview-x": "hy4-preview", "kimi-k3-1": "kimi-k3" +}) + # In-memory OAuth sessions do not survive restarts. _OAUTH = auth_oauth.OAuthManager(user_agent=USER_AGENT) @@ -3695,10 +3783,12 @@ def _model_profiles(model: str | None, region: str | None = None, configured=Non profiles = {profile for profile in PROFILE_ENDPOINTS if _in_region(profile, region)} if not model: return profiles + route_ids = model_policy.route_ids(CONFIG, model, policy) supported = {profile for profile in profiles - if any(item["id"] == _upstream_model(model, profile) + if any(item["id"] == _upstream_model(route, profile) + for route in route_ids for item in _models_for_profile(profile, configured, scope="serves", - model_id=_upstream_model(model, profile), policy=policy))} + model_id=_upstream_model(route, profile), policy=policy))} if model == "auto" and region == "cn": # WorkBuddy uses its advertised Auto; legacy CLI defaults remain separate. if "cn-work" in configured and "cn-work" in supported: @@ -3949,7 +4039,12 @@ def _prepare_chat_body(body: dict, *, region=None, session_payload=None) -> dict if session_payload is not None: _bind_request_session(session_payload, body) body = dict(body) - body["model"] = model_policy.resolve(CONFIG, body.get("model", "auto")) + public_model = body.get("model", "auto") + body["model"] = model_policy.resolve(CONFIG, public_model) + route_candidates = model_policy.route_candidates(CONFIG, public_model) + if len(route_candidates) > 1: + body["_route_candidates"] = route_candidates + body["_route_index"] = 0 guard_model(body["model"], region=region, resolved=True) messages = body.get("messages") if not isinstance(messages, list) or not messages or any(not isinstance(message, dict) for message in messages): @@ -4039,7 +4134,7 @@ def list_models(request: Request, authorization: Optional[str] = Header(default= data = [{"id": item["id"], "object": "model", "created": 1700000000, "owned_by": "codebuddy", "credits": item["credits"], "credits_by_profile": item["credits_by_profile"], **{key: item[key] for key in ("credits_estimated", "capabilities", "limits", "metadata_by_profile", - "media_kinds", "media_sources", "media_defaults", "embedding", "rerank") + "media_kinds", "media_sources", "media_defaults", "embedding", "rerank", "routes") if key in item}} for item in model_policy.public_details(sys.modules[__name__]) if principal is None or principal.models is None @@ -4260,6 +4355,10 @@ async def create_embeddings(request: Request, requested_model = payload.get("model") model = model_policy.resolve(CONFIG, requested_model) model_policy.check_resolved(CONFIG, model) + if len(model_policy.route_ids(CONFIG, requested_model)) > 1: + raise HTTPException(status_code=400, detail={"error": { + "message": "embedding 模型暂不支持多模型路由,请为该模型保留单一路线", + "type": "invalid_request_error", "code": "multi_route_not_supported", "param": "model"}}) if not embeddings.is_embedding_model(model): raise HTTPException(status_code=400, detail={"error": { "message": f"模型 {model} 不是 embedding 模型,请选择 /v1/models 中的向量模型", @@ -4367,6 +4466,10 @@ async def create_rerank(request: Request, requested_model = payload.get("model") or rerank.MODEL_ID model = model_policy.resolve(CONFIG, requested_model) model_policy.check_resolved(CONFIG, model) + if len(model_policy.route_ids(CONFIG, requested_model)) > 1: + raise HTTPException(status_code=400, detail={"error": { + "message": "rerank 模型暂不支持多模型路由,请为该模型保留单一路线", + "type": "invalid_request_error", "code": "multi_route_not_supported", "param": "model"}}) if not rerank.is_rerank_model(model): raise HTTPException(status_code=400, detail={"error": { "message": f"模型 {model} 不是重排序模型,请使用 reranker", @@ -4441,7 +4544,8 @@ async def chat_completions(request: Request, + (f" | last_user={_truncate(last_user, 60)!r}" if last_user else "")) # Credential selection and refresh perform blocking file and network I/O. prepared = body # Keep canonical input for failover policy checks. - body, cred, headers, url = await run_in_threadpool(_route_chat, payload, body, rid) + body, attempt = await run_in_threadpool(_route_chat_with_routes, payload, body, rid) + body, cred, headers, url = attempt _log_json(f"[{rid}] REQUEST BODY (发往后端,预览)", body) t0 = time.time() @@ -5300,6 +5404,46 @@ def _failover_limit() -> int: return int(CONFIG.get("failover_max") or 0) +def _next_route_body(canonical): + """Advance a prepared request to its next enabled model route, if any.""" + if not isinstance(canonical, dict): + return None + candidates = canonical.get("_route_candidates") + index = canonical.get("_route_index", 0) + if not isinstance(candidates, list) or not isinstance(index, int) or index + 1 >= len(candidates): + return None + return {**canonical, "model": candidates[index + 1], "_route_index": index + 1} + + +def _route_retry_attempt(payload, canonical, rid): + """Try the next configured model route without carrying account exclusions across routes.""" + candidate = _next_route_body(canonical) + while candidate is not None: + try: + model_policy.bind_route(CONFIG, candidate["model"], candidate["_route_index"]) + return candidate, _route_chat(payload, candidate, rid) + except HTTPException: + candidate = _next_route_body(candidate) + return None, None + + +def _route_chat_with_routes(payload, body, rid): + """Select the first route with an eligible account, skipping preflight failures.""" + candidate = body + last_error = None + while candidate is not None: + try: + model_policy.bind_route(CONFIG, candidate.get("model"), candidate.get("_route_index")) + return candidate, _route_chat(payload, candidate, rid) + except HTTPException as error: + last_error = error + candidate = _next_route_body(candidate) + if last_error is not None: + raise last_error + raise HTTPException(status_code=503, detail={"error": { + "message": "所有模型路线均不可用", "type": "service_unavailable", "code": "routes_unavailable"}}) + + async def _stream_plan(payload, canonical, model_name, rid, t0, make, routed, cred, headers, url): """Prefetch with bounded credential failover, using canonical input to preserve routing restrictions.""" tried = [] @@ -5316,7 +5460,15 @@ async def _stream_plan(payload, canonical, model_name, rid, t0, make, routed, cr limit = _failover_limit() surface = HTTPException(status_code=failure.status, headers=failure.headers, detail=_safe_err_raw(failure.raw, failure.status)) - if limit <= 0 or len(tried) > limit or not _failover_safe(failure.error, failure.raw): + safe = _failover_safe(failure.error, failure.raw) + if safe: + next_body, attempt = await run_in_threadpool(_route_retry_attempt, payload, canonical, rid) + if attempt is not None: + canonical = next_body + routed, cred, headers, url = attempt + _log(f"[{rid}] ↻ 切换模型路由 | {model_name} → {routed.get('model')}") + continue + if limit <= 0 or len(tried) > limit or not safe: raise surface from None try: attempt = await run_in_threadpool(_route_chat, payload, canonical, rid, @@ -5366,7 +5518,16 @@ async def _routed_fetch(payload, canonical, model_name, rid, t0, fetch, routed, limit = _failover_limit() surface = HTTPException(status_code=status, detail=_safe_err_raw(raw, status), headers=error.headers if isinstance(error, UpstreamHTTPError) else None) - if limit <= 0 or len(tried) > limit or not _failover_safe(error, raw): + safe = _failover_safe(error, raw) + if safe: + next_body, attempt = await run_in_threadpool(_route_retry_attempt, payload, canonical, rid) + if attempt is not None: + canonical = next_body + routed, cred, headers, url = attempt + tried = [] + _log(f"[{rid}] ↻ 切换模型路由 | {model_name} → {routed.get('model')}") + continue + if limit <= 0 or len(tried) > limit or not safe: raise surface from None try: attempt = await run_in_threadpool(_route_chat, payload, canonical, rid, @@ -6163,7 +6324,8 @@ async def create_response(request: Request, ) # Keep blocking credential selection and refresh off the event loop. prepared = chat_body # Preserve canonical input for routing policy checks. - chat_body, cred, headers, url = await run_in_threadpool(_route_chat, payload, chat_body, rid) + chat_body, attempt = await run_in_threadpool(_route_chat_with_routes, payload, chat_body, rid) + chat_body, cred, headers, url = attempt _log_json(f"[{rid}] RESPONSES → CHAT BODY (预览)", chat_body) t0 = time.time() @@ -6403,7 +6565,8 @@ async def create_message(request: Request, _log(f"[{rid}] ▶ ANTHROPIC {model_name} | msgs={len(chat_messages)} | anthropic_msgs={len(messages)}") # Keep blocking credential selection and refresh off the event loop. prepared = chat_body # Preserve canonical input for routing policy checks. - chat_body, cred, headers, url = await run_in_threadpool(_route_chat, payload, chat_body, rid) + chat_body, attempt = await run_in_threadpool(_route_chat_with_routes, payload, chat_body, rid) + chat_body, cred, headers, url = attempt _log_json(f"[{rid}] ANTHROPIC → CHAT BODY (预览)", chat_body) t0 = time.time() diff --git a/docs/advanced.md b/docs/advanced.md index 8fe46f1..5cc7785 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -238,6 +238,10 @@ The root table is not a guarantee that a backend serves every listed model; meas Beyond standard model fields, `credits` is the lowest source multiplier: `0.0` identifies a zero-multiplier source and `null` means no parseable multiplier was declared. `credits_by_profile` provides source details, such as `{"intl-work":0.0,"cn-cli":0.03}`. Compatible clients may ignore these fields; multipliers are not guaranteed to stay unchanged. +Model rules may include an ordered `routes` list. Each entry has an `upstream_id`, optional `weight`, `enabled` and `source` (`builtin`, `cloud` or `manual`). Empty weights use top-to-bottom failover; weights use weighted selection before the normal account scheduler. Disabled entries are skipped, and an all-disabled list makes the public model unavailable. Existing rules without `routes` keep their single `upstream_id` behavior. + +Cloud catalog variants are grouped automatically only when their region, declared multiplier, context, output, image/reasoning/tool support and reasoning metadata match. A saved manual `routes` list takes precedence over that automatic group. + Credential domain / token issuer determine the product identity. Chat and refresh use fixed origins with separate product headers: | Profile | Chat / refresh origin | diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index d97026d..d91da24 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -239,6 +239,10 @@ WebUI 可以直接上传文件;以下限制针对 `POST /admin/credentials` 标准模型字段之外,`credits` 是各来源最低倍率:`0.0` 表示该来源零倍率,`null` 表示未声明可解析倍率。`credits_by_profile` 提供来源明细,例如 `{"intl-work":0.0,"cn-cli":0.03}`;兼容客户端可忽略这些扩展字段,倍率不保证永久不变。 +模型规则可以增加有序的 `routes` 列表。每项包含 `upstream_id`、可选 `weight`、`enabled` 和 `source`(`builtin`、`cloud` 或 `manual`)。权重都留空时按列表顺序故障切换;填写权重时先按权重选路,再进入原有账号调度。关闭的路线会跳过,全部关闭时公开模型不可用。没有 `routes` 的旧规则继续使用单个 `upstream_id`。 + +云端目录变体只有在区域、声明倍率、上下文、输出、图片/思考/工具能力及思考元数据都一致时才会自动合并。已保存的手工 `routes` 列表优先于自动分组。 + 凭据 domain / token issuer 决定产品身份,聊天与刷新使用各自固定入口及独立产品头: | Profile | 聊天 / 刷新入口 | diff --git a/tests/test_weighted_routes.py b/tests/test_weighted_routes.py new file mode 100644 index 0000000..87c295d --- /dev/null +++ b/tests/test_weighted_routes.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Model route lists, weights and independent route enable switches.""" + +import sys +from pathlib import Path +from unittest import TestCase + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app import model_policy +from app.control_store import validate_model +import converter + + +class Store: + def __init__(self, models): + self.data = {"models": models, "credentials": {}, "revision": 1} + + def snapshot(self): + return self.data + + +def config(rule): + return {"control_store": Store({"aggregate": rule})} + + +class WeightedRouteTests(TestCase): + def test_legacy_rule_keeps_one_upstream_route(self): + self.assertEqual(model_policy.route_candidates( + {"control_store": Store({"public": {"public_id": "public", "upstream_id": "legacy"}})}, + "public"), ["legacy"]) + + def test_ordered_routes_skip_disabled_entries(self): + rule = {"public_id": "aggregate", "upstream_id": "first", "routes": [ + {"upstream_id": "first", "enabled": False, "source": "builtin"}, + {"upstream_id": "second", "enabled": True, "source": "cloud"}, + {"upstream_id": "third", "enabled": True, "source": "manual"}, + ]} + self.assertEqual(model_policy.route_candidates(config(rule), "aggregate"), ["second", "third"]) + + def test_weighted_routes_select_a_weighted_first_candidate(self): + rule = {"public_id": "weighted-ratio", "upstream_id": "first", "routes": [ + {"upstream_id": "first", "weight": 3}, + {"upstream_id": "second", "weight": 1}, + ]} + seen = [model_policy.route_candidates(config(rule), "weighted-ratio")[0] for _ in range(40)] + self.assertEqual(seen.count("first"), 30) + self.assertEqual(seen.count("second"), 10) + self.assertEqual(set(seen), {"first", "second"}) + + def test_all_disabled_routes_are_unavailable(self): + rule = {"public_id": "aggregate", "upstream_id": "first", "routes": [ + {"upstream_id": "first", "enabled": False}, + {"upstream_id": "second", "enabled": False}, + ]} + self.assertEqual(model_policy.route_candidates(config(rule), "aggregate"), []) + + def test_validation_preserves_route_order_and_source(self): + rule = validate_model("aggregate", { + "public_id": "aggregate", "upstream_id": "first", "routes": [ + {"upstream_id": "first", "source": "builtin", "enabled": False}, + {"upstream_id": "second", "source": "cloud", "weight": 2}, + ]}, known_models=["first", "second"]) + self.assertEqual([item["upstream_id"] for item in rule["routes"]], ["first", "second"]) + self.assertFalse(rule["routes"][0]["enabled"]) + self.assertEqual(rule["routes"][0]["source"], "builtin") + self.assertEqual(rule["routes"][1]["source"], "cloud") + self.assertEqual(rule["routes"][1]["weight"], 2.0) + + def test_validation_rejects_zero_weight_only_and_duplicate_routes(self): + for routes in ( + [{"upstream_id": "first", "weight": 0}], + [{"upstream_id": "first", "weight": 2, "enabled": False}, + {"upstream_id": "second", "weight": 0, "enabled": True}], + [{"upstream_id": "first"}, {"upstream_id": "first"}], + ): + with self.subTest(routes=routes), self.assertRaises(ValueError): + validate_model("aggregate", {"upstream_id": "first", "routes": routes}, + known_models=["first"]) + + def test_catalog_variants_with_same_cost_and_capabilities_are_grouped(self): + groups, aliases = converter._build_catalog_route_groups({"account": { + "profile": "cn-cli", "serves": [ + {"id": "hy4-preview", "name": "Hy4 preview", "credits": "x0.29", + "maxInputTokens": 960000, "maxOutputTokens": 64000, + "supportsImages": True, "supportsReasoning": True, "supportsToolCall": True}, + {"id": "hy4-preview-x", "name": "Hy4 preview", "credits": "x0.29", + "maxInputTokens": 960000, "maxOutputTokens": 64000, + "supportsImages": True, "supportsReasoning": True, "supportsToolCall": True}, + {"id": "hy4-preview-paid", "name": "Hy4 preview", "credits": "x0.30", + "maxInputTokens": 960000, "maxOutputTokens": 64000, + "supportsImages": True, "supportsReasoning": True, "supportsToolCall": True}, + ]}}) + self.assertEqual([route["upstream_id"] for route in groups["hy4-preview"]], + ["hy4-preview", "hy4-preview-f", "hy4-preview-x"]) + self.assertEqual(aliases, { + "hy4-preview-f": "hy4-preview", "hy4-preview-x": "hy4-preview", "kimi-k3-1": "kimi-k3" + }) + + +if __name__ == "__main__": + import unittest + + unittest.main(verbosity=2) diff --git a/web/src/api.ts b/web/src/api.ts index 3b0e401..47b0e63 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -246,10 +246,17 @@ export function useResource( reload, }; } +export type ModelRoute = { + upstream_id: string; + weight: number | null; + enabled: boolean; + source: "builtin" | "cloud" | "manual"; +}; export type ModelRule = { id: string; public_id: string; upstream_id?: string; + routes?: ModelRoute[]; custom?: boolean; available?: boolean; enabled: boolean; @@ -310,6 +317,17 @@ export function modelResponse(value: unknown): { region: typeof m.region === "string" ? m.region : "", profile: typeof m.profile === "string" ? m.profile : "", credential_ids: m.credential_ids as string[], + routes: Array.isArray(m.routes) + ? (m.routes.filter( + (route): route is RecordValue => + !!route && + typeof route === "object" && + !Array.isArray(route) && + typeof route.upstream_id === "string" && + typeof route.enabled === "boolean" && + (route.weight === null || typeof route.weight === "number"), + ) as ModelRoute[]) + : undefined, credits_override: rateOverride(m.credits_override), }; }); diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index db12364..6fbddc8 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -635,6 +635,18 @@ const zh = { "media.settings.save": "保存", "media.settings.saved": "媒体设置已保存", "media.settings.note": "缩小容量或时长会在下次写入或定时清理时淘汰旧资源。", + "models.routes.title": "多模型路由", + "models.routes.help": + "可按顺序或权重选择多个上游模型。留空权重时按列表从上到下尝试;关闭路线不会参与派发。", + "models.routes.label": "路由", + "models.routes.weight": "权重", + "models.routes.enabled": "启用", + "models.routes.remove": "删除", + "models.routes.add": "添加路由候选", + "models.routes.invalidEmpty": "路由列表不能为空,删除全部候选请停用模型", + "models.routes.invalidId": "路由上游 ID 必须非空且不重复", + "models.routes.invalidWeight": "路由权重必须是 0–1000 的有限数字", + "models.routes.invalidPositive": "加权路由至少需要一条正权重路线", } satisfies Record; export type MessageKey = keyof typeof zh; @@ -1288,6 +1300,20 @@ const en: Record = { "media.settings.saved": "Media settings saved", "media.settings.note": "Shrinking capacity or retention evicts older assets on the next write or cleanup.", + "models.routes.title": "Multi-model routes", + "models.routes.help": + "Choose upstream models by order or weight. Empty weights use top-to-bottom fallback; disabled routes are skipped.", + "models.routes.label": "Routes", + "models.routes.weight": "Weight", + "models.routes.enabled": "Enabled", + "models.routes.remove": "Remove", + "models.routes.add": "Add route", + "models.routes.invalidEmpty": + "The route list cannot be empty; disable the model to remove every candidate.", + "models.routes.invalidId": "Route upstream IDs must be non-empty and unique.", + "models.routes.invalidWeight": "Route weights must be finite numbers from 0 to 1000.", + "models.routes.invalidPositive": + "A weighted route list needs at least one enabled positive weight.", }; const messages: Record> = { zh, en }; diff --git a/web/src/pages/Models.tsx b/web/src/pages/Models.tsx index d94a5d5..050cd88 100644 --- a/web/src/pages/Models.tsx +++ b/web/src/pages/Models.tsx @@ -58,15 +58,38 @@ import { JobBanner, ProgressBanner, type Job } from "../jobs"; import s from "../ui.module.scss"; import { SearchInput, Select } from "../fields"; import { clearToasts, notify } from "../toast"; +import { useT } from "../i18n"; export const profiles = ["cn-cli", "cn-work", "intl-cli", "intl-work"]; const mediaKindLabels: Record = { image: "文生图", edit: "图生图", video: "视频" }; const validId = (id: string) => /^[A-Za-z0-9_.:/@-]{1,160}$/.test(id) && ![".", ".."].includes(id); -export function validateRule(rule: ModelRule, models: ModelRule[]): string | null { +export function validateRule( + rule: ModelRule, + models: ModelRule[], + translate: (key: string) => string = (key) => key, +): string | null { if (!rule.public_id.trim()) return "对外 ID 不能为空"; if (rule.public_id !== rule.public_id.trim() || /\s/.test(rule.public_id)) return "对外 ID 不能包含空白字符"; if (!validId(rule.public_id)) return "对外 ID 只允许 1–160 位字母、数字及 _ . : / @ -"; if (!validId(rule.upstream_id ?? rule.id)) return "上游 ID 不能为空,且须为有效模型标识"; + if (rule.routes) { + if (!rule.routes.length) return translate("models.routes.invalidEmpty"); + const routeIds = new Set(); + let hasWeight = false; + let hasPositive = false; + for (const route of rule.routes) { + if (!validId(route.upstream_id) || routeIds.has(route.upstream_id)) + return translate("models.routes.invalidId"); + routeIds.add(route.upstream_id); + if (route.weight !== null) { + hasWeight = true; + if (!Number.isFinite(route.weight) || route.weight < 0 || route.weight > 1000) + return translate("models.routes.invalidWeight"); + if (route.weight > 0) hasPositive = true; + } + } + if (hasWeight && !hasPositive) return translate("models.routes.invalidPositive"); + } if ( models.some( (m) => m.id !== rule.id && (m.id === rule.public_id || m.public_id === rule.public_id), @@ -222,9 +245,11 @@ export function ModelEditor({ rateProbe?: RateProbeControl; creating?: boolean; }) { + const t = useT(); const [rule, setRule] = useState({ ...model, upstream_id: model.upstream_id ?? model.id, + routes: model.routes?.map((route) => ({ ...route })), region: model.region || (!model.credential_ids.length && profiles.includes(model.profile) @@ -232,6 +257,9 @@ export function ModelEditor({ : ""), }); const [mode, setMode] = useState(() => bindingMode(model)); + const [routesTouched, setRoutesTouched] = useState( + () => model.routes?.some((route) => route.source === "manual") ?? false, + ); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [preview, setPreview] = useState(null); @@ -240,6 +268,7 @@ export function ModelEditor({ useEffect(() => () => request.current?.abort(), []); const update = (key: K, value: ModelRule[K]) => { setRule((old) => ({ ...old, [key]: value })); + if (key === "routes") setRoutesTouched(true); setPreview(null); }; const chooseMode = (next: Mode) => { @@ -256,7 +285,7 @@ export function ModelEditor({ }; const run = (save: boolean) => { const invalid = - validateRule(rule, models) ?? + validateRule(rule, models, t) ?? (mode === "accounts" && !rule.credential_ids.length ? "请选择至少一个账号" : null); if (invalid) { setError(invalid); @@ -270,6 +299,7 @@ export function ModelEditor({ revision, public_id: rule.public_id, upstream_id: rule.upstream_id, + ...(routesTouched && rule.routes?.length ? { routes: rule.routes } : {}), enabled: rule.enabled, keep_original: rule.keep_original, region: rule.region || null, @@ -353,6 +383,106 @@ export function ModelEditor({ ))} +
+ {t("models.routes.title")} +

{t("models.routes.help")}

+ {(rule.routes?.length ?? 0) > 0 && ( +
+ {rule.routes?.map((route, index) => ( +
+ + update( + "routes", + (rule.routes ?? []).map((item, i) => + i === index ? { ...item, upstream_id: e.target.value } : item, + ), + ) + } + /> + + update( + "routes", + (rule.routes ?? []).map((item, i) => + i === index + ? { + ...item, + weight: e.target.value === "" ? null : Number(e.target.value), + } + : item, + ), + ) + } + /> + + +
+ ))} +
+ )} + +
diff --git a/web/src/ui.module.scss b/web/src/ui.module.scss index fa71c01..a077a89 100644 --- a/web/src/ui.module.scss +++ b/web/src/ui.module.scss @@ -2972,6 +2972,31 @@ $white: var(--page); color: $ink; overflow-wrap: anywhere; } +.routeSummary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin-top: 5px; +} +.routeSummaryLabel { + color: $muted; + font-size: 11px; +} +.routePill { + max-width: 100%; + padding: 2px 6px; + border: 1px solid #9fbc9a; + border-radius: 999px; + color: $ink; + font-size: 10px; + overflow-wrap: anywhere; +} +.routeDisabled { + border-color: #d2b4a7; + color: $muted; + text-decoration: line-through; +} .modelMetadata { padding: 0 22px 18px; min-width: 0;