Repository navigation
Add weighted multi-model routing #107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ffce5d4
ae1d78b
f1cf4da
d178aae
fc31327
995191c
1d82613
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,16 @@ | ||
| """Local model policies layered over account-owned upstream capabilities.""" | ||
| from contextvars import ContextVar | ||
| from math import gcd | ||
| import threading | ||
|
|
||
| from fastapi import HTTPException | ||
|
|
||
| from app import growth_tasks, perf_metrics, travel, embeddings | ||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Model metadata misstates route capabilities When a manual route list contains upstreams with different capabilities or declared prices, the published metadata is still derived from the single Publish metadata that accurately describes the route set, or reject route combinations whose capabilities and pricing cannot be represented consistently. Also at Prompt for AI agentsIn `app/model_policy.py` at line 336:
**Model metadata misstates route capabilities**
When a manual route list contains upstreams with different capabilities or declared prices, the published metadata is still derived from the single `upstream` model, while manual routes can name models with different capabilities or prices. Clients therefore receive capability and pricing details that do not describe all routes the gateway may select, and can make requests that fail on the selected route.
Publish metadata that accurately describes the route set, or reject route combinations whose capabilities and pricing cannot be represented consistently.
Also at `app/model_policy.py:415`. |
||
| 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] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High · Usable fallback models appear unavailable
When the primary upstream has no eligible account but another enabled route is servable,
public_detailsand the admin preview or inventory check credentials and metadata against onlyrule['upstream_id'], so they omit the model or report it unavailable despite a usable route; published pricing can also describe the wrong upstream.Evaluate route availability and published metadata across the configured routes instead of only the primary upstream.
Also at
app/gateway_management.py:378-380.Prompt for AI agents