From fdc4b23d49ec63ab6e5a3845056a315a6afa3681 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?=
<277378677+maiphucgiang@users.noreply.github.com>
Date: Sun, 18 Oct 2026 12:24:40 +0800
Subject: [PATCH 01/13] Add offline routing benchmark and process counters
---
app/admin_api.py | 6 ++
app/model_policy.py | 6 +-
app/perf_metrics.py | 64 ++++++++++++++
benchmarks/bench_routing.py | 164 ++++++++++++++++++++++++++++++++++++
converter.py | 17 +++-
tests/test_perf_metrics.py | 117 +++++++++++++++++++++++++
6 files changed, 371 insertions(+), 3 deletions(-)
create mode 100644 app/perf_metrics.py
create mode 100644 benchmarks/bench_routing.py
create mode 100644 tests/test_perf_metrics.py
diff --git a/app/admin_api.py b/app/admin_api.py
index a1f064a..bd5622b 100644
--- a/app/admin_api.py
+++ b/app/admin_api.py
@@ -17,6 +17,7 @@
from starlette.responses import JSONResponse, Response
from . import auth_oauth
+from . import perf_metrics
from .admin_auth import AdminAuth, AdminMiddleware, COOKIE_NAME, SESSION_TTL, error_response, same_origin
from .control_store import ConflictError, validate_model
from .credential_io import CredentialFileError, MAX_CREDENTIAL_BYTES, _valid_name, read_import_file
@@ -687,4 +688,9 @@ def build_dashboard():
result = await run_in_threadpool(build_dashboard)
return result if isinstance(result, Response) else JSONResponse(result)
+ @route("GET", "/admin/performance")
+ async def performance_get(request):
+ """Read-only process counters; no routing state is exposed or mutated."""
+ return JSONResponse(perf_metrics.snapshot())
+
return auth
diff --git a/app/model_policy.py b/app/model_policy.py
index 89e6d7b..33d5370 100644
--- a/app/model_policy.py
+++ b/app/model_policy.py
@@ -3,7 +3,7 @@
from fastapi import HTTPException
-from app import travel
+from app import perf_metrics, travel
from app.model_capabilities import route_metadata
_request_policy = ContextVar("gateway_model_policy", default=None)
@@ -24,7 +24,9 @@ async def __call__(self, scope, receive, send):
def snapshot(config):
store = config.get("control_store")
- return store.snapshot() if store is not None else {"models": {}, "credentials": {}, "revision": 0}
+ if store is None:
+ return {"models": {}, "credentials": {}, "revision": 0}
+ return store.snapshot()
def credential_enabled(config, entry):
diff --git a/app/perf_metrics.py b/app/perf_metrics.py
new file mode 100644
index 0000000..e650091
--- /dev/null
+++ b/app/perf_metrics.py
@@ -0,0 +1,64 @@
+"""Process-wide counters and timings for routing and maintenance hot paths."""
+from __future__ import annotations
+
+import threading
+import time
+from contextlib import contextmanager
+
+_COUNTERS: dict[str, int] = {}
+_TIMINGS: dict[str, dict[str, float]] = {}
+_LOCK = threading.Lock()
+_STARTED = time.monotonic()
+
+
+def inc(name: str, amount: int = 1) -> None:
+ """Count one event; a single dict update stays cheap enough for the request path."""
+ _COUNTERS[name] = _COUNTERS.get(name, 0) + amount
+
+
+def observe(name: str, seconds: float) -> None:
+ """Record one duration sample, creating the series on first use."""
+ try:
+ sample = max(0.0, float(seconds))
+ except (TypeError, ValueError):
+ return
+ stats = _TIMINGS.get(name)
+ if stats is None:
+ _TIMINGS[name] = {"count": 1.0, "total_s": sample, "max_s": sample, "min_s": sample}
+ return
+ # Samples only ever add to these fields, so a lost update costs one sample, never an error.
+ stats["count"] += 1
+ stats["total_s"] += sample
+ stats["max_s"] = max(stats["max_s"], sample)
+ stats["min_s"] = min(stats["min_s"], sample)
+
+
+@contextmanager
+def timed(name: str):
+ """Time the enclosed block into one series."""
+ start = time.perf_counter()
+ try:
+ yield
+ finally:
+ observe(name, time.perf_counter() - start)
+
+
+def reset() -> None:
+ """Clear every series; used by benchmarks and tests, never by a running service."""
+ with _LOCK:
+ _COUNTERS.clear()
+ _TIMINGS.clear()
+
+
+def snapshot() -> dict:
+ """Return a read-only copy with millisecond timings for the management view."""
+ with _LOCK:
+ counters = dict(_COUNTERS)
+ timings = {name: {"count": int(stats["count"]),
+ "total_ms": round(stats["total_s"] * 1000, 3),
+ "avg_ms": round(stats["total_s"] * 1000 / stats["count"], 3),
+ "max_ms": round(stats["max_s"] * 1000, 3),
+ "min_ms": round(stats["min_s"] * 1000, 3)}
+ for name, stats in _TIMINGS.items()}
+ return {"uptime_seconds": round(time.monotonic() - _STARTED, 1),
+ "counters": counters, "timings": timings}
diff --git a/benchmarks/bench_routing.py b/benchmarks/bench_routing.py
new file mode 100644
index 0000000..131b0c4
--- /dev/null
+++ b/benchmarks/bench_routing.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""Offline synthetic benchmark for multi-account routing and model aggregation.
+
+Builds a real ``CredentialPool`` from synthetic ``.info`` files, a real
+``CreditLedger`` and account-scoped catalogs, then replaces nothing but the
+upstream network. It measures the request-side hot paths so a change can be
+compared against the documented baseline on the same machine.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import statistics
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+PROFILES = ("cn-cli", "cn-work", "intl-cli", "intl-work")
+DOMAINS = {"cn-cli": "www.codebuddy.cn", "cn-work": "www.workbuddy.cn",
+ "intl-cli": "www.codebuddy.ai", "intl-work": "www.workbuddy.ai"}
+MODELS = {
+ "cn-cli": ["hy4-preview", "glm-5.3", "glm-5.3-flash", "deepseek-v4-flash"],
+ "cn-work": ["auto", "glm-5.3", "glm-5.3-flash"],
+ "intl-cli": ["default-model", "claude-sonnet-4-5", "gpt-5.1"],
+ "intl-work": ["default-model", "claude-sonnet-4-5", "gpt-5.1"],
+}
+FREE_MODEL = "glm-5.3-flash"
+
+
+def credential_payload(uid: str, profile: str) -> dict:
+ """Return a credential shaped like a real export: account block plus plaintext token."""
+ return {"account": {"uid": uid, "nickname": f"bench-{uid}"},
+ "auth": {"accessToken": f"bench-token-{uid}", "refreshToken": f"bench-refresh-{uid}",
+ "domain": DOMAINS[profile], "expiresAt": int(time.time() * 1000) + 86400 * 1000,
+ "lastRefreshTime": int(time.time() * 1000)}}
+
+
+def catalog(profile: str, free: bool) -> dict:
+ models = [{"id": name, "supportsToolCall": True,
+ "credits": "x0 credits" if free and name == FREE_MODEL else "x1 credits"}
+ for name in MODELS[profile]]
+ return {"profile": profile, "models": [dict(item) for item in models],
+ "serves": [dict(item) for item in models]}
+
+
+def build(converter, credits_mod, count: int, root: Path):
+ """Populate the process-wide CONFIG with ``count`` synthetic accounts."""
+ config = converter.CONFIG
+ paths = []
+ for index in range(count):
+ profile = PROFILES[index % len(PROFILES)]
+ path = root / f"account-{index:05d}.info"
+ path.write_text(json.dumps(credential_payload(f"bench-{index:05d}", profile)), encoding="utf-8")
+ paths.append(path)
+ pool = converter.CredentialPool(paths, scan=False)
+ ledger = credits_mod.CreditLedger(root / "credits.json")
+ pool.set_ledger(ledger)
+ accounts = {}
+ for index, entry in enumerate(pool.entries()):
+ profile = entry.get("profile") or PROFILES[index % len(PROFILES)]
+ ledger.bind_identity(entry["id"], entry["account_key"])
+ ledger.update_credits(entry["id"], {"credits": 1000 + index, "intl": profile.startswith("intl"),
+ "segments": [{"remaining": 1000 + index,
+ "expires_at": time.time() + 3600 + index}]})
+ accounts[entry["account_key"]] = catalog(profile, free=index % 3 == 0)
+ catalogs = {profile: None for profile in PROFILES}
+ for account in accounts.values():
+ merged = catalogs[account["profile"]] = catalogs[account["profile"]] or []
+ merged.extend(account["models"])
+ config.update({"cred_pool": pool, "ledger": ledger, "account_catalogs": accounts,
+ "model_catalogs": catalogs, "models_remote": catalogs["cn-cli"],
+ "models_intl": catalogs["intl-cli"], "model_cache": None,
+ "model_guard": True, "max_inflight_per_account": 0,
+ "control_store": None, "log_path": None})
+ return pool
+
+
+def measure(converter, perf_metrics, case, operation, repeat):
+ """Run one case and return wall-clock milliseconds plus per-op counters."""
+ times = []
+ counters = {}
+ for _ in range(repeat):
+ perf_metrics.reset()
+ started = time.perf_counter()
+ operation()
+ times.append((time.perf_counter() - started) * 1000)
+ for name, value in perf_metrics.snapshot()["counters"].items():
+ counters[name] = counters.get(name, 0) + value
+ return {"case": case, "median_ms": round(statistics.median(times), 3),
+ "max_ms": round(max(times), 3),
+ "counters": {name: round(value / repeat, 2) for name, value in counters.items()}}
+
+
+def run(converter, credits_mod, perf_metrics, count, repeat, model):
+ temporary = tempfile.TemporaryDirectory()
+ root = Path(temporary.name)
+ pool = build(converter, credits_mod, count, root)
+ rows = []
+ try:
+ rows.append(measure(converter, perf_metrics, "pick", lambda: pool.pick("session", model), repeat))
+ rows.append(measure(converter, perf_metrics, "headers_for",
+ lambda: pool.headers_for(None, model), repeat))
+ cm = pool.pick("cooldown", model)
+ pool.note_status(cm, 429, model=model)
+ try:
+ rows.append(measure(converter, perf_metrics, "pick + cooldown", lambda: pool.pick("session", model), repeat))
+ finally:
+ converter.CONFIG["model_cache"] = None
+ tried = frozenset(entry["cm"] for entry in pool.entries()[:max(1, count // 10)])
+ rows.append(measure(converter, perf_metrics, "pick + tried",
+ lambda: pool.pick("session", model, tried=tried), repeat))
+ limit = converter.CONFIG["max_inflight_per_account"]
+ converter.CONFIG["max_inflight_per_account"] = 1
+
+ def saturated():
+ try:
+ pool.pick("session", model, with_capacity=True)
+ except Exception:
+ pass # Only the ranking work is measured; the refusal itself is free.
+ rows.append(measure(converter, perf_metrics, "pick + capacity full", saturated, repeat))
+ converter.CONFIG["max_inflight_per_account"] = limit
+ rows.append(measure(converter, perf_metrics, "current_models", converter.current_models, repeat))
+ rows.append(measure(converter, perf_metrics, "current_model_details", converter.current_model_details, repeat))
+ finally:
+ temporary.cleanup()
+ converter.CONFIG.update({"cred_pool": None, "ledger": None, "account_catalogs": None,
+ "model_catalogs": {}, "models_remote": None, "models_intl": None})
+ return rows
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--accounts", default="10,100,500", help="comma-separated account counts")
+ parser.add_argument("--repeat", type=int, default=3, help="repetitions per case")
+ parser.add_argument("--model", default="glm-5.3-flash", help="routed model to benchmark")
+ parser.add_argument("--json", action="store_true", help="print JSON instead of a table")
+ args = parser.parse_args(argv)
+ os.environ.setdefault("CODEBUDDY_AUTH_DIR", tempfile.mkdtemp(prefix="bench-auth-"))
+ import converter
+ from app import perf_metrics, credits as credits_mod
+ report = {}
+ for count in [int(value) for value in args.accounts.split(",") if value.strip()]:
+ report[count] = run(converter, credits_mod, perf_metrics, count, max(1, args.repeat), args.model)
+ if args.json:
+ print(json.dumps(report, ensure_ascii=False, indent=2))
+ return 0
+ print("| 账号数 | 场景 | 中位耗时 ms | 最慢 ms | summary 调用 | stat 调用 | 候选检查 |")
+ print("|---:|---|---:|---:|---:|---:|---:|")
+ for count, rows in report.items():
+ for row in rows:
+ counters = row["counters"]
+ print(f"| {count} | {row['case']} | {row['median_ms']} | {row['max_ms']} | "
+ f"{counters.get('credential_summary_calls', 0)} | {counters.get('credential_stat_calls', 0)} | "
+ f"{counters.get('candidate_checks', 0)} |")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/converter.py b/converter.py
index 35b3cc1..a9c594b 100644
--- a/converter.py
+++ b/converter.py
@@ -77,6 +77,7 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False,
from app.content_filter import ContentFilterDetector, is_filter_error
from app.request_limits import ImageLimitError, apply_image_policy
from app.safe_logging import format_log_body, sanitize_log_text
+from app import perf_metrics
from app.startup import allows_open_noauth, load_startup_env, run_server
from app.site_routing import (DOMESTIC, INTERNATIONAL, PROFILE_ENDPOINTS, site_for_auth, site_for_headers,
profile_for_auth, profile_for_headers, profile_region, profile_product,
@@ -287,6 +288,7 @@ def _read_raw(self) -> dict:
return json.load(f)
def _file_version(self):
+ perf_metrics.inc("credential_stat_calls")
st = self.path.stat()
return st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size
@@ -390,6 +392,7 @@ def invalidate(self):
def summary(self) -> dict:
+ perf_metrics.inc("credential_summary_calls")
with self._lock:
s = self._session()
auth = s.get("auth") or {}
@@ -1088,6 +1091,7 @@ def _evict_sticky(self):
def _candidates(self, model: str | None, *, region=None, tried=()) -> list[dict]:
"""Exclude tried credentials and rank candidates by zero rate and credit expiry."""
+ perf_metrics.inc("candidate_checks", len(self._entries))
tried = set(tried)
healthy = [entry for entry in self._entries if entry["cm"] not in tried
and self._healthy(entry)
@@ -1114,7 +1118,11 @@ def pick(self, skey: str | None, model: str | None = None, *, region=None,
tried=(), with_capacity=False, requirements=None) -> CredentialManager | None:
"""Select a healthy sticky or round-robin credential, preferring eligible zero-rate accounts."""
self._rescan() # Reload and prune acquire their own locks.
- with self._lock:
+ started = time.perf_counter()
+ waiting = time.perf_counter()
+ self._lock.acquire()
+ perf_metrics.observe("pool_lock_wait", time.perf_counter() - waiting)
+ try:
self._evict_sticky()
candidates = self._candidates(model, region=region, tried=tried)
if not candidates:
@@ -1135,6 +1143,7 @@ def pick(self, skey: str | None, model: str | None = None, *, region=None,
candidates = [entry for entry in candidates if self._model_free(entry, model) == free
and self._capacity.count(self._capacity_key(entry)) < limit]
if not candidates:
+ perf_metrics.inc("capacity_acquire_failures")
raise self._capacity_error()
best = candidates[0]
free = self._model_free(best, model)
@@ -1152,6 +1161,9 @@ def pick(self, skey: str | None, model: str | None = None, *, region=None,
if skey:
self._sticky[skey] = (e["id"], time.time())
return e["cm"]
+ finally:
+ self._lock.release()
+ perf_metrics.observe("route_pick_duration", time.perf_counter() - started)
def headers_for(self, skey: str | None, model: str | None = None, *, region=None,
with_generation=False, tried=(), with_capacity=False, requirements=None):
@@ -1187,6 +1199,7 @@ def headers_for(self, skey: str | None, model: str | None = None, *, region=None
lease = self._capacity.acquire(self._capacity_key(entry),
CONFIG.get("max_inflight_per_account", 0), cm, generation)
if lease is None:
+ perf_metrics.inc("capacity_acquire_failures")
capacity_race = True
continue
return lease, headers
@@ -1655,6 +1668,7 @@ def _sync_model_catalogs(pool, ledger, refs, failed):
key = catalog_cache_key(profile, identity)
if cache.fresh(key) and not entry.get("catalog_dirty"):
continue
+ perf_metrics.inc("catalog_fetch_count")
try:
scopes = credits_mod.fetch_model_scopes(
_bearer_token(headers), domain=headers.get("X-Domain", ""),
@@ -1671,6 +1685,7 @@ def publish():
else:
failed.add(entry["id"])
except Exception as error:
+ perf_metrics.inc("catalog_fetch_failures")
failed.add(entry["id"])
_sync_error(pool, ledger, entry, generation, "models", error)
_publish_model_cache()
diff --git a/tests/test_perf_metrics.py b/tests/test_perf_metrics.py
new file mode 100644
index 0000000..252fbb0
--- /dev/null
+++ b/tests/test_perf_metrics.py
@@ -0,0 +1,117 @@
+"""Process counters, routing instrumentation and the read-only management view."""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution.
+
+import json
+import os
+import tempfile
+import time
+import unittest
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+import converter
+from app import perf_metrics
+from app.admin_api import install_admin
+from app.control_store import ControlStore
+
+
+class PerfMetricsTests(unittest.TestCase):
+ def setUp(self):
+ self.enterContext(patch.object(perf_metrics, "_COUNTERS", {}))
+ self.enterContext(patch.object(perf_metrics, "_TIMINGS", {}))
+
+ def test_counters_and_timings_accumulate(self):
+ perf_metrics.inc("candidate_checks")
+ perf_metrics.inc("candidate_checks", 4)
+ perf_metrics.observe("route_pick_duration", 0.004)
+ perf_metrics.observe("route_pick_duration", 0.002)
+ with perf_metrics.timed("route_pick_duration"):
+ pass
+ snapshot = perf_metrics.snapshot()
+ self.assertEqual(snapshot["counters"], {"candidate_checks": 5})
+ timing = snapshot["timings"]["route_pick_duration"]
+ self.assertEqual(timing["count"], 3)
+ self.assertAlmostEqual(timing["total_ms"], 6.0, places=1)
+ self.assertGreaterEqual(timing["max_ms"], 4.0)
+ self.assertLessEqual(timing["min_ms"], 2.0)
+
+ def test_malformed_sample_is_ignored_and_reset_clears(self):
+ perf_metrics.observe("pool_lock_wait", "not-a-number")
+ self.assertEqual(perf_metrics.snapshot()["timings"], {})
+ perf_metrics.inc("catalog_fetch_failures")
+ perf_metrics.reset()
+ self.assertEqual(perf_metrics.snapshot()["counters"], {})
+
+
+class RoutingInstrumentationTests(unittest.TestCase):
+ def setUp(self):
+ temporary = self.enterContext(tempfile.TemporaryDirectory())
+ self.enterContext(patch.dict(os.environ, {"CODEBUDDY_AUTH_DIR": temporary}))
+ self.enterContext(patch.object(perf_metrics, "_COUNTERS", {}))
+ self.enterContext(patch.object(perf_metrics, "_TIMINGS", {}))
+ self.root = Path(temporary)
+ self.path = self.root / "account.info"
+ self.path.write_text(json.dumps({"account": {"uid": "instrumented"}, "auth": {
+ "accessToken": "synthetic-token", "domain": "www.codebuddy.cn",
+ "expiresAt": (time.time() + 3600) * 1000}}), encoding="utf-8")
+ self.pool = converter.CredentialPool([self.path])
+ self.enterContext(patch.dict(converter.CONFIG, {"cred_pool": self.pool, "log_path": None,
+ "control_store": None, "model_cache": None,
+ "account_catalogs": None}))
+
+ def test_pick_records_duration_lock_wait_and_candidate_work(self):
+ self.assertIsNotNone(self.pool.pick("session", "glm-5.3-flash"))
+ snapshot = perf_metrics.snapshot()
+ self.assertGreaterEqual(snapshot["counters"]["candidate_checks"], 1)
+ self.assertGreaterEqual(snapshot["counters"]["credential_summary_calls"], 1)
+ self.assertGreaterEqual(snapshot["counters"]["credential_stat_calls"], 1)
+ self.assertEqual(snapshot["timings"]["pool_lock_wait"]["count"], 1)
+ self.assertEqual(snapshot["timings"]["route_pick_duration"]["count"], 1)
+
+ def test_capacity_refusal_is_counted(self):
+ with patch.dict(converter.CONFIG, {"max_inflight_per_account": 1}):
+ self.pool.pick("session", "glm-5.3-flash")
+ entry = self.pool._entries[0]
+ self.pool._capacity.acquire(self.pool._capacity_key(entry), 1, entry["cm"], entry["cm"]._generation)
+ with self.assertRaises(converter.HTTPException):
+ self.pool.pick("session", "glm-5.3-flash", with_capacity=True)
+ self.assertGreaterEqual(perf_metrics.snapshot()["counters"]["capacity_acquire_failures"], 1)
+
+
+
+class AdminPerformanceViewTests(unittest.TestCase):
+ def setUp(self):
+ root = Path(self.enterContext(tempfile.TemporaryDirectory()))
+ self.store = ControlStore(root / "control.sqlite3")
+ self.addCleanup(self.store.close)
+ self.config = {"api_key": "synthetic-key", "control_store": self.store, "max_images": 16,
+ "session_path": root / "admin-sessions.json"}
+ self.audit = Mock()
+ self.audit.storage.return_value = {}
+ self.config["audit_store"] = self.audit
+ self.gateway = SimpleNamespace(managed_auth_dir=Mock(return_value=root))
+ self.app = FastAPI()
+ install_admin(self.app, self.config, self.gateway)
+ self.client = self.enterContext(TestClient(self.app))
+
+ def test_requires_admin_login_and_returns_counters_only(self):
+ self.assertEqual(self.client.get("/admin/performance").status_code, 401)
+ self.client.post("/admin/session", json={"api_key": "synthetic-key"},
+ headers={"Origin": "http://testserver"})
+ perf_metrics.inc("route_pick_duration_marker")
+ result = self.client.get("/admin/performance")
+ self.assertEqual(result.status_code, 200, result.text)
+ body = result.json()
+ self.assertEqual(set(body), {"uptime_seconds", "counters", "timings"})
+ self.assertGreaterEqual(body["counters"]["route_pick_duration_marker"], 1)
+ self.assertNotIn("synthetic-key", result.text)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
From b9272af4fde6bf277db040be133384e212f5162b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?=
<277378677+maiphucgiang@users.noreply.github.com>
Date: Sun, 18 Oct 2026 12:26:41 +0800
Subject: [PATCH 02/13] Move credential file scanning out of the request path
---
converter.py | 93 +++++++++++++++++++++++++++-----
tests/test_credential_runtime.py | 24 +++++++++
tests/test_identity_sync.py | 4 +-
3 files changed, 106 insertions(+), 15 deletions(-)
diff --git a/converter.py b/converter.py
index a9c594b..5c3549f 100644
--- a/converter.py
+++ b/converter.py
@@ -563,7 +563,11 @@ def __init__(self, paths: list[Path] | None = None, scan: bool = False,
self._rr = {None: 0, "cn": 0, "intl": 0}
self._ledger = None # Prefer credits expiring sooner.
self._capacity = AccountCapacity()
- self._scan = scan # Rescan credentials before selection.
+ self._scan = scan # Scan the auth directory during reconcile, never per request.
+ # Request-side lookups are rebuilt whenever the entry list changes.
+ self._index_id: dict[str, dict] = {}
+ self._index_cm: dict = {}
+ self._index_identity: dict[str, list[dict]] = {}
# Explicit paths must survive rejection or eviction so a repaired file re-enters.
self._configured: list[str] = []
self._ignored_duplicates: set[str] = set()
@@ -671,9 +675,32 @@ def reload(self, paths: list[Path], *, reset: bool = True):
if uid:
have_uids[identity_key] = cid
self._queue_sync(cid)
+ self._rebuild_index()
+
+ def _rebuild_index(self):
+ """Rebuild request-side lookups after the entry list changed; never called per request."""
+ self._index_id = {entry["id"]: entry for entry in self._entries}
+ self._index_cm = {entry["cm"]: entry for entry in self._entries}
+ identity: dict[str, list[dict]] = {}
+ for entry in self._entries:
+ if entry.get("account_key"):
+ identity.setdefault(entry["account_key"], []).append(entry)
+ self._index_identity = identity
+
+ def entry_for(self, cm):
+ """Return the live entry owning this manager, or None after eviction."""
+ return self._index_cm.get(cm)
+
+ def entry_by_id(self, cid):
+ """Return the live entry for one credential path."""
+ return self._index_id.get(cid)
+
+ def entries_by_identity(self, account_key):
+ """Return every live entry sharing one account identity."""
+ return tuple(self._index_identity.get(account_key, ()))
def _queue_sync(self, cid):
- entry = next((entry for entry in self._entries if entry["id"] == cid), None)
+ entry = self._index_id.get(cid)
if entry is not None and not model_policy.credential_enabled(CONFIG, entry):
return
self._sync_pending.add(cid)
@@ -728,7 +755,7 @@ def sync_wait(self, periodic_delay):
def apply_if_current(self, cm, generation, update):
"""Run updates only for enabled accounts with the current credential lease."""
with self._lock, cm._lock:
- entry = next((entry for entry in self._entries if entry["cm"] is cm), None)
+ entry = self._index_cm.get(cm)
if entry is None or not model_policy.credential_enabled(CONFIG, entry):
return False
if not self._lease_matches(cm, generation):
@@ -752,6 +779,7 @@ def _drop_entry(self, entry):
self._syncing.discard(cid)
self._sync_retry.pop(cid, None)
self._sync_attempts.pop(cid, None)
+ self._rebuild_index()
invalidate_model_table()
def prune(self):
"""Remove missing credential files and their session bindings."""
@@ -777,6 +805,8 @@ def prune(self):
self._model_fail = {k: v for k, v in self._model_fail.items() if k[0] in ids}
if CONFIG.get("cred_pool") is self:
_publish_model_cache()
+ if len(self._entries) != before:
+ self._rebuild_index()
def find_by_uid(self, uid: str, identity: str | None = None) -> Optional[str]:
@@ -956,11 +986,35 @@ def _rescan(self):
paths = find_auth_files() if self._scan else [Path(cid) for cid in self._configured]
self.reload(paths, reset=False)
+ def fingerprint(self):
+ """Return a cheap directory fingerprint so reconcile only runs after a real change."""
+ configured = {str(path) for path in find_auth_files()} if self._scan else set(self._configured)
+ rows = []
+ for cid in sorted(configured):
+ try:
+ st = os.stat(cid)
+ except OSError:
+ rows.append((cid, None))
+ continue
+ rows.append((cid, st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size))
+ return (self._scan, tuple(rows))
+
+ def reconcile(self, previous=None):
+ """Apply file changes outside the request path; returns the new fingerprint."""
+ current = self.fingerprint()
+ if previous is None or current != previous:
+ self._rescan()
+ return current
+
def _healthy(self, e: dict) -> bool:
return model_policy.credential_enabled(CONFIG, e) and time.time() >= e["fail_until"]
@staticmethod
def _entry_profile(entry):
+ # Reload caches the parsed profile, so the request path never re-reads the file here.
+ profile = entry.get("profile")
+ if profile:
+ return profile
try:
return entry["cm"].summary().get("profile", "cn-cli")
except Exception:
@@ -1117,7 +1171,7 @@ def _capacity_key(entry):
def pick(self, skey: str | None, model: str | None = None, *, region=None,
tried=(), with_capacity=False, requirements=None) -> CredentialManager | None:
"""Select a healthy sticky or round-robin credential, preferring eligible zero-rate accounts."""
- self._rescan() # Reload and prune acquire their own locks.
+ # The candidate list comes from the indexes; file changes are applied by background reconcile.
started = time.perf_counter()
waiting = time.perf_counter()
self._lock.acquire()
@@ -1186,8 +1240,10 @@ def headers_for(self, skey: str | None, model: str | None = None, *, region=None
self.cooldown(cm, reason=reason, generation=generation)
continue
with self._lock:
- self.reload([cm.path], reset=False)
- entry = next((entry for entry in self._entries if entry["cm"] is cm), None)
+ entry = self._index_cm.get(cm)
+ if entry is not None and cm._generation != entry.get("generation"):
+ self.reload([cm.path], reset=False) # Ownership changed on disk: re-read one file.
+ entry = self._index_cm.get(cm)
if (entry is not None and cm._generation == generation and self._healthy(entry)
and self._eligible(entry, model, region=region, profile=profile) and self._model_healthy(entry, model)):
if requirements is not None:
@@ -1454,6 +1510,22 @@ def _refresher_loop(pool: CredentialPool):
except Exception as e:
_log(f"[cred] 刷新线程异常: {e}")
+CRED_RECONCILE_INTERVAL_S = 5 # Credential directory polling interval for background reconcile
+
+
+def _reconcile_loop(pool: CredentialPool):
+ """Apply credential file changes in the background so requests never scan the pool."""
+ try:
+ previous = pool.fingerprint()
+ except Exception:
+ previous = None
+ while True:
+ time.sleep(CRED_RECONCILE_INTERVAL_S)
+ try:
+ previous = pool.reconcile(previous)
+ except Exception as e:
+ _log(f"[cred] 目录变更处理异常: {_network_error_text(e)}")
+
CHECKIN_FIRST_DELAY = 30 # Initial check-in delay in seconds
HOUSEKEEP_INTERVAL = 3600 # Account maintenance interval in seconds
@@ -2737,8 +2809,6 @@ def _positive(entry):
def current_models(region: str | None = None) -> list[str]:
"""Merge models eligible for current accounts without region-specific client URLs."""
pool = CONFIG.get("cred_pool")
- if pool is not None:
- pool._rescan()
with pool._lock if pool is not None else nullcontext():
configured = _configured_profiles(region)
out, has_auto = [], False
@@ -2784,8 +2854,6 @@ def current_models(region: str | None = None) -> list[str]:
def current_model_details(region: str | None = None) -> list[dict]:
"""Return public model rates with per-profile values and the minimum eligible rate."""
pool = CONFIG.get("cred_pool")
- if pool is not None:
- pool._rescan()
details: dict[str, dict] = {}
declarations = {}
for name in current_models(region):
@@ -2965,9 +3033,6 @@ def guard_model(name: str, *, region=None, resolved=False) -> None:
model_policy.check_resolved(CONFIG, name)
if not CONFIG.get("model_guard"):
return
- pool = CONFIG.get("cred_pool")
- if pool is not None:
- pool._rescan()
if _model_profiles(name, region):
return
if _catalog_pending(region):
@@ -4625,6 +4690,8 @@ def main():
daemon=True, name="service-lease").start()
threading.Thread(target=_refresher_loop, args=(CONFIG["cred_pool"],),
daemon=True, name="cred-refresher").start()
+ threading.Thread(target=_reconcile_loop, args=(CONFIG["cred_pool"],),
+ daemon=True, name="cred-reconcile").start()
if credits_mod is not None:
threading.Thread(target=_housekeeper_loop, args=(CONFIG["cred_pool"], ledger),
daemon=True, name="cred-housekeeper").start()
diff --git a/tests/test_credential_runtime.py b/tests/test_credential_runtime.py
index ed1ab0f..1fdc07a 100644
--- a/tests/test_credential_runtime.py
+++ b/tests/test_credential_runtime.py
@@ -264,6 +264,30 @@ def test_directory_changes_still_add_and_remove_accounts(self):
pool._rescan()
self.assertEqual([e["id"] for e in pool.entries()], [str(second)])
+ def test_request_path_stays_indexed_and_reconcile_applies_file_changes(self):
+ first = self.credential(age=0)
+ pool = converter.CredentialPool([first], scan=True)
+ manager = pool.first()
+ self.assertIs(pool.entry_for(manager), pool._entries[0])
+ self.assertIs(pool.entry_by_id(str(first)), pool._entries[0])
+ second = self.credential("second.info", uid="second", age=0)
+ self.assertIsNotNone(pool.pick("session", "glm-5.3-flash")) # A request never scans the directory.
+ self.assertEqual(len(pool.entries()), 1)
+ reloads = []
+ original = pool.reload
+ with patch.object(pool, "reload", side_effect=lambda paths, **kw: (reloads.append(list(paths)),
+ original(paths, **kw))[1]):
+ fingerprint = pool.reconcile()
+ self.assertEqual(len(pool.entries()), 2)
+ self.assertTrue(reloads)
+ reloads.clear()
+ self.assertEqual(pool.reconcile(fingerprint), fingerprint)
+ self.assertEqual(reloads, []) # An unchanged directory is not re-read.
+ first.unlink()
+ pool.reconcile(fingerprint)
+ self.assertEqual([e["id"] for e in pool.entries()], [str(second)])
+ self.assertIsNone(pool.entry_for(manager))
+
def test_encrypted_envelope_tokens_fail_fast_and_stay_out_of_pool(self):
valid = self.credential("valid.info", uid="valid", age=0)
envelope = self.root / "envelope.info"
diff --git a/tests/test_identity_sync.py b/tests/test_identity_sync.py
index f04cf7a..8a67c2b 100644
--- a/tests/test_identity_sync.py
+++ b/tests/test_identity_sync.py
@@ -428,8 +428,8 @@ def barrier(*args, **kwargs):
patch.object(c.uvicorn, "run", side_effect=barrier), \
patch("sys.stderr", new=io.StringIO()):
c.main()
- # refresher, housekeeper, lease heartbeat and the server itself.
- self.assertEqual(len(observed), 4)
+ # refresher, reconcile, housekeeper, lease heartbeat and the server itself.
+ self.assertEqual(len(observed), 5)
if __name__ == "__main__":
From 2e5240a9f86abf7aa7f7f164798e3fbaa1ce5b4c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?=
<277378677+maiphucgiang@users.noreply.github.com>
Date: Sun, 18 Oct 2026 12:31:42 +0800
Subject: [PATCH 03/13] Reuse one policy revision and indexed catalogs per
routing decision
---
app/catalog_index.py | 77 +++++++++++++++
app/control_store.py | 5 +
app/credits.py | 7 ++
app/model_policy.py | 74 +++++++++++---
converter.py | 171 ++++++++++++++++++++++++---------
tests/test_routing_snapshot.py | 109 +++++++++++++++++++++
6 files changed, 384 insertions(+), 59 deletions(-)
create mode 100644 app/catalog_index.py
create mode 100644 tests/test_routing_snapshot.py
diff --git a/app/catalog_index.py b/app/catalog_index.py
new file mode 100644
index 0000000..aba81ef
--- /dev/null
+++ b/app/catalog_index.py
@@ -0,0 +1,77 @@
+"""Read-only indexes over published account catalogs, built once per catalog revision."""
+from __future__ import annotations
+
+from .model_catalog_view import INTERNATIONAL, share_models
+
+
+def account_scope(account, scope="models"):
+ """Merge root candidates into selector models while retaining selector metadata."""
+ picker = account.get("models")
+ if scope == "models" or picker is None:
+ return picker
+ seen = {item.get("id") for item in picker}
+ # Root entries supply missing names without replacing selector metadata.
+ return picker + [item for item in account.get("serves") or [] if item.get("id") not in seen]
+
+
+class CatalogIndex:
+ """Precompute per-account and per-profile catalogs so request paths never rescan accounts.
+
+ ``accounts`` is the published ``account_catalogs`` mapping; the index keeps that same
+ object so a caller can detect a republished catalog by identity.
+ """
+
+ SCOPES = ("models", "serves")
+
+ def __init__(self, accounts, *, revision=0, policy_revision=0, is_enabled=None):
+ self.accounts = accounts if isinstance(accounts, dict) else {}
+ self.revision = revision
+ self.policy_revision = policy_revision
+ self._enabled = is_enabled or (lambda _identity: True)
+ self._account: dict[tuple[int, str], list[dict] | None] = {}
+ self._profile: dict[str, dict[str, tuple[dict, ...]]] = {}
+ self.profiles: tuple[str, ...] = tuple(dict.fromkeys(
+ account.get("profile") for account in self.accounts.values() if account.get("profile")))
+ for scope in self.SCOPES:
+ sources = self._shared_sources(scope)
+ merged: dict[str, list[dict]] = {}
+ for identity, account in self.accounts.items():
+ profile = account.get("profile") if isinstance(account, dict) else None
+ if not profile:
+ continue
+ items = self._effective(account, scope, sources)
+ if items is None:
+ continue
+ merged.setdefault(profile, []).extend(items)
+ self._profile[scope] = {name: tuple(items) for name, items in merged.items()}
+
+ def _shared_sources(self, scope):
+ """Collect every enabled international account catalog once per scope."""
+ return [(account["profile"], account_scope(account, scope))
+ for identity, account in self.accounts.items()
+ if isinstance(account, dict) and account.get("profile") in INTERNATIONAL
+ and account.get("models") is not None and self._enabled(identity)]
+
+ def _effective(self, account, scope, sources):
+ native = account_scope(account, scope)
+ if native is None:
+ return None
+ profile = account.get("profile")
+ items = share_models(native, profile, sources) if profile in INTERNATIONAL else native
+ self._account[(id(account), scope)] = items
+ return items
+
+ def account_catalog(self, account, scope="models"):
+ """Return this account's effective catalog, or None when the index never saw it."""
+ if not isinstance(account, dict):
+ return None
+ return self._account.get((id(account), scope))
+
+ def catalog(self, profile, scope="models", *, model_id=None):
+ """Return the merged catalog for one profile, or None when no account supplied one."""
+ items = self._profile.get(scope, {}).get(profile)
+ if items is None:
+ return None
+ if model_id is None:
+ return list(items)
+ return [item for item in items if item.get("id") == model_id]
diff --git a/app/control_store.py b/app/control_store.py
index c8e063b..8afd790 100644
--- a/app/control_store.py
+++ b/app/control_store.py
@@ -174,6 +174,11 @@ def snapshot(self):
# Published dictionaries are never mutated; SQLite writer locks cannot stall routing.
return copy.deepcopy(self._snapshot)
+ @property
+ def revision(self):
+ """Current revision without copying the snapshot, for cache and policy views."""
+ return self._snapshot["revision"]
+
def _update(self, revision, change):
with self._lock:
self._db.execute("BEGIN IMMEDIATE")
diff --git a/app/credits.py b/app/credits.py
index a3536eb..9fffc76 100644
--- a/app/credits.py
+++ b/app/credits.py
@@ -4,6 +4,7 @@
import base64
from copy import deepcopy
import json
+import itertools
import math
import os
import re
@@ -794,6 +795,9 @@ def fetch_request_usage(access_token: str, days: int = USAGE_MAX_DAYS,
# Atomic JSON persistence for per-credential credit and check-in state
# ---------------------------------------------------------------------------
+_LEDGER_REVISION = itertools.count(1)
+
+
class CreditLedger:
"""Persist per-credential check-in and balance snapshots for expiry-aware scheduling."""
@@ -802,6 +806,8 @@ def __init__(self, path: Path | None = None, *, store=None):
self.path = Path(store.path if store is not None else path)
self._lock = threading.Lock()
self._data: dict = {"version": 1, "creds": {}}
+ # Globally unique so a rebuilt ledger can never reuse the previous one's cache key.
+ self.revision = next(_LEDGER_REVISION)
self._load()
def _load(self):
@@ -816,6 +822,7 @@ def _load(self):
self._data = {"version": 1, "creds": {}}
def _save(self):
+ self.revision = next(_LEDGER_REVISION)
if self._store is not None:
self._store.put("credits", self._data)
return
diff --git a/app/model_policy.py b/app/model_policy.py
index 33d5370..afa2d79 100644
--- a/app/model_policy.py
+++ b/app/model_policy.py
@@ -23,33 +23,75 @@ async def __call__(self, scope, receive, send):
def snapshot(config):
+ """Return a detached policy snapshot; one routing decision resolves it exactly once."""
store = config.get("control_store")
if store is None:
return {"models": {}, "credentials": {}, "revision": 0}
+ perf_metrics.inc("policy_snapshot_calls")
return store.snapshot()
-def credential_enabled(config, entry):
- return snapshot(config)["credentials"].get(entry.get("account_key"), {}).get("enabled", True)
+class PolicyView:
+ """One published policy revision reused across a whole routing decision."""
+ __slots__ = ("data",)
-def credential_auto_checkin(config, entry):
+ def __init__(self, data):
+ self.data = data
+
+ @property
+ def revision(self):
+ return self.data.get("revision", 0)
+
+ def credential(self, entry):
+ """Return the account metadata block for this entry, or an empty mapping."""
+ return (self.data.get("credentials") or {}).get(entry.get("account_key")) or {}
+
+ def rule_for(self, source):
+ return {**default_rule(source), **(self.data.get("models") or {}).get(source, {})}
+
+
+def view(config):
+ """Resolve the policy once so every candidate check in one request sees the same revision."""
+ return PolicyView(snapshot(config))
+
+
+def revision(config, policy=None):
+ """Return the published revision without copying the snapshot."""
+ if policy is not None:
+ return policy.revision
+ store = config.get("control_store")
+ if store is None:
+ return 0
+ value = getattr(store, "revision", None)
+ return value if isinstance(value, int) else snapshot(config).get("revision", 0)
+
+
+def _policy(config, policy=None):
+ return policy.data if policy is not None else snapshot(config)
+
+
+def credential_enabled(config, entry, policy=None):
+ return _policy(config, policy)["credentials"].get(entry.get("account_key"), {}).get("enabled", True)
+
+
+def credential_auto_checkin(config, entry, policy=None):
"""International accounts opt in explicitly; the preference is independent of routing."""
default = entry.get("profile") in {"cn-cli", "cn-work"}
- return snapshot(config)["credentials"].get(entry.get("account_key"), {}).get("auto_checkin", default)
+ return _policy(config, policy)["credentials"].get(entry.get("account_key"), {}).get("auto_checkin", default)
-def credential_auto_travel(config, entry):
+def credential_auto_travel(config, entry, policy=None):
# The growth center is personal-only, so an enterprise tenant must never be scheduled
# for travel even though it shares the domestic product profile.
supported = travel.supported(entry.get("profile"), entry.get("enterprise") is True)
- return supported and snapshot(config)["credentials"].get(entry.get("account_key"), {}).get("auto_travel", True)
+ return supported and _policy(config, policy)["credentials"].get(entry.get("account_key"), {}).get("auto_travel", True)
-def credential_auto_daily_chat(config, entry):
+def credential_auto_daily_chat(config, entry, policy=None):
"""A turn spends credits on the account, so international accounts opt in explicitly."""
supported = entry.get("profile") == "intl-work"
- return supported and snapshot(config)["credentials"].get(entry.get("account_key"), {}).get("auto_daily_chat", False)
+ return supported and _policy(config, policy)["credentials"].get(entry.get("account_key"), {}).get("auto_daily_chat", False)
def default_rule(source):
@@ -58,8 +100,8 @@ def default_rule(source):
"region": None, "profile": None, "credential_ids": []}
-def rule_for(config, source):
- return {**default_rule(source), **snapshot(config)["models"].get(source, {})}
+def rule_for(config, source, policy=None):
+ return {**default_rule(source), **_policy(config, policy)["models"].get(source, {})}
def _deny(message, code="model_disabled"):
@@ -67,11 +109,11 @@ def _deny(message, code="model_disabled"):
"message": message, "type": "invalid_request_error", "param": "model", "code": code}})
-def _active(config, upstream):
+def _active(config, upstream, policy=None):
state = _request_policy.get()
if state and state.get("upstream") == upstream:
- return rule_for(config, state["source"]), state
- return rule_for(config, upstream), None
+ return rule_for(config, state["source"], policy), state
+ return rule_for(config, upstream, policy), None
def resolve(config, public_model):
@@ -107,11 +149,11 @@ def check_resolved(config, upstream):
_deny("模型已停用")
-def route_allowed(config, entry, model, *, rule=None):
- if not credential_enabled(config, entry):
+def route_allowed(config, entry, model, *, rule=None, policy=None):
+ if not credential_enabled(config, entry, policy):
return False
if rule is None:
- active, state = _active(config, model) if model else (default_rule(""), None)
+ active, state = _active(config, model, policy) if model else (default_rule(""), None)
if state and state["rule"] != active:
return False
rule = active
diff --git a/converter.py b/converter.py
index 5c3549f..6fdb798 100644
--- a/converter.py
+++ b/converter.py
@@ -6,6 +6,7 @@
import argparse
import asyncio
import hashlib
+import itertools
import json
import math
import os
@@ -72,6 +73,8 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False,
from app.message_normalization import merge_intl_user_images
from app.adapters.chat_input import normalize_chat_messages
from app.model_catalog_view import INTERNATIONAL as SHARED_INTL_PROFILES, share_models
+from app.catalog_index import CatalogIndex
+from app.catalog_index import account_scope as _account_scope
from app.inference_auth import require_api_key
from app.admin_auth import SessionStoreError
from app.content_filter import ContentFilterDetector, is_filter_error
@@ -546,6 +549,10 @@ def _dynamic_request_headers(skey: str | None) -> dict:
}
+# Globally unique revisions keep cached policy views and model tables from matching a rebuilt pool.
+_POOL_REVISION = itertools.count(1)
+
+
class CredentialPool:
"""Manage credential discovery, reloads, sticky sessions, cooldowns and refresh."""
@@ -568,6 +575,7 @@ def __init__(self, paths: list[Path] | None = None, scan: bool = False,
self._index_id: dict[str, dict] = {}
self._index_cm: dict = {}
self._index_identity: dict[str, list[dict]] = {}
+ self.revision = next(_POOL_REVISION) # Bumped whenever the entry list is rebuilt.
# Explicit paths must survive rejection or eviction so a repaired file re-enters.
self._configured: list[str] = []
self._ignored_duplicates: set[str] = set()
@@ -679,6 +687,7 @@ def reload(self, paths: list[Path], *, reset: bool = True):
def _rebuild_index(self):
"""Rebuild request-side lookups after the entry list changed; never called per request."""
+ self.revision = next(_POOL_REVISION)
self._index_id = {entry["id"]: entry for entry in self._entries}
self._index_cm = {entry["cm"]: entry for entry in self._entries}
identity: dict[str, list[dict]] = {}
@@ -1006,8 +1015,8 @@ def reconcile(self, previous=None):
self._rescan()
return current
- def _healthy(self, e: dict) -> bool:
- return model_policy.credential_enabled(CONFIG, e) and time.time() >= e["fail_until"]
+ def _healthy(self, e: dict, policy=None) -> bool:
+ return model_policy.credential_enabled(CONFIG, e, policy) and time.time() >= e["fail_until"]
@staticmethod
def _entry_profile(entry):
@@ -1052,25 +1061,22 @@ def _has_credit(self, entry, profile):
except (TypeError, ValueError):
return False
- def _eligible(self, entry, model, *, region=None, profile=None, rule=None):
- if not model_policy.route_allowed(CONFIG, entry, model, rule=rule):
+ def _eligible(self, entry, model, *, region=None, profile=None, rule=None, policy=None):
+ if not model_policy.route_allowed(CONFIG, entry, model, rule=rule, policy=policy):
return False
actual = self._entry_profile(entry)
profile = profile or actual
if not profile or profile != actual or not _in_region(profile, region):
return False
- configured = {candidate for item in self._entries if (candidate := self._entry_profile(item))
- and _in_region(candidate, region)}
- if profile not in _model_profiles(model, region, configured):
+ configured = _configured_profiles(region, policy)
+ if profile not in _model_profiles(model, region, configured, policy=policy):
return False
if CONFIG.get("account_catalogs") is not None or CONFIG.get("model_cache") is not None:
- try:
- identity = entry["cm"].summary()["account_key"]
- except Exception:
+ # A changed file invalidates this account until it is re-read; headers_for reloads and
+ # rechecks the selected credential before any request is sent with it.
+ if entry["generation"] != entry["cm"]._generation:
return False
- if identity != entry.get("account_key"):
- return False
- account = (CONFIG.get("account_catalogs") or {}).get(identity) or {}
+ account = (CONFIG.get("account_catalogs") or {}).get(entry.get("account_key")) or {}
models = _effective_account_scope(account, "serves", model_id=_upstream_model(model, profile))
if account.get("profile") != profile or models is None:
return False
@@ -1079,7 +1085,7 @@ def _eligible(self, entry, model, *, region=None, profile=None, rule=None):
cli_auto = model == "auto" and profile == "cn-cli" and bool(usable)
# Disabling the guard must not borrow another account's model capabilities.
declared = any(item["id"] == _upstream_model(model, profile)
- for item in _models_for_profile(profile, configured, scope="serves"))
+ for item in _models_for_profile(profile, configured, scope="serves", policy=policy))
passthrough = (model != "auto" and not declared and not CONFIG.get("model_guard")
and len(configured) == 1)
if model and not (supported or cli_auto or passthrough):
@@ -1143,13 +1149,14 @@ def _evict_sticky(self):
else:
break
- def _candidates(self, model: str | None, *, region=None, tried=()) -> list[dict]:
+ def _candidates(self, model: str | None, *, region=None, tried=(), policy=None) -> list[dict]:
"""Exclude tried credentials and rank candidates by zero rate and credit expiry."""
perf_metrics.inc("candidate_checks", len(self._entries))
tried = set(tried)
healthy = [entry for entry in self._entries if entry["cm"] not in tried
- and self._healthy(entry)
- and self._eligible(entry, model, region=region) and self._model_healthy(entry, model)
+ and self._healthy(entry, policy)
+ and self._eligible(entry, model, region=region, policy=policy)
+ and self._model_healthy(entry, model)
and self._model_servable(entry, model)]
if not healthy:
return []
@@ -1169,16 +1176,17 @@ def _capacity_key(entry):
def pick(self, skey: str | None, model: str | None = None, *, region=None,
- tried=(), with_capacity=False, requirements=None) -> CredentialManager | None:
+ tried=(), with_capacity=False, requirements=None, policy=None) -> CredentialManager | None:
"""Select a healthy sticky or round-robin credential, preferring eligible zero-rate accounts."""
# The candidate list comes from the indexes; file changes are applied by background reconcile.
+ policy = policy or model_policy.view(CONFIG) # One policy revision for the whole decision.
started = time.perf_counter()
waiting = time.perf_counter()
self._lock.acquire()
perf_metrics.observe("pool_lock_wait", time.perf_counter() - waiting)
try:
self._evict_sticky()
- candidates = self._candidates(model, region=region, tried=tried)
+ candidates = self._candidates(model, region=region, tried=tried, policy=policy)
if not candidates:
if skey:
self._sticky.pop(skey, None)
@@ -1220,12 +1228,14 @@ def pick(self, skey: str | None, model: str | None = None, *, region=None,
perf_metrics.observe("route_pick_duration", time.perf_counter() - started)
def headers_for(self, skey: str | None, model: str | None = None, *, region=None,
- with_generation=False, tried=(), with_capacity=False, requirements=None):
+ with_generation=False, tried=(), with_capacity=False, requirements=None, policy=None):
"""Recheck identity and atomically reserve account capacity before sending."""
+ policy = policy or model_policy.view(CONFIG)
capacity_race = False
capability_failures = []
for _ in range(max(1, len(self._entries))):
- cm = self.pick(skey, model, region=region, tried=tried, with_capacity=with_capacity, requirements=requirements)
+ cm = self.pick(skey, model, region=region, tried=tried, with_capacity=with_capacity,
+ requirements=requirements, policy=policy)
if cm is None:
return None
reason = None
@@ -1244,8 +1254,9 @@ def headers_for(self, skey: str | None, model: str | None = None, *, region=None
if entry is not None and cm._generation != entry.get("generation"):
self.reload([cm.path], reset=False) # Ownership changed on disk: re-read one file.
entry = self._index_cm.get(cm)
- if (entry is not None and cm._generation == generation and self._healthy(entry)
- and self._eligible(entry, model, region=region, profile=profile) and self._model_healthy(entry, model)):
+ if (entry is not None and cm._generation == generation and self._healthy(entry, policy)
+ and self._eligible(entry, model, region=region, profile=profile, policy=policy)
+ and self._model_healthy(entry, model)):
if requirements is not None:
failures = requirements.violations(model_capabilities.entry_model(sys.modules[__name__], entry, model))
if failures:
@@ -1726,6 +1737,7 @@ def _publish_model_cache():
models.extend(account["models"])
CONFIG["model_catalogs"] = catalogs
CONFIG["models_remote"], CONFIG["models_intl"] = catalogs["cn-cli"], catalogs["intl-cli"]
+ _publish_catalog_index(accounts)
invalidate_model_table()
@@ -2624,6 +2636,8 @@ def billing_usage(start_date: Optional[str] = None, end_date: Optional[str] = No
# Prefer cloud catalogs over static fallback models.
_MODEL_TABLE_TTL = 60.0 # Model snapshot lifetime in seconds
_model_table_cache: dict = {}
+_configured_cache: dict = {} # (region, pool revision, policy revision) -> enabled profiles
+_catalog_revision = 0
def invalidate_model_table() -> None:
@@ -2632,10 +2646,45 @@ def invalidate_model_table() -> None:
_model_table_cache = {}
+def _identity_enabled(identity) -> bool:
+ """Whether a published account identity still has an enabled credential."""
+ pool = CONFIG.get("cred_pool")
+ entries = pool.entries_by_identity(identity) if pool is not None else ()
+ if not entries:
+ return True
+ return any(model_policy.credential_enabled(CONFIG, entry) for entry in entries)
+
+
+def _publish_catalog_index(accounts):
+ """Build the read-only catalog index once per publish, never per request."""
+ global _catalog_revision
+ _catalog_revision += 1
+ index = CatalogIndex(accounts, revision=_catalog_revision,
+ policy_revision=model_policy.revision(CONFIG),
+ is_enabled=_identity_enabled)
+ CONFIG["catalog_index"] = index
+ return index
+
+
+def _catalog_index():
+ """Return the catalog index matching the published accounts and policy revision."""
+ accounts = CONFIG.get("account_catalogs")
+ if accounts is None:
+ return None
+ index = CONFIG.get("catalog_index")
+ if (index is not None and index.accounts is accounts
+ and index.policy_revision == model_policy.revision(CONFIG)):
+ return index
+ return _publish_catalog_index(accounts)
+
+
def _catalog_for(profile: str, scope: str = "models", *, model_id=None):
"""Return a profile catalog within the requested account scope."""
accounts = CONFIG.get("account_catalogs")
if accounts is not None or CONFIG.get("model_cache") is not None:
+ index = _catalog_index()
+ if index is not None:
+ return index.catalog(profile, scope, model_id=model_id)
pool = CONFIG.get("cred_pool")
models = None
for entry in pool.entries() if pool is not None else []:
@@ -2659,11 +2708,20 @@ def _in_region(profile: str, region: str | None) -> bool:
return region is None or profile_region(profile) == region
-def _configured_profiles(region: str | None) -> set[str]:
+def _configured_profiles(region: str | None, policy=None) -> set[str]:
+ """Return enabled profiles in a region, cached until the pool or policy revision moves."""
pool = CONFIG.get("cred_pool")
if pool is not None:
- return {profile for entry in pool.entries() if (profile := pool._entry_profile(entry))
- and model_policy.credential_enabled(CONFIG, entry) and _in_region(profile, region)}
+ key = (region, getattr(pool, "revision", 0), model_policy.revision(CONFIG, policy))
+ cached = _configured_cache.get(key)
+ if cached is not None:
+ return cached
+ profiles = frozenset(profile for entry in pool.entries() if (profile := pool._entry_profile(entry))
+ and model_policy.credential_enabled(CONFIG, entry, policy)
+ and _in_region(profile, region))
+ _configured_cache.clear()
+ _configured_cache[key] = profiles
+ return profiles
cm = CONFIG.get("cred")
if cm is not None:
profile = cm.summary()["profile"]
@@ -2678,16 +2736,6 @@ def _usable_models(models):
and not model.get("disabled")]
-def _account_scope(account: dict, scope: str = "models") -> list[dict] | None:
- """Merge root candidates into selector models while retaining selector metadata."""
- picker = account.get("models")
- if scope == "models" or picker is None:
- return picker
- seen = {item.get("id") for item in picker}
- # Root entries supply missing names without replacing selector metadata.
- return picker + [item for item in account.get("serves") or [] if item.get("id") not in seen]
-
-
def _shared_catalog_sources(scope="models"):
accounts, pool = CONFIG.get("account_catalogs"), CONFIG.get("cred_pool")
if accounts is not None or CONFIG.get("model_cache") is not None:
@@ -2701,6 +2749,8 @@ def _shared_catalog_sources(scope="models"):
def _effective_account_scope(account, scope="models", *, model_id=None):
+ # Account-level scopes stay live so a capability change is rechecked inside a request;
+ # the published profile catalogs below come from the indexed snapshot.
native = _account_scope(account, scope)
profile = account.get("profile")
if profile not in SHARED_INTL_PROFILES or native is None:
@@ -2709,11 +2759,13 @@ def _effective_account_scope(account, scope="models", *, model_id=None):
-def _models_for_profile(profile: str, configured=None, *, scope: str = "models", model_id=None) -> list[dict]:
+def _models_for_profile(profile: str, configured=None, *, scope: str = "models", model_id=None,
+ policy=None) -> list[dict]:
models = _catalog_for(profile, scope, model_id=model_id)
if models is None:
# Static fallback is limited to legacy domestic CLI deployments.
- configured = _configured_profiles(profile_region(profile)) if configured is None else configured
+ configured = (_configured_profiles(profile_region(profile), policy)
+ if configured is None else configured)
return ([{"id": name, "supportsToolCall": True} for name in DEFAULT_MODELS]
if CONFIG.get("model_cache") is None and CONFIG.get("account_catalogs") is None
and profile == "cn-cli" and configured <= {"cn-cli"} else [])
@@ -2756,21 +2808,23 @@ def _model_free(models, model: str | None, profile: str) -> bool:
for item in models or [])
-def _model_profiles(model: str | None, region: str | None = None, configured=None) -> set[str]:
- configured = _configured_profiles(region) if configured is None else configured
+def _model_profiles(model: str | None, region: str | None = None, configured=None, *, policy=None) -> set[str]:
+ configured = _configured_profiles(region, policy) if configured is None else configured
profiles = {profile for profile in PROFILE_ENDPOINTS if _in_region(profile, region)}
if not model:
return profiles
supported = {profile for profile in profiles
if any(item["id"] == _upstream_model(model, profile)
- for item in _models_for_profile(profile, configured, scope="serves", model_id=_upstream_model(model, profile)))}
+ for item in _models_for_profile(profile, configured, scope="serves",
+ model_id=_upstream_model(model, 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:
return {"cn-work"}
- if "cn-cli" in configured and _models_for_profile("cn-cli", configured):
+ if "cn-cli" in configured and _models_for_profile("cn-cli", configured, policy=policy):
return {"cn-cli"}
- if model == "auto" and region is None and "cn-cli" in configured and _models_for_profile("cn-cli", configured):
+ if (model == "auto" and region is None and "cn-cli" in configured
+ and _models_for_profile("cn-cli", configured, policy=policy)):
supported.add("cn-cli")
if model != "auto" and not supported and not CONFIG.get("model_guard") and len(configured) == 1:
return configured
@@ -2806,8 +2860,35 @@ def _positive(entry):
for entry in pool.entries() if model_policy.credential_enabled(CONFIG, entry))
+def _model_table_revisions() -> tuple:
+ """Return the revisions that a published model table depends on."""
+ pool = CONFIG.get("cred_pool")
+ ledger = CONFIG.get("ledger")
+ index = CONFIG.get("catalog_index")
+ return (getattr(pool, "revision", 0), model_policy.revision(CONFIG),
+ getattr(index, "revision", 0), getattr(ledger, "revision", 0))
+
+
+def _model_table(name, region, build):
+ """Reuse a published model table until a revision moves or the short TTL lapses."""
+ key = (name, region, *_model_table_revisions())
+ cached = _model_table_cache.get(key)
+ if cached is not None and cached[0] > time.monotonic():
+ perf_metrics.inc("model_table_hits")
+ return cached[1]
+ value = build(region)
+ _model_table_cache.clear()
+ _model_table_cache[key] = (time.monotonic() + _MODEL_TABLE_TTL, value)
+ perf_metrics.inc("model_table_builds")
+ return value
+
+
def current_models(region: str | None = None) -> list[str]:
"""Merge models eligible for current accounts without region-specific client URLs."""
+ return _model_table("models", region, _build_current_models)
+
+
+def _build_current_models(region: str | None = None) -> list[str]:
pool = CONFIG.get("cred_pool")
with pool._lock if pool is not None else nullcontext():
configured = _configured_profiles(region)
@@ -2853,6 +2934,10 @@ def current_models(region: str | None = None) -> list[str]:
def current_model_details(region: str | None = None) -> list[dict]:
"""Return public model rates with per-profile values and the minimum eligible rate."""
+ return _model_table("details", region, _build_current_model_details)
+
+
+def _build_current_model_details(region: str | None = None) -> list[dict]:
pool = CONFIG.get("cred_pool")
details: dict[str, dict] = {}
declarations = {}
diff --git a/tests/test_routing_snapshot.py b/tests/test_routing_snapshot.py
new file mode 100644
index 0000000..0cb8eec
--- /dev/null
+++ b/tests/test_routing_snapshot.py
@@ -0,0 +1,109 @@
+"""One policy revision per routing decision, indexed catalogs and a revision-keyed model table."""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution.
+
+import json
+import os
+import tempfile
+import time
+import unittest
+from unittest.mock import patch
+
+import converter
+from app import model_policy, perf_metrics
+from app.control_store import ControlStore
+from app.credits import CreditLedger
+
+MODELS = [{"id": "glm-5.3-flash", "supportsToolCall": True, "credits": "x0 credits"},
+ {"id": "deepseek-v4-flash", "supportsToolCall": True, "credits": "x1 credits"}]
+
+
+class RoutingSnapshotTests(unittest.TestCase):
+ def setUp(self):
+ temporary = self.enterContext(tempfile.TemporaryDirectory())
+ self.enterContext(patch.dict(os.environ, {"CODEBUDDY_AUTH_DIR": temporary}))
+ self.enterContext(patch.object(perf_metrics, "_COUNTERS", {}))
+ self.enterContext(patch.object(perf_metrics, "_TIMINGS", {}))
+ self.root = Path(temporary)
+ self.store = ControlStore(self.root / "control.sqlite3")
+ self.addCleanup(self.store.close)
+ self.path = self.write_credential("account.info", "synthetic")
+ self.pool = converter.CredentialPool([self.path])
+ self.ledger = CreditLedger(self.root / "ledger.json")
+ self.pool.set_ledger(self.ledger)
+ entry = self.pool.entries()[0]
+ self.identity = entry["account_key"]
+ self.ledger.bind_identity(entry["id"], self.identity)
+ self.ledger.update_credits(entry["id"], {"credits": 100, "intl": False, "segments": []})
+ self.accounts = {self.identity: {"profile": "cn-cli",
+ "models": [dict(item) for item in MODELS],
+ "serves": [dict(item) for item in MODELS]}}
+ converter.invalidate_model_table()
+ converter._configured_cache.clear()
+ self.enterContext(patch.dict(converter.CONFIG, {
+ "cred_pool": self.pool, "ledger": self.ledger, "control_store": self.store,
+ "account_catalogs": self.accounts, "catalog_index": converter._publish_catalog_index(self.accounts),
+ "model_catalogs": {}, "model_cache": None, "model_guard": True, "log_path": None,
+ "max_inflight_per_account": 0}))
+
+ def write_credential(self, name, uid):
+ path = self.root / name
+ path.write_text(json.dumps({"account": {"uid": uid}, "auth": {
+ "accessToken": "synthetic-token", "domain": "www.codebuddy.cn",
+ "expiresAt": (time.time() + 3600) * 1000}}), encoding="utf-8")
+ return path
+
+ def test_one_policy_snapshot_per_decision(self):
+ self.assertIsNotNone(self.pool.headers_for(None, "glm-5.3-flash"))
+ self.assertEqual(perf_metrics.snapshot()["counters"]["policy_snapshot_calls"], 1)
+ self.assertIsNotNone(self.pool.pick("session", "glm-5.3-flash"))
+ self.assertEqual(perf_metrics.snapshot()["counters"]["policy_snapshot_calls"], 2)
+
+ def test_policy_view_reuses_one_revision_and_sees_new_publishes(self):
+ policy = model_policy.view(converter.CONFIG)
+ entry = self.pool.entries()[0]
+ self.assertEqual(policy.revision, self.store.revision)
+ self.assertTrue(model_policy.credential_enabled(converter.CONFIG, entry, policy))
+ self.store.set_credential(self.identity, False)
+ self.assertTrue(model_policy.credential_enabled(converter.CONFIG, entry, policy)) # Old revision kept.
+ self.assertFalse(model_policy.view(converter.CONFIG).credential(entry)["enabled"])
+ self.assertIsNone(self.pool.pick("session", "glm-5.3-flash"))
+
+ def test_revision_reads_do_not_copy_the_snapshot(self):
+ self.assertEqual(model_policy.revision(converter.CONFIG), self.store.revision)
+ self.assertEqual(perf_metrics.snapshot()["counters"].get("policy_snapshot_calls", 0), 0)
+ self.assertEqual(self.store.revision, self.store.snapshot()["revision"])
+
+ def test_model_table_is_reused_until_a_revision_moves(self):
+ first = converter.current_models()
+ self.assertEqual(first[:2], ["glm-5.3-flash", "deepseek-v4-flash"])
+ self.assertEqual(converter.current_models(), first)
+ counters = perf_metrics.snapshot()["counters"]
+ self.assertEqual(counters["model_table_builds"], 1)
+ self.assertEqual(counters["model_table_hits"], 1)
+ entry = self.pool.entries()[0]
+ self.ledger.update_credits(entry["id"], {"credits": 0, "intl": False, "segments": []})
+ self.assertEqual(converter.current_models()[:1], ["glm-5.3-flash"]) # Zero balance keeps free models only.
+ self.assertEqual(perf_metrics.snapshot()["counters"]["model_table_builds"], 2)
+
+ def test_catalog_republish_rebuilds_the_model_table(self):
+ converter.current_models()
+ accounts = {self.identity: {"profile": "cn-cli", "models": [dict(MODELS[1])],
+ "serves": [dict(MODELS[1])]}}
+ converter.CONFIG["account_catalogs"] = accounts
+ converter.CONFIG["catalog_index"] = converter._publish_catalog_index(accounts)
+ self.assertEqual(converter.current_models()[:1], ["deepseek-v4-flash"])
+
+ def test_configured_profiles_are_cached_between_decisions(self):
+ converter._configured_profiles(None)
+ with patch.object(self.pool, "entries", wraps=self.pool.entries) as entries:
+ self.assertEqual(set(converter._configured_profiles(None)), {"cn-cli"})
+ self.assertEqual(entries.call_count, 0)
+ self.store.set_credential(self.identity, False)
+ self.assertEqual(set(converter._configured_profiles(None)), set())
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
From 8184dfce89b66cfc2fd6ef148b663800addcfb3c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?=
<277378677+maiphucgiang@users.noreply.github.com>
Date: Sun, 18 Oct 2026 12:34:50 +0800
Subject: [PATCH 04/13] Add revision-keyed routing shards with legacy and
shadow modes
---
app/admin_api.py | 7 +-
app/routing_scheduler.py | 69 ++++++++++++
app/settings.py | 3 +
benchmarks/bench_routing.py | 4 +-
converter.py | 23 +++-
tests/test_perf_metrics.py | 2 +-
tests/test_routing_scheduler.py | 185 ++++++++++++++++++++++++++++++++
7 files changed, 286 insertions(+), 7 deletions(-)
create mode 100644 app/routing_scheduler.py
create mode 100644 tests/test_routing_scheduler.py
diff --git a/app/admin_api.py b/app/admin_api.py
index bd5622b..4795f90 100644
--- a/app/admin_api.py
+++ b/app/admin_api.py
@@ -691,6 +691,11 @@ def build_dashboard():
@route("GET", "/admin/performance")
async def performance_get(request):
"""Read-only process counters; no routing state is exposed or mutated."""
- return JSONResponse(perf_metrics.snapshot())
+ payload = perf_metrics.snapshot()
+ pool = config.get("cred_pool")
+ scheduler = getattr(pool, "_scheduler", None)
+ payload["scheduler"] = {"mode": config.get("routing_scheduler", "indexed"),
+ **(scheduler.report() if scheduler is not None else {"shards": [], "mismatches": []})}
+ return JSONResponse(payload)
return auth
diff --git a/app/routing_scheduler.py b/app/routing_scheduler.py
new file mode 100644
index 0000000..5df2a62
--- /dev/null
+++ b/app/routing_scheduler.py
@@ -0,0 +1,69 @@
+"""Revision-keyed credential shards for request-side selection."""
+from __future__ import annotations
+
+import time
+
+from . import perf_metrics
+
+MODES = ("legacy", "shadow", "indexed")
+MISMATCH_LIMIT = 20
+
+
+class RoutingScheduler:
+ """Serve the statically eligible entries per model; dynamic health stays per request.
+
+ ``legacy`` evaluates every credential on every request, ``indexed`` reuses a shard built
+ for the current revision set, and ``shadow`` does both while returning the legacy answer.
+ """
+
+ __slots__ = ("_shards", "mismatches")
+
+ def __init__(self):
+ self._shards: dict[tuple, tuple] = {}
+ self.mismatches: list[dict] = []
+
+ @staticmethod
+ def mode(config) -> str:
+ """Resolve the configured mode, falling back to the safe indexed path."""
+ value = config.get("routing_scheduler", "indexed")
+ return value if value in MODES else "indexed"
+
+ def clear(self):
+ self._shards.clear()
+
+ def candidates(self, *, mode, revisions, model, region, entries, evaluate):
+ """Return statically eligible entries for one model and region."""
+ if mode == "legacy":
+ return [entry for entry in entries if evaluate(entry)] # No shard is built.
+ key = (model, region, *revisions)
+ shard = self._shards.get(key)
+ if shard is None:
+ shard = tuple(entry for entry in entries if evaluate(entry))
+ # Keep only the current revision set; a moved revision never reuses stale entries.
+ self._shards = {key: shard}
+ perf_metrics.inc("shard_builds")
+ perf_metrics.inc("shard_size_rebuilt", len(shard))
+ else:
+ perf_metrics.inc("shard_hits")
+ if mode == "shadow":
+ legacy = [entry for entry in entries if evaluate(entry)]
+ if legacy != list(shard):
+ self.record(model, region, legacy, shard)
+ return legacy
+ return list(shard)
+
+ def record(self, model, region, legacy, indexed):
+ """Retain a bounded, credential-free description of one shadow mismatch."""
+ perf_metrics.inc("scheduler_mismatches")
+ self.mismatches.append({
+ "at": time.time(), "model": model, "region": region,
+ "legacy": [entry["id"] for entry in legacy],
+ "indexed": [entry["id"] for entry in indexed]})
+ del self.mismatches[:-MISMATCH_LIMIT]
+
+ def report(self) -> dict:
+ """Describe the active shard set without exposing tokens."""
+ return {"shards": [
+ {"model": key[0], "region": key[1], "size": len(value)}
+ for key, value in self._shards.items()],
+ "mismatches": list(self.mismatches)}
diff --git a/app/settings.py b/app/settings.py
index d3d19c6..e0763c3 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -97,6 +97,9 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum=
"credit_price_usd": _item(0.03, "number", "国际积分单价", minimum=0),
"model_catalog_ttl": _item(21600, "integer", "模型目录缓存秒数", minimum=0, maximum=31536000),
"model_guard": _item(True, "boolean", "表外模型拦截"),
+ "routing_scheduler": _item("indexed", "string", "路由调度模式",
+ env="CODEBUDDY2API_ROUTING_SCHEDULER",
+ choices=["legacy", "shadow", "indexed"]),
"model_capability_guard": _item(True, "boolean", "模型能力预检", env="CODEBUDDY2API_MODEL_CAPABILITY_GUARD"),
"max_images": _item(16, "integer", "单请求图片上限", env="CODEBUDDY2API_MAX_IMAGES", minimum=0, maximum=10000),
"image_policy": _item("truncate", "string", "超额图片策略", env="CODEBUDDY2API_IMAGE_POLICY", choices=["truncate", "error"]),
diff --git a/benchmarks/bench_routing.py b/benchmarks/bench_routing.py
index 131b0c4..84c8e89 100644
--- a/benchmarks/bench_routing.py
+++ b/benchmarks/bench_routing.py
@@ -73,6 +73,7 @@ def build(converter, credits_mod, count: int, root: Path):
merged = catalogs[account["profile"]] = catalogs[account["profile"]] or []
merged.extend(account["models"])
config.update({"cred_pool": pool, "ledger": ledger, "account_catalogs": accounts,
+ "catalog_index": converter._publish_catalog_index(accounts),
"model_catalogs": catalogs, "models_remote": catalogs["cn-cli"],
"models_intl": catalogs["intl-cli"], "model_cache": None,
"model_guard": True, "max_inflight_per_account": 0,
@@ -129,7 +130,8 @@ def saturated():
finally:
temporary.cleanup()
converter.CONFIG.update({"cred_pool": None, "ledger": None, "account_catalogs": None,
- "model_catalogs": {}, "models_remote": None, "models_intl": None})
+ "catalog_index": None, "model_catalogs": {},
+ "models_remote": None, "models_intl": None})
return rows
diff --git a/converter.py b/converter.py
index 6fdb798..33499a0 100644
--- a/converter.py
+++ b/converter.py
@@ -74,6 +74,7 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False,
from app.adapters.chat_input import normalize_chat_messages
from app.model_catalog_view import INTERNATIONAL as SHARED_INTL_PROFILES, share_models
from app.catalog_index import CatalogIndex
+from app.routing_scheduler import RoutingScheduler
from app.catalog_index import account_scope as _account_scope
from app.inference_auth import require_api_key
from app.admin_auth import SessionStoreError
@@ -576,6 +577,7 @@ def __init__(self, paths: list[Path] | None = None, scan: bool = False,
self._index_cm: dict = {}
self._index_identity: dict[str, list[dict]] = {}
self.revision = next(_POOL_REVISION) # Bumped whenever the entry list is rebuilt.
+ self._scheduler = RoutingScheduler() # Revision-keyed eligibility shards.
# Explicit paths must survive rejection or eviction so a repaired file re-enters.
self._configured: list[str] = []
self._ignored_duplicates: set[str] = set()
@@ -688,6 +690,7 @@ def reload(self, paths: list[Path], *, reset: bool = True):
def _rebuild_index(self):
"""Rebuild request-side lookups after the entry list changed; never called per request."""
self.revision = next(_POOL_REVISION)
+ self._scheduler.clear()
self._index_id = {entry["id"]: entry for entry in self._entries}
self._index_cm = {entry["cm"]: entry for entry in self._entries}
identity: dict[str, list[dict]] = {}
@@ -1149,13 +1152,21 @@ def _evict_sticky(self):
else:
break
+ def _shard_revisions(self, policy=None) -> tuple:
+ """Revisions that change static eligibility; any move invalidates every shard."""
+ return (self.revision, model_policy.revision(CONFIG, policy), _catalog_revision,
+ getattr(self._ledger, "revision", 0))
+
def _candidates(self, model: str | None, *, region=None, tried=(), policy=None) -> list[dict]:
"""Exclude tried credentials and rank candidates by zero rate and credit expiry."""
- perf_metrics.inc("candidate_checks", len(self._entries))
tried = set(tried)
- healthy = [entry for entry in self._entries if entry["cm"] not in tried
+ eligible = self._scheduler.candidates(
+ mode=RoutingScheduler.mode(CONFIG), revisions=self._shard_revisions(policy),
+ model=model, region=region, entries=self._entries,
+ evaluate=lambda entry: self._eligible(entry, model, region=region, policy=policy))
+ perf_metrics.inc("candidate_checks", len(eligible))
+ healthy = [entry for entry in eligible if entry["cm"] not in tried
and self._healthy(entry, policy)
- and self._eligible(entry, model, region=region, policy=policy)
and self._model_healthy(entry, model)
and self._model_servable(entry, model)]
if not healthy:
@@ -2080,6 +2091,7 @@ async def _protocol_http_exception(request: Request, exc: HTTPException):
"account_catalogs": None, # Account-scoped models/serves; None enables legacy embedding
"trial_ledger": None,
"model_guard": True, # Reject models absent from authorized catalogs
+ "routing_scheduler": "indexed", # legacy | shadow | indexed eligibility shards
"max_images": 16, "image_policy": "truncate",
"max_request_bytes": 32 * 1024 * 1024, "log_body_limit": 65536,
"max_inbound_bytes": 64 * 1024 * 1024,
@@ -4682,6 +4694,9 @@ def main():
ap.add_argument("--stream-mode", choices=_STREAM_MODES,
default=os.environ.get("CODEBUDDY2API_STREAM_MODE", "compatible"),
help="流式传输:compatible 保持兼容聚合策略,realtime 增量发送且不重生成工具参数;默认 compatible")
+ ap.add_argument("--routing-scheduler", choices=("legacy", "shadow", "indexed"),
+ default=os.environ.get("CODEBUDDY2API_ROUTING_SCHEDULER", "indexed"),
+ help="路由调度:indexed 复用按模型分片(默认);shadow 记录新旧差异并仍返回旧结果;legacy 每次全量判断")
ap.add_argument("--model-capability-guard", type=_boolean_arg, nargs="?", const=True,
default=os.environ.get("CODEBUDDY2API_MODEL_CAPABILITY_GUARD", "true"),
help="按账号模型声明预检图片、工具、思考和输出上限;false 仅关闭新增能力预检")
@@ -4719,7 +4734,7 @@ def main():
"tool_call_max_retry", "max_inbound_bytes", "max_collect_bytes", "max_concurrent",
"failover_max", "retry_write_timeout", "upstream_keepalive", "max_inflight_per_account",
"request_context_mode", "stream_mode", "model_capability_guard", "admin_allowed_origins",
- "responses_projection_mode", "responses_projection_max_bytes"):
+ "responses_projection_mode", "responses_projection_max_bytes", "routing_scheduler"):
CONFIG[key] = getattr(args, key)
CONFIG["api_key"] = args.api_key
CONFIG["desensitize"] = args.desensitize
diff --git a/tests/test_perf_metrics.py b/tests/test_perf_metrics.py
index 252fbb0..e37b267 100644
--- a/tests/test_perf_metrics.py
+++ b/tests/test_perf_metrics.py
@@ -108,7 +108,7 @@ def test_requires_admin_login_and_returns_counters_only(self):
result = self.client.get("/admin/performance")
self.assertEqual(result.status_code, 200, result.text)
body = result.json()
- self.assertEqual(set(body), {"uptime_seconds", "counters", "timings"})
+ self.assertEqual(set(body), {"uptime_seconds", "counters", "timings", "scheduler"})
self.assertGreaterEqual(body["counters"]["route_pick_duration_marker"], 1)
self.assertNotIn("synthetic-key", result.text)
diff --git a/tests/test_routing_scheduler.py b/tests/test_routing_scheduler.py
new file mode 100644
index 0000000..1797274
--- /dev/null
+++ b/tests/test_routing_scheduler.py
@@ -0,0 +1,185 @@
+"""Revision-keyed shards: caching, invalidation, and behavioural parity with the legacy scan."""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution.
+
+import json
+import os
+import tempfile
+import time
+import unittest
+from unittest.mock import patch
+
+import converter
+from app import perf_metrics
+from app.credits import CreditLedger
+from app.routing_scheduler import MISMATCH_LIMIT, RoutingScheduler
+
+PROFILES = ("cn-cli", "cn-work", "intl-cli", "intl-work")
+DOMAINS = {"cn-cli": "www.codebuddy.cn", "cn-work": "www.workbuddy.cn",
+ "intl-cli": "www.codebuddy.ai", "intl-work": "www.workbuddy.ai"}
+MODELS = {
+ "cn-cli": ["glm-5.3-flash", "deepseek-v4-flash"],
+ "cn-work": ["auto", "glm-5.3-flash"],
+ "intl-cli": ["default-model", "glm-5.3-flash"],
+ "intl-work": ["default-model", "glm-5.3-flash"],
+}
+
+
+class SchedulerUnitTests(unittest.TestCase):
+ def setUp(self):
+ self.enterContext(patch.object(perf_metrics, "_COUNTERS", {}))
+ self.enterContext(patch.object(perf_metrics, "_TIMINGS", {}))
+ self.entries = [{"id": f"/cred-{index}"} for index in range(4)]
+
+ def test_legacy_never_reuses_a_shard(self):
+ scheduler = RoutingScheduler()
+ calls = []
+ evaluate = lambda entry: calls.append(entry) or entry["id"] != "/cred-0"
+ first = scheduler.candidates(mode="legacy", revisions=(1,), model="m", region=None,
+ entries=self.entries, evaluate=evaluate)
+ second = scheduler.candidates(mode="legacy", revisions=(1,), model="m", region=None,
+ entries=self.entries, evaluate=evaluate)
+ self.assertEqual([entry["id"] for entry in first], ["/cred-1", "/cred-2", "/cred-3"])
+ self.assertEqual(first, second)
+ self.assertEqual(len(calls), 8)
+ self.assertEqual(scheduler.report()["shards"], [])
+
+ def test_indexed_builds_once_per_revision_set(self):
+ scheduler = RoutingScheduler()
+ calls = []
+ evaluate = lambda entry: calls.append(entry) or True
+ for revision in ((1, 2, 3, 4), (1, 2, 3, 4), (1, 2, 3, 5)):
+ scheduler.candidates(mode="indexed", revisions=revision, model="m", region=None,
+ entries=self.entries, evaluate=evaluate)
+ self.assertEqual(len(calls), 8) # Two builds, then a reuse.
+ self.assertEqual(perf_metrics.snapshot()["counters"]["shard_builds"], 2)
+ self.assertEqual(perf_metrics.snapshot()["counters"]["shard_hits"], 1)
+
+ def test_shadow_returns_legacy_and_records_mismatches(self):
+ scheduler = RoutingScheduler()
+ allowed = {"value": 0}
+ evaluate = lambda entry: allowed["value"] == 0
+ scheduler.candidates(mode="indexed", revisions=(1,), model="m", region=None,
+ entries=self.entries, evaluate=evaluate)
+ allowed["value"] = 1 # The index now disagrees with a live scan.
+ result = scheduler.candidates(mode="shadow", revisions=(1,), model="m", region=None,
+ entries=self.entries, evaluate=evaluate)
+ self.assertEqual(result, [])
+ report = scheduler.report()
+ self.assertEqual(report["mismatches"][0]["legacy"], [])
+ self.assertEqual(len(report["mismatches"][0]["indexed"]), 4)
+ self.assertEqual(perf_metrics.snapshot()["counters"]["scheduler_mismatches"], 1)
+ for index in range(MISMATCH_LIMIT + 5):
+ scheduler.record("m", None, [], self.entries)
+ self.assertEqual(len(scheduler.report()["mismatches"]), MISMATCH_LIMIT)
+
+
+class SchedulerPoolTests(unittest.TestCase):
+ def setUp(self):
+ temporary = self.enterContext(tempfile.TemporaryDirectory())
+ self.enterContext(patch.dict(os.environ, {"CODEBUDDY_AUTH_DIR": temporary}))
+ self.root = Path(temporary)
+
+ def build(self, count, mode="indexed"):
+ paths = []
+ for index in range(count):
+ profile = PROFILES[index % len(PROFILES)]
+ path = self.root / f"account-{index}.info"
+ path.write_text(json.dumps({"account": {"uid": f"shard-{index}"}, "auth": {
+ "accessToken": f"token-{index}", "domain": DOMAINS[profile],
+ "expiresAt": (time.time() + 3600) * 1000}}), encoding="utf-8")
+ paths.append(path)
+ pool = converter.CredentialPool(paths)
+ ledger = CreditLedger(self.root / f"ledger-{mode}.json")
+ pool.set_ledger(ledger)
+ accounts = {}
+ for index, entry in enumerate(pool.entries()):
+ profile = entry["profile"]
+ ledger.bind_identity(entry["id"], entry["account_key"])
+ ledger.update_credits(entry["id"], {"credits": 100 + index, "intl": profile.startswith("intl"),
+ "segments": [{"remaining": 100, "expires_at": time.time() + 60 + index}]})
+ models = [{"id": name, "supportsToolCall": True,
+ "credits": "x0 credits" if name == "glm-5.3-flash" else "x1 credits"}
+ for name in MODELS[profile]]
+ accounts[entry["account_key"]] = {"profile": profile, "models": models, "serves": models}
+ converter._configured_cache.clear()
+ converter.invalidate_model_table()
+ self.enterContext(patch.dict(converter.CONFIG, {
+ "cred_pool": pool, "ledger": ledger, "control_store": None, "model_cache": None,
+ "account_catalogs": accounts, "catalog_index": converter._publish_catalog_index(accounts),
+ "model_catalogs": {}, "model_guard": True, "log_path": None, "routing_scheduler": mode,
+ "max_inflight_per_account": 0}))
+ return pool, ledger
+
+ def script(self, pool, ledger):
+ """Deterministic routing script exercising sticky, cooldown, tried and capacity paths."""
+ observed = []
+ pick = lambda skey=None, **kw: (pool.pick(skey, "glm-5.3-flash", **kw) or None)
+ for _ in range(6):
+ manager = pick()
+ observed.append(Path(manager.path).name)
+ for _ in range(4):
+ observed.append(Path(pick(skey="session").path).name)
+ first = pool.entries()[0]
+ pool.note_status(first["cm"], 429, model="glm-5.3-flash")
+ observed.append(Path(pick().path).name)
+ observed.append(Path(pick(tried=(first["cm"],)).path).name)
+ pool.cooldown(pool.entries()[1]["cm"], reason="synthetic")
+ observed.append(Path(pick().path).name)
+ ledger.update_credits(pool.entries()[2]["id"], {"credits": 0, "intl": False, "segments": []})
+ observed.append(Path(pick().path).name)
+ with patch.dict(converter.CONFIG, {"max_inflight_per_account": 1}):
+ retained = []
+ while True:
+ manager = pick(with_capacity=True) if not retained else None
+ if manager is None:
+ break
+ retained.append(Path(manager.path).name)
+ observed.extend(retained)
+ try:
+ pick(with_capacity=True)
+ except converter.HTTPException as error:
+ observed.append(str(error.status_code))
+ return observed
+
+ def test_modes_choose_the_same_credentials(self):
+ results = {}
+ for mode in ("legacy", "indexed", "shadow"):
+ with patch.object(perf_metrics, "_COUNTERS", {}):
+ pool, ledger = self.build(12, mode)
+ results[mode] = self.script(pool, ledger)
+ self.assertEqual(pool._scheduler.report()["mismatches"], [], mode)
+ self.assertEqual(results["legacy"], results["indexed"])
+ self.assertEqual(results["legacy"], results["shadow"])
+ self.assertGreater(len(results["legacy"]), 10)
+
+ def test_second_pick_reuses_the_shard_without_rechecking_eligibility(self):
+ pool, _ = self.build(8)
+ pool.pick("session", "glm-5.3-flash")
+ with patch.object(converter.CredentialPool, "_eligible", wraps=pool._eligible) as eligible:
+ pool.pick("session", "glm-5.3-flash")
+ self.assertEqual(eligible.call_count, 0)
+ self.assertEqual(perf_metrics.snapshot()["counters"]["shard_hits"], 1)
+
+ def test_credential_reload_invalidates_the_shard(self):
+ pool, _ = self.build(4)
+ pool.pick("session", "glm-5.3-flash")
+ revision = pool._shard_revisions()
+ pool.reload([Path(entry["id"]) for entry in pool.entries()], reset=False)
+ self.assertNotEqual(pool._shard_revisions(), revision)
+ with patch.object(converter.CredentialPool, "_eligible", wraps=pool._eligible) as eligible:
+ pool.pick("session", "glm-5.3-flash")
+ self.assertGreater(eligible.call_count, 0)
+
+ def test_balance_change_invalidates_the_shard(self):
+ pool, ledger = self.build(4)
+ pool.pick("session", "glm-5.3-flash")
+ revision = pool._shard_revisions()
+ ledger.update_credits(pool.entries()[0]["id"], {"credits": 0, "intl": False, "segments": []})
+ self.assertNotEqual(pool._shard_revisions(), revision)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
From a395d5de30ef006f19297a2f974e91e69ea01e85 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?=
<277378677+maiphucgiang@users.noreply.github.com>
Date: Sun, 18 Oct 2026 12:41:50 +0800
Subject: [PATCH 05/13] Probe one representative catalog per profile and add a
manual full scan
---
.env.example | 7 +
app/admin_api.py | 12 ++
app/catalog_probe.py | 153 ++++++++++++++++++++++
app/model_policy.py | 7 +
app/routing_scheduler.py | 10 +-
app/settings.py | 3 +
app/state_store.py | 8 +-
converter.py | 219 +++++++++++++++++++++++++++++---
docs/advanced.md | 6 +
docs/advanced.zh-CN.md | 6 +
tests/test_admin_api.py | 19 +++
tests/test_catalog_probe.py | 204 +++++++++++++++++++++++++++++
tests/test_routing_scheduler.py | 30 ++++-
tests/test_sqlite_state.py | 8 +-
web/src/api.ts | 33 +++++
web/src/pages/Models.tsx | 51 +++++++-
16 files changed, 750 insertions(+), 26 deletions(-)
create mode 100644 app/catalog_probe.py
create mode 100644 tests/test_catalog_probe.py
diff --git a/.env.example b/.env.example
index e90e967..a3e728e 100644
--- a/.env.example
+++ b/.env.example
@@ -104,6 +104,13 @@ CODEBUDDY2API_MAX_CONCURRENT=64
# Disable only the declared model-capability preflight; other safeguards and Intl image merging remain.
# 只关闭声明的模型能力预检;其他保护与国际站图片合并仍然生效。
# CODEBUDDY2API_MODEL_CAPABILITY_GUARD=true
+# Routing scheduler: indexed reuses per-model shards, shadow records legacy/indexed differences
+# while returning the legacy answer, legacy rescans every credential on each request.
+# 路由调度:indexed 复用按模型分片(默认),shadow 记录新旧差异但仍返回旧结果,legacy 每次全量判断。
+# CODEBUDDY2API_ROUTING_SCHEDULER=indexed
+# Catalog probing: representative probes one account per product per TTL cycle; account refreshes all.
+# 目录探测:representative 每个产品每周期只探测一个代表账号(默认),account 逐个账号刷新。
+# CODEBUDDY2API_CATALOG_PROBE_MODE=representative
# Retired: this setting only warns; all persistent logs use logs.sqlite3.
# 已停用:该项只会给出告警;所有持久日志都写入 logs.sqlite3。
diff --git a/app/admin_api.py b/app/admin_api.py
index 4795f90..2282447 100644
--- a/app/admin_api.py
+++ b/app/admin_api.py
@@ -339,9 +339,21 @@ def build_models():
"model_guard_locked": bool(resolved.get("model_guard", {}).get("locked")),
"model_capability_guard": config.get("model_capability_guard", True),
"model_capability_guard_locked": bool(resolved.get("model_capability_guard", {}).get("locked")),
+ "catalog": gateway.catalog_status(),
})
return await run_in_threadpool(build_models)
+ @route("POST", "/admin/models/scan")
+ async def models_scan_start(request):
+ """Start one user-triggered full catalog scan; it is never a periodic task."""
+ result = await run_in_threadpool(gateway.start_catalog_scan)
+ event("models.scan")
+ return JSONResponse(result)
+
+ @route("GET", "/admin/models/scan")
+ async def models_scan_state(request):
+ return JSONResponse(gateway.catalog_scan_view())
+
def checked_rule(source, data, *, creating=False):
if "custom" in data:
raise ValueError("custom 是只读字段")
diff --git a/app/catalog_probe.py b/app/catalog_probe.py
new file mode 100644
index 0000000..bf6a752
--- /dev/null
+++ b/app/catalog_probe.py
@@ -0,0 +1,153 @@
+"""Representative per-profile catalog probes with rotation, backoff and diff status."""
+from __future__ import annotations
+
+import math
+import threading
+import time
+
+SCHEMA_VERSION = 1
+PROFILES = ("cn-cli", "cn-work", "intl-cli", "intl-work")
+MAX_PROFILES = 8
+MAX_MODEL_IDS = 2000
+RETRY_BASE_S = 60
+RETRY_MAX_S = 1800
+_MAX_FAILURES = 16
+_FIELDS = {"source", "source_generation", "fetched_at", "retry_at", "failures", "status", "models"}
+
+
+def _time(value):
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0
+
+
+def _token(value):
+ return isinstance(value, str) and 0 < len(value) <= 160 and value.isprintable() and not any(c.isspace() for c in value)
+
+
+def valid_row(row):
+ """Validate one persisted profile probe row without ever touching credential material."""
+ if not isinstance(row, dict) or set(row) - _FIELDS:
+ return False
+ if "source" in row and row["source"] is not None and not _token(row["source"]):
+ return False
+ if not _time(row.get("fetched_at")) or not _time(row.get("retry_at")):
+ return False
+ failures = row.get("failures", 0)
+ if type(failures) is not int or not 0 <= failures <= _MAX_FAILURES:
+ return False
+ if row.get("status") not in ("ok", "error", "fresh"):
+ return False
+ if "source_generation" in row and type(row["source_generation"]) is not int:
+ return False
+ models = row.get("models", [])
+ return (isinstance(models, list) and len(models) <= MAX_MODEL_IDS
+ and all(isinstance(model, str) and 0 < len(model) <= 160 for model in models))
+
+
+class AggregateCatalog:
+ """One representative source per product profile, with rotation and retry backoff."""
+
+ def __init__(self, *, ttl=6 * 3600, store=None, clock=time.time):
+ self.ttl = max(60.0, float(ttl or 0))
+ self._store = store
+ self._clock = clock
+ self._lock = threading.Lock()
+ self._data = self._load()
+
+ def _load(self):
+ raw = self._store.get("catalog_probe") if self._store is not None else None
+ if (not isinstance(raw, dict) or raw.get("version") != SCHEMA_VERSION
+ or not isinstance(raw.get("profiles"), dict)):
+ return {"version": SCHEMA_VERSION, "profiles": {}}
+ profiles = {name: dict(row) for name, row in raw["profiles"].items()
+ if name in PROFILES and valid_row(row)}
+ return {"version": SCHEMA_VERSION, "profiles": profiles}
+
+ def _save(self):
+ if self._store is not None:
+ self._store.put_cache("catalog_probe", {"version": SCHEMA_VERSION, "profiles": self._data["profiles"]})
+
+ def row(self, profile):
+ """Return a copy of one profile row."""
+ with self._lock:
+ return dict(self._data["profiles"].get(profile) or {})
+
+ def profiles(self):
+ """Return a copy of every profile row for the management view."""
+ with self._lock:
+ return {name: dict(row) for name, row in self._data["profiles"].items()}
+
+ def due(self, profile, now=None):
+ """Whether this profile may probe again; a retry backoff keeps failures quiet."""
+ now = self._clock() if now is None else now
+ row = self.row(profile)
+ return not row or now >= float(row.get("retry_at") or 0.0)
+
+ @staticmethod
+ def _age(candidate, age_of):
+ """Treat an unknown catalog age as infinitely stale so a new account is probed."""
+ value = age_of(candidate)
+ return math.inf if value is None else float(value)
+
+ def choose(self, profile, candidates, *, age_of, now=None):
+ """Select the account to probe: the current source when due, else the stalest one."""
+ now = self._clock() if now is None else now
+ due = [candidate for candidate in candidates if self._age(candidate, age_of) >= self.ttl]
+ if not due:
+ return None
+ source = self.row(profile).get("source")
+ for candidate in due:
+ if candidate["account_key"] == source:
+ return candidate
+ # Unknown or oldest catalogs first, so rotation eventually covers every account.
+ return max(due, key=lambda candidate: self._age(candidate, age_of))
+
+ def success(self, profile, *, source, generation, models, now=None):
+ """Record one probe result and schedule the next TTL check."""
+ now = self._clock() if now is None else now
+ with self._lock:
+ self._data["profiles"][profile] = {
+ "source": source, "source_generation": int(generation or 0),
+ "fetched_at": now, "retry_at": now + self.ttl, "failures": 0, "status": "ok",
+ "models": sorted({str(model)[:160] for model in models if model})[:MAX_MODEL_IDS]}
+ self._save()
+
+ def fresh(self, profile, now=None):
+ """Record that the representative catalog is still inside its TTL."""
+ now = self._clock() if now is None else now
+ with self._lock:
+ row = dict(self._data["profiles"].get(profile) or {})
+ if not row:
+ return
+ row.update(status="fresh", fetched_at=now, retry_at=now + self.ttl, failures=0)
+ self._data["profiles"][profile] = row
+ self._save()
+
+ def failure(self, profile, *, source=None, now=None):
+ """Back off an unusable probe and keep the previous aggregate for the next attempt."""
+ now = self._clock() if now is None else now
+ with self._lock:
+ row = dict(self._data["profiles"].get(profile) or {})
+ failures = min(int(row.get("failures") or 0) + 1, _MAX_FAILURES)
+ row.update(source=source if source is not None else row.get("source"),
+ failures=failures, status="error",
+ retry_at=now + min(RETRY_BASE_S * 2 ** (failures - 1), RETRY_MAX_S))
+ row.setdefault("fetched_at", 0.0)
+ row.setdefault("models", [])
+ self._data["profiles"][profile] = row
+ self._save()
+
+ def model_ids(self, profile):
+ """Return the model identifiers the last successful probe advertised."""
+ return set(self.row(profile).get("models") or ())
+
+ def view(self, now=None):
+ """Describe probe state for the management API without exposing credentials."""
+ now = self._clock() if now is None else now
+ rows = {}
+ for profile, row in self.profiles().items():
+ rows[profile] = {
+ "source": row.get("source"), "status": row.get("status", "unknown"),
+ "models": len(row.get("models") or []), "failures": row.get("failures", 0),
+ "fetched_at": row.get("fetched_at"),
+ "next_probe_in": max(0, round(float(row.get("retry_at") or 0.0) - now))}
+ return {"ttl_seconds": self.ttl, "profiles": rows}
diff --git a/app/model_policy.py b/app/model_policy.py
index afa2d79..094162d 100644
--- a/app/model_policy.py
+++ b/app/model_policy.py
@@ -116,6 +116,13 @@ def _active(config, upstream, policy=None):
return rule_for(config, upstream, policy), None
+def active_rule(config, model, policy=None):
+ """Return the rule governing this request's model, including a request-scoped alias."""
+ if not model:
+ return default_rule("")
+ return _active(config, model, policy)[0]
+
+
def resolve(config, public_model):
"""Resolve one public name to a literal upstream ID while retaining its own policy."""
if not isinstance(public_model, str) or not public_model.strip():
diff --git a/app/routing_scheduler.py b/app/routing_scheduler.py
index 5df2a62..34b5eab 100644
--- a/app/routing_scheduler.py
+++ b/app/routing_scheduler.py
@@ -16,11 +16,12 @@ class RoutingScheduler:
for the current revision set, and ``shadow`` does both while returning the legacy answer.
"""
- __slots__ = ("_shards", "mismatches")
+ __slots__ = ("_shards", "mismatches", "_limit")
- def __init__(self):
+ def __init__(self, limit=32):
self._shards: dict[tuple, tuple] = {}
self.mismatches: list[dict] = []
+ self._limit = max(1, limit) # Aliases share one upstream model, so keep several shards.
@staticmethod
def mode(config) -> str:
@@ -39,8 +40,9 @@ def candidates(self, *, mode, revisions, model, region, entries, evaluate):
shard = self._shards.get(key)
if shard is None:
shard = tuple(entry for entry in entries if evaluate(entry))
- # Keep only the current revision set; a moved revision never reuses stale entries.
- self._shards = {key: shard}
+ if len(self._shards) >= self._limit:
+ self._shards.pop(next(iter(self._shards))) # A moved revision never matches again.
+ self._shards[key] = shard
perf_metrics.inc("shard_builds")
perf_metrics.inc("shard_size_rebuilt", len(shard))
else:
diff --git a/app/settings.py b/app/settings.py
index e0763c3..b3c7fe2 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -100,6 +100,9 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum=
"routing_scheduler": _item("indexed", "string", "路由调度模式",
env="CODEBUDDY2API_ROUTING_SCHEDULER",
choices=["legacy", "shadow", "indexed"]),
+ "catalog_probe_mode": _item("representative", "string", "目录探测模式",
+ env="CODEBUDDY2API_CATALOG_PROBE_MODE",
+ choices=["account", "representative"]),
"model_capability_guard": _item(True, "boolean", "模型能力预检", env="CODEBUDDY2API_MODEL_CAPABILITY_GUARD"),
"max_images": _item(16, "integer", "单请求图片上限", env="CODEBUDDY2API_MAX_IMAGES", minimum=0, maximum=10000),
"image_policy": _item("truncate", "string", "超额图片策略", env="CODEBUDDY2API_IMAGE_POLICY", choices=["truncate", "error"]),
diff --git a/app/state_store.py b/app/state_store.py
index ff35031..c7f4366 100644
--- a/app/state_store.py
+++ b/app/state_store.py
@@ -21,6 +21,7 @@
"credits": "credits-ledger.json",
"trial": "trial-ledger.json",
"catalog": "model-catalog.json",
+ "catalog_probe": "model-catalog-probe.json",
"usage": "usage-snapshots.json",
}
@@ -75,6 +76,7 @@ def _check_document(name, value):
"sessions": ("sessions", (1,)), "cooldowns": ("accounts", (1,)),
"model_blocks": ("blocks", (1,)), "credits": ("creds", (1,)),
"trial": ("accounts", (1,)), "catalog": ("groups", (1, 2)),
+ "catalog_probe": ("profiles", (1,)),
"usage": ("accounts", (1,)),
}[name]
if type(value.get("version")) is not int or value["version"] not in versions or not isinstance(value.get(field), dict):
@@ -146,6 +148,10 @@ def _check_document(name, value):
if (not isinstance(row, dict) or type(row.get("until")) not in (int, float)
or not math.isfinite(row["until"]) or type(row.get("hits", 1)) is not int):
raise ValueError("Invalid model backoff")
+ elif name == "catalog_probe":
+ from .catalog_probe import valid_row
+ if len(value[field]) > 8 or not all(valid_row(row) for row in value[field].values()):
+ raise ValueError("Invalid catalog probe state")
elif name == "catalog":
for row in value[field].values():
if (not isinstance(row, dict) or not isinstance(row.get("models"), list)
@@ -204,7 +210,7 @@ def put(self, name, value):
def put_cache(self, name, value):
"""Cache failures stay visible without replacing the upstream inference outcome."""
- if name not in ("catalog", "model_blocks"):
+ if name not in ("catalog", "catalog_probe", "model_blocks"):
raise ValueError("Not a rebuildable cache")
try:
self.put(name, value)
diff --git a/converter.py b/converter.py
index 33499a0..bc0bbca 100644
--- a/converter.py
+++ b/converter.py
@@ -75,6 +75,7 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False,
from app.model_catalog_view import INTERNATIONAL as SHARED_INTL_PROFILES, share_models
from app.catalog_index import CatalogIndex
from app.routing_scheduler import RoutingScheduler
+from app.catalog_probe import AggregateCatalog
from app.catalog_index import account_scope as _account_scope
from app.inference_auth import require_api_key
from app.admin_auth import SessionStoreError
@@ -1160,8 +1161,12 @@ def _shard_revisions(self, policy=None) -> tuple:
def _candidates(self, model: str | None, *, region=None, tried=(), policy=None) -> list[dict]:
"""Exclude tried credentials and rank candidates by zero rate and credit expiry."""
tried = set(tried)
+ # A request-scoped alias restricts the accounts, so its rule is part of the shard key.
+ rule = model_policy.active_rule(CONFIG, model, policy)
+ scope = (rule.get("public_id"), rule.get("region") or "", rule.get("profile") or "",
+ tuple(rule.get("credential_ids") or ()))
eligible = self._scheduler.candidates(
- mode=RoutingScheduler.mode(CONFIG), revisions=self._shard_revisions(policy),
+ mode=RoutingScheduler.mode(CONFIG), revisions=(*self._shard_revisions(policy), scope),
model=model, region=region, entries=self._entries,
evaluate=lambda entry: self._eligible(entry, model, region=region, policy=policy))
perf_metrics.inc("candidate_checks", len(eligible))
@@ -1752,29 +1757,41 @@ def _publish_model_cache():
invalidate_model_table()
-def _sync_model_catalogs(pool, ledger, refs, failed):
- cache = CONFIG.get("model_cache")
- if cache is None:
- return
+def _account_catalog_key(profile, headers):
+ return catalog_cache_key(profile, account_key(profile, headers.get("X-User-Id"),
+ headers.get("X-Enterprise-Id")))
+
+
+def _fetch_catalog(headers):
+ """Fetch both catalog scopes for one account token; the only upstream call involved."""
+ scopes = credits_mod.fetch_model_scopes(
+ _bearer_token(headers), domain=headers.get("X-Domain", ""),
+ uid=headers.get("X-User-Id", ""), enterprise_id=headers.get("X-Enterprise-Id", ""))
+ return scopes["picker"], scopes["account"]
+
+
+def _store_catalog(pool, cache, entry, generation, key, models, serves):
+ """Publish one fetched catalog only while the credential lease is still current."""
+ def publish():
+ cache.put(key, models, serves=serves)
+ for current in pool._entries:
+ if current["cm"] is entry["cm"]:
+ current["catalog_dirty"] = False
+ return pool.apply_if_current(entry["cm"], generation, publish)
+
+
+def _sync_account_catalogs(pool, ledger, refs, failed, cache):
+ """Refresh every synced account's own catalog; used by the account probe mode."""
for entry, generation, headers, profile in refs.values():
- identity = account_key(profile, headers.get("X-User-Id"), headers.get("X-Enterprise-Id"))
if not model_policy.credential_enabled(CONFIG, entry):
continue
- key = catalog_cache_key(profile, identity)
+ key = _account_catalog_key(profile, headers)
if cache.fresh(key) and not entry.get("catalog_dirty"):
continue
perf_metrics.inc("catalog_fetch_count")
try:
- scopes = credits_mod.fetch_model_scopes(
- _bearer_token(headers), domain=headers.get("X-Domain", ""),
- uid=headers.get("X-User-Id", ""), enterprise_id=headers.get("X-Enterprise-Id", ""))
- models, serves = scopes["picker"], scopes["account"]
- def publish():
- cache.put(key, models, serves=serves)
- for current in pool._entries:
- if current["cm"] is entry["cm"]:
- current["catalog_dirty"] = False
- if pool.apply_if_current(entry["cm"], generation, publish):
+ models, serves = _fetch_catalog(headers)
+ if _store_catalog(pool, cache, entry, generation, key, models, serves):
_log(f"[models] {profile} 模型表已刷新: 选择器 {len(models)} 个,"
f"账号根表 {len(serves)} 个")
else:
@@ -1783,9 +1800,169 @@ def publish():
perf_metrics.inc("catalog_fetch_failures")
failed.add(entry["id"])
_sync_error(pool, ledger, entry, generation, "models", error)
+
+
+def _mark_catalog_diffs(pool, profile, model_ids, cache):
+ """Mark accounts whose own catalog lacks models the representative just advertised."""
+ if not model_ids:
+ return
+ with pool._lock:
+ for entry in pool._entries:
+ identity, current = entry.get("account_key"), entry.get("profile")
+ if not identity or current != profile:
+ continue
+ known = {item.get("id") for item in (cache.models(catalog_cache_key(profile, identity)) or [])}
+ if known and not model_ids <= known:
+ entry["catalog_dirty"] = True
+
+
+def _probe_profile_catalogs(pool, ledger, refs, failed, cache, aggregate):
+ """Probe at most one representative credential per product profile per cycle."""
+ for profile in PROFILE_ENDPOINTS:
+ if not aggregate.due(profile):
+ continue
+ candidates = [{"entry": entry, "generation": generation, "headers": headers,
+ "account_key": entry.get("account_key")}
+ for entry, generation, headers, candidate_profile in refs.values()
+ if candidate_profile == profile and entry.get("account_key")
+ and model_policy.credential_enabled(CONFIG, entry)]
+ target = aggregate.choose(
+ profile, candidates, age_of=lambda candidate: cache.age(_account_catalog_key(profile, candidate["headers"])))
+ if target is None:
+ aggregate.fresh(profile)
+ continue
+ entry, generation, headers = target["entry"], target["generation"], target["headers"]
+ key = _account_catalog_key(profile, headers)
+ perf_metrics.inc("catalog_fetch_count")
+ try:
+ models, serves = _fetch_catalog(headers)
+ if not _store_catalog(pool, cache, entry, generation, key, models, serves):
+ aggregate.failure(profile, source=entry.get("account_key"))
+ failed.add(entry["id"])
+ continue
+ aggregate.success(profile, source=entry.get("account_key"), generation=generation,
+ models=[item.get("id") for item in models])
+ _mark_catalog_diffs(pool, profile, {item.get("id") for item in models}, cache)
+ _log(f"[models] {profile} 代表账号目录已刷新: 选择器 {len(models)} 个,"
+ f"账号根表 {len(serves)} 个")
+ except Exception as error:
+ perf_metrics.inc("catalog_fetch_failures")
+ failed.add(entry["id"])
+ aggregate.failure(profile, source=entry.get("account_key"))
+ _sync_error(pool, ledger, entry, generation, "models", error)
+
+
+def _sync_model_catalogs(pool, ledger, refs, failed):
+ cache = CONFIG.get("model_cache")
+ if cache is None:
+ return
+ aggregate = CONFIG.get("aggregate_catalog")
+ if CONFIG.get("catalog_probe_mode", "account") == "representative" and aggregate is not None:
+ _probe_profile_catalogs(pool, ledger, refs, failed, cache, aggregate)
+ else:
+ _sync_account_catalogs(pool, ledger, refs, failed, cache)
_publish_model_cache()
+CATALOG_SCAN_START_LOCK = threading.Lock() # One user-triggered full scan at a time.
+CATALOG_SCAN_MAX_ROWS = 500 # Bounded per-account progress payload.
+_SCAN_FIELDS = ("status", "total", "completed", "success", "failed", "differences",
+ "started_at", "finished_at", "accounts")
+
+
+def catalog_scan_view() -> dict:
+ """Return the current or last full catalog scan without any credential material."""
+ scan = CONFIG.get("catalog_scan")
+ if not scan:
+ return {"status": "idle", "total": 0, "completed": 0, "success": 0, "failed": 0,
+ "differences": 0, "started_at": None, "finished_at": None, "accounts": []}
+ return {key: scan.get(key) for key in _SCAN_FIELDS}
+
+
+def catalog_status() -> dict:
+ """Describe probe mode, representative state and the last full scan."""
+ aggregate = CONFIG.get("aggregate_catalog")
+ return {"probe_mode": CONFIG.get("catalog_probe_mode", "account"),
+ "representative": aggregate.view() if aggregate is not None else None,
+ "scan": catalog_scan_view()}
+
+
+def _scan_one_catalog(pool, cache, entry):
+ """Refresh one account's catalog and report how far it moved from the cached copy."""
+ cm = entry["cm"]
+ with cm._lock:
+ headers = cm.get_headers()
+ generation = cm._generation
+ profile = profile_for_headers(headers)
+ key = _account_catalog_key(profile, headers)
+ previous = {item.get("id") for item in (cache.models(key) or [])}
+ models, serves = _fetch_catalog(headers)
+ if not _store_catalog(pool, cache, entry, generation, key, models, serves):
+ raise RuntimeError("扫描期间凭证已变化,已跳过该账号")
+ current = {item.get("id") for item in models}
+ # A first fetch has no baseline to diff against; only a change to a known catalog counts.
+ differences = len(previous ^ current) if previous else 0
+ return {"profile": profile, "models": len(models), "serves": len(serves),
+ "differences": differences, "model_ids": [item.get("id") for item in models]}
+
+
+def _catalog_scan_worker(pool, cache, scan, entries):
+ """Scan every account serially, keeping each previous catalog when its refresh fails."""
+ aggregate = CONFIG.get("aggregate_catalog")
+ try:
+ for entry in entries:
+ row = {"name": Path(entry["id"]).name}
+ try:
+ if not model_policy.credential_enabled(CONFIG, entry):
+ row.update(status="skipped", reason="disabled")
+ else:
+ result = _scan_one_catalog(pool, cache, entry)
+ row.update(status="ok", **{key: value for key, value in result.items() if key != "model_ids"})
+ scan["success"] += 1
+ scan["differences"] += result["differences"]
+ if aggregate is not None and entry.get("account_key"):
+ # A full scan satisfies this profile's TTL, so no probe follows immediately.
+ aggregate.success(result["profile"], source=entry["account_key"],
+ generation=entry.get("generation"), models=result["model_ids"])
+ except Exception as error:
+ perf_metrics.inc("catalog_fetch_failures")
+ row.update(status="failed", error=sanitize_log_text(_network_error_text(error), 120))
+ scan["failed"] += 1
+ scan["completed"] += 1
+ if len(scan["accounts"]) < CATALOG_SCAN_MAX_ROWS:
+ scan["accounts"].append(row)
+ _publish_model_cache()
+ scan["status"] = "completed"
+ except Exception as error:
+ scan["status"] = "failed"
+ scan["error"] = sanitize_log_text(_network_error_text(error), 120)
+ finally:
+ scan["finished_at"] = time.time()
+ _log(f"[models] 手动全量扫描结束: 成功 {scan['success']},失败 {scan['failed']},"
+ f"差异 {scan['differences']}")
+
+
+def start_catalog_scan() -> dict:
+ """Start one user-triggered full scan; an already running scan is reported instead."""
+ pool, cache = CONFIG.get("cred_pool"), CONFIG.get("model_cache")
+ if pool is None or cache is None or credits_mod is None:
+ raise HTTPException(status_code=503, detail={"error": {
+ "message": "模型目录不可用,无法扫描", "type": "service_unavailable",
+ "code": "catalog_unavailable"}})
+ with CATALOG_SCAN_START_LOCK:
+ current = CONFIG.get("catalog_scan")
+ if current is not None and current.get("status") == "running":
+ return catalog_scan_view()
+ entries = pool.entries()
+ scan = {"status": "running", "total": len(entries), "completed": 0, "success": 0,
+ "failed": 0, "differences": 0, "started_at": time.time(), "finished_at": None,
+ "accounts": []}
+ CONFIG["catalog_scan"] = scan
+ threading.Thread(target=_catalog_scan_worker, args=(pool, cache, scan, entries),
+ daemon=True, name="catalog-scan").start()
+ return catalog_scan_view()
+
+
def _sync_usage(pool, entries=None, expected_identity=None):
"""Refresh usage during maintenance with independent per-account snapshots."""
accounts = CONFIG.get("usage_daily_accounts")
@@ -2092,6 +2269,7 @@ async def _protocol_http_exception(request: Request, exc: HTTPException):
"trial_ledger": None,
"model_guard": True, # Reject models absent from authorized catalogs
"routing_scheduler": "indexed", # legacy | shadow | indexed eligibility shards
+ "catalog_probe_mode": "representative", # account | representative catalog refresh
"max_images": 16, "image_policy": "truncate",
"max_request_bytes": 32 * 1024 * 1024, "log_body_limit": 65536,
"max_inbound_bytes": 64 * 1024 * 1024,
@@ -4694,6 +4872,9 @@ def main():
ap.add_argument("--stream-mode", choices=_STREAM_MODES,
default=os.environ.get("CODEBUDDY2API_STREAM_MODE", "compatible"),
help="流式传输:compatible 保持兼容聚合策略,realtime 增量发送且不重生成工具参数;默认 compatible")
+ ap.add_argument("--catalog-probe-mode", choices=("account", "representative"),
+ default=os.environ.get("CODEBUDDY2API_CATALOG_PROBE_MODE", "representative"),
+ help="目录探测:representative 每个产品每周期只探测一个代表账号(默认);account 逐个账号刷新")
ap.add_argument("--routing-scheduler", choices=("legacy", "shadow", "indexed"),
default=os.environ.get("CODEBUDDY2API_ROUTING_SCHEDULER", "indexed"),
help="路由调度:indexed 复用按模型分片(默认);shadow 记录新旧差异并仍返回旧结果;legacy 每次全量判断")
@@ -4734,7 +4915,8 @@ def main():
"tool_call_max_retry", "max_inbound_bytes", "max_collect_bytes", "max_concurrent",
"failover_max", "retry_write_timeout", "upstream_keepalive", "max_inflight_per_account",
"request_context_mode", "stream_mode", "model_capability_guard", "admin_allowed_origins",
- "responses_projection_mode", "responses_projection_max_bytes", "routing_scheduler"):
+ "responses_projection_mode", "responses_projection_max_bytes", "routing_scheduler",
+ "catalog_probe_mode"):
CONFIG[key] = getattr(args, key)
CONFIG["api_key"] = args.api_key
CONFIG["desensitize"] = args.desensitize
@@ -4768,6 +4950,7 @@ def main():
ledger = credits_mod.CreditLedger(store=CONFIG["state_store"])
CONFIG["ledger"] = ledger
CONFIG["model_cache"] = credits_mod.ModelCatalogCache(ttl=args.model_catalog_ttl, store=CONFIG["state_store"])
+ CONFIG["aggregate_catalog"] = AggregateCatalog(ttl=args.model_catalog_ttl, store=CONFIG["state_store"])
CONFIG["cred_pool"].set_ledger(ledger) # Verify balance ownership before publishing catalogs.
_publish_model_cache()
# Publish cached usage before maintenance threads start, so the dashboard is populated from
diff --git a/docs/advanced.md b/docs/advanced.md
index c2a3d23..373cff1 100644
--- a/docs/advanced.md
+++ b/docs/advanced.md
@@ -239,6 +239,12 @@ Credential domain / token issuer determine the product identity. Chat and refres
- WebUI region, product and credential bindings strictly limit candidates; unavailable bindings never fall back to unselected accounts. Disabled models also reject direct requests. Renaming hides the original ID unless you choose to retain it.
- Sent requests are not replayed against another account because of account availability or HTTP errors; later requests select again. Pending catalog/credential readiness usually returns 503 with `Retry-After`; unsupported or disabled models return 404.
+Routing and catalog maintenance are switchable without reverting data:
+
+- `routing_scheduler`: `indexed` (default) reuses one eligibility shard per model, request-scoped alias and revision set; `shadow` also runs the legacy scan, records mismatches under `GET /admin/performance` and still returns the legacy answer; `legacy` re-evaluates every credential on every request.
+- `catalog_probe_mode`: `representative` (default) probes one representative credential per product profile per TTL cycle, preferring the current source and rotating to the stalest account, with exponential backoff after failures; `account` refreshes every synced account each cycle. An unprobed or failed account keeps its last trusted catalog, so `/v1/models` stays the union of current account catalogs and only manually verified models are published.
+- `POST /admin/models/scan` refreshes every current account once, in the background; `GET /admin/models/scan` reports progress, successes, failures and catalog differences. It is the only way to calibrate all accounts at once and is never a periodic task.
+
### Model declarations and image compatibility
`/v1/models` retains its existing fields and adds `capabilities`, `limits` and `metadata_by_profile`; management and routing previews expose the same metadata. Capabilities use `supported`, `unsupported`, `mixed` or `unknown`. Limits carry `state` (`known`, `mixed`, `unknown`) and `value`; only unanimous known limits have a numeric value. Per-profile arrays preserve distinct account declarations without identities. Safe descriptions, capabilities, windows, reasoning options, related models and parameter suggestions are allowlisted; credentials, internal configuration and authenticated URLs are excluded. These are upstream declarations, not native-model or measured guarantees; suggestions do not override requests.
diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md
index 7b2c076..5c82b2b 100644
--- a/docs/advanced.zh-CN.md
+++ b/docs/advanced.zh-CN.md
@@ -239,6 +239,12 @@ WebUI 可以直接上传文件;以下限制针对 `POST /admin/credentials`
- WebUI 的地域、产品和凭证绑定严格限制候选账号,不会回退到未选账号。模型禁用后直接请求同样拒绝;改名默认不保留原 ID,只有选择保留时才同时提供旧 ID。
- 已发送请求不会因账号不可用或 HTTP 错误换账号重放;后续请求才重新选路。目录同步或凭证未就绪通常返回带 `Retry-After` 的 503,不支持或禁用的模型返回 404。
+选路与目录维护都可切换模式,无需回滚已持久化数据:
+
+- `routing_scheduler`:`indexed`(默认)按模型、请求内别名和版本组合复用同一份候选分片;`shadow` 同时执行旧的全量判断,把差异记录在 `GET /admin/performance`,但仍返回旧结果;`legacy` 每次请求重新判断全部凭证。
+- `catalog_probe_mode`:`representative`(默认)每个产品每 TTL 周期只探测一个代表凭证,优先当前来源账号,再轮换到目录最旧的账号,失败按指数退避;`account` 每周期刷新全部已同步账号。未探测或探测失败的账号保留上次可信目录,`/v1/models` 仍是当前账号目录并集,只有经过账号级验证的模型才会发布。
+- `POST /admin/models/scan` 在后台把当前全部账号各刷新一次,`GET /admin/models/scan` 返回进度、成功数、失败数与目录差异数;它是一次性校准入口,不是周期任务。
+
### 模型声明与图片兼容
`/v1/models` 保留原字段,增加 `capabilities`、`limits`、`metadata_by_profile`,管理接口及路由预览同步提供。能力状态为 `supported`、`unsupported`、`mixed`、`unknown`;限制含 `state`(`known`、`mixed`、`unknown`)和 `value`,仅已知且一致时返回数值。按产品保留不同账号的声明版本,但不公开账号身份。白名单覆盖说明、能力、窗口、思考选项、关联模型及参数建议;凭据、内部配置和带认证信息的 URL 不公开。上游声明不等于原生能力或实测保证,参数建议不自动覆盖请求。
diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py
index 731d12c..35e431e 100644
--- a/tests/test_admin_api.py
+++ b/tests/test_admin_api.py
@@ -39,6 +39,12 @@ def setUp(self):
admin_set_credential_enabled=Mock(side_effect=lambda identity, enabled: self.store.set_credential(identity, enabled)),
admin_apply_settings=Mock(), admin_delete_guard=Mock(),
managed_auth_dir=Mock(return_value=self.root), _store_credential=Mock(),
+ catalog_status=Mock(return_value={"probe_mode": "account", "representative": None,
+ "scan": {"status": "idle", "total": 0, "completed": 0,
+ "success": 0, "failed": 0, "differences": 0,
+ "started_at": None, "finished_at": None, "accounts": []}}),
+ start_catalog_scan=Mock(return_value={"status": "running"}),
+ catalog_scan_view=Mock(return_value={"status": "idle"}),
_credential_identity=Mock(return_value="fingerprint"),
_save_oauth_credential=Mock(return_value=self.root / "first.info"), _OAUTH=Mock())
self.gateway._OAUTH.start.return_value = {"login_id": "task", "verification_uri": "https://www.codebuddy.cn/login", "expires_in": 600}
@@ -480,6 +486,19 @@ def inventory():
self.assertEqual(response.json()["models"][0]["public_id"], "synced-model")
+ def test_catalog_scan_routes_require_session_and_delegate(self):
+ self.assertEqual(self.client.post("/admin/models/scan").status_code, 401)
+ self.assertEqual(self.client.get("/admin/models/scan").status_code, 401)
+ csrf = self.login()
+ started = self.client.post("/admin/models/scan", headers=csrf)
+ self.assertEqual(started.status_code, 200, started.text)
+ self.assertEqual(started.json(), {"status": "running"})
+ self.gateway.start_catalog_scan.assert_called_once()
+ state = self.client.get("/admin/models/scan")
+ self.assertEqual(state.status_code, 200, state.text)
+ self.assertEqual(state.json(), {"status": "idle"})
+ self.gateway.catalog_scan_view.assert_called()
+
def test_dashboard_granularity_is_validated_and_forwarded(self):
response = self.client.get("/admin/dashboard?days=1&granularity=hour", headers=self.headers)
self.assertEqual(response.status_code, 200, response.text)
diff --git a/tests/test_catalog_probe.py b/tests/test_catalog_probe.py
new file mode 100644
index 0000000..405c3c5
--- /dev/null
+++ b/tests/test_catalog_probe.py
@@ -0,0 +1,204 @@
+"""Representative catalog probes, retry backoff, diff marking and the manual full scan."""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution.
+
+import json
+import os
+import tempfile
+import time
+import unittest
+from unittest.mock import patch
+
+import converter
+from app import model_policy, perf_metrics
+from app.catalog_probe import AggregateCatalog, valid_row
+from app.control_store import ControlStore
+from app.credits import CreditLedger, ModelCatalogCache
+
+
+def model(*names):
+ return [{"id": name, "supportsToolCall": True, "credits": "x1 credits"} for name in names]
+
+
+class AggregateCatalogTests(unittest.TestCase):
+ def test_round_trip_persists_and_rejects_invalid_rows(self):
+ root = Path(self.enterContext(tempfile.TemporaryDirectory()))
+ store = ControlStore(root / "control.sqlite3")
+ self.addCleanup(store.close)
+ aggregate = AggregateCatalog(ttl=3600, store=store.state)
+ aggregate.success("cn-cli", source="fingerprint-a", generation=3, models=["glm-5.3", "hy3"])
+ reloaded = AggregateCatalog(ttl=3600, store=store.state)
+ row = reloaded.row("cn-cli")
+ self.assertEqual(row["source"], "fingerprint-a")
+ self.assertEqual(row["source_generation"], 3)
+ self.assertEqual(row["models"], ["glm-5.3", "hy3"])
+ self.assertFalse(reloaded.due("cn-cli"))
+ with self.assertRaises(ValueError):
+ store.state.put("catalog_probe", {"version": 1, "profiles": {"cn-cli": {"source": 1}}})
+ self.assertEqual(len(AggregateCatalog(ttl=3600, store=store.state).profiles()), 1)
+ self.assertFalse(valid_row({"source": "a", "fetched_at": 1, "retry_at": 2, "status": "ok",
+ "models": ["x"], "secret": "y"}))
+
+ def test_failure_backs_off_then_recovers(self):
+ aggregate = AggregateCatalog(ttl=3600, clock=lambda: 1000.0)
+ aggregate.failure("cn-cli", source="fingerprint-a")
+ self.assertFalse(aggregate.due("cn-cli"))
+ row = aggregate.row("cn-cli")
+ self.assertEqual(row["status"], "error")
+ self.assertGreater(row["retry_at"], 1000.0)
+ aggregate.failure("cn-cli", source="fingerprint-a")
+ self.assertGreater(aggregate.row("cn-cli")["retry_at"], row["retry_at"]) # Exponential backoff.
+ aggregate.success("cn-cli", source="fingerprint-a", generation=1, models=["hy3"])
+ self.assertEqual(aggregate.row("cn-cli")["failures"], 0)
+ self.assertTrue(aggregate.due("cn-cli", now=1000.0 + 3601))
+
+ def test_choose_prefers_the_current_source_then_the_stalest_account(self):
+ aggregate = AggregateCatalog(ttl=100, clock=lambda: 1000.0)
+ accounts = [{"account_key": "a"}, {"account_key": "b"}, {"account_key": "c"}]
+ ages = {"a": 500.0, "b": 1000.0, "c": None}
+ age_of = lambda candidate: ages[candidate["account_key"]]
+ aggregate.success("cn-cli", source="a", generation=1, models=["hy3"])
+ aggregate._data["profiles"]["cn-cli"]["retry_at"] = 0.0
+ self.assertEqual(aggregate.choose("cn-cli", accounts, age_of=age_of)["account_key"], "a")
+ ages["a"] = 10.0 # The source just refreshed; rotation moves to the stalest account.
+ self.assertEqual(aggregate.choose("cn-cli", accounts, age_of=age_of)["account_key"], "c")
+ ages["c"] = 10.0
+ self.assertEqual(aggregate.choose("cn-cli", accounts, age_of=age_of)["account_key"], "b")
+ ages["b"] = 10.0
+ self.assertIsNone(aggregate.choose("cn-cli", accounts, age_of=age_of))
+
+
+class CatalogProbeTests(unittest.TestCase):
+ def setUp(self):
+ temporary = self.enterContext(tempfile.TemporaryDirectory())
+ self.enterContext(patch.dict(os.environ, {"CODEBUDDY_AUTH_DIR": temporary}))
+ self.enterContext(patch.object(perf_metrics, "_COUNTERS", {}))
+ self.root = Path(temporary)
+ self.calls = []
+
+ def build(self, count=2, profile="cn-cli"):
+ paths = []
+ for index in range(count):
+ path = self.root / f"account-{index}.info"
+ path.write_text(json.dumps({"account": {"uid": f"probe-{index}"}, "auth": {
+ "accessToken": f"token-{index}", "domain": "www.codebuddy.cn",
+ "expiresAt": (time.time() + 3600) * 1000}}), encoding="utf-8")
+ paths.append(path)
+ pool = converter.CredentialPool(paths)
+ ledger = CreditLedger(self.root / "ledger.json")
+ pool.set_ledger(ledger)
+ for entry in pool.entries():
+ ledger.bind_identity(entry["id"], entry["account_key"])
+ ledger.update_credits(entry["id"], {"credits": 100, "intl": False, "segments": []})
+ cache = ModelCatalogCache(self.root / "catalog.json", ttl=60)
+ aggregate = AggregateCatalog(ttl=3600)
+ self.enterContext(patch.dict(converter.CONFIG, {
+ "cred_pool": pool, "ledger": ledger, "model_cache": cache, "aggregate_catalog": aggregate,
+ "catalog_probe_mode": "representative", "account_catalogs": {}, "catalog_index": None,
+ "model_catalogs": {}, "model_guard": True, "log_path": None, "control_store": None}))
+ return pool, ledger, cache, aggregate
+
+ def refs(self, pool):
+ result = {}
+ for entry in pool.entries():
+ cm = entry["cm"]
+ with cm._lock:
+ headers = cm.get_headers()
+ result[entry["id"]] = (entry, cm._generation, headers, entry["profile"])
+ return result
+
+ def scopes(self, *names):
+ def fetch(_token, **_kwargs):
+ self.calls.append(names)
+ return {"picker": model(*names), "account": model(*names)}
+ return fetch
+
+ def test_representative_probe_runs_once_per_profile_and_marks_differences(self):
+ pool, ledger, cache, aggregate = self.build(3)
+ entries = pool.entries()
+ stale = {item["id"] for item in model("hy3")}
+ cache.put(converter.catalog_cache_key("cn-cli", entries[1]["account_key"]), model("hy3"), serves=model("hy3"))
+ with patch.object(converter.credits_mod, "fetch_model_scopes", side_effect=self.scopes("hy3", "glm-5.3")):
+ converter._sync_model_catalogs(pool, ledger, self.refs(pool), set())
+ self.assertEqual(len(self.calls), 1)
+ self.assertTrue(stale < {item["id"] for item in model("hy3", "glm-5.3")})
+ fresh = [entry for entry in pool.entries() if not entry.get("catalog_dirty")]
+ self.assertEqual(len(fresh), 1) # Only the representative stored a new catalog.
+ self.assertTrue(pool.entry_by_id(entries[1]["id"])["catalog_dirty"])
+ self.assertFalse(aggregate.due("cn-cli"))
+ self.assertEqual(aggregate.row("cn-cli")["models"], ["glm-5.3", "hy3"])
+ with patch.object(converter.credits_mod, "fetch_model_scopes", side_effect=self.scopes("hy3")):
+ converter._sync_model_catalogs(pool, ledger, self.refs(pool), set())
+ self.assertEqual(len(self.calls), 1) # The TTL suppresses another fetch.
+
+ def test_probe_failure_keeps_other_catalogs_and_backs_off(self):
+ pool, ledger, cache, aggregate = self.build(2)
+ entries = pool.entries()
+ cache.put(converter.catalog_cache_key("cn-cli", entries[0]["account_key"]), model("hy3"), serves=model("hy3"))
+ failed = set()
+ with patch.object(converter.credits_mod, "fetch_model_scopes", side_effect=RuntimeError("synthetic upstream")):
+ converter._sync_model_catalogs(pool, ledger, self.refs(pool), failed)
+ self.assertTrue(failed)
+ self.assertLessEqual({"source", "status", "failures", "retry_at"}, set(aggregate.row("cn-cli")))
+ self.assertEqual(aggregate.row("cn-cli")["status"], "error")
+ self.assertFalse(aggregate.due("cn-cli"))
+ kept = {item["id"] for item in cache.models(converter.catalog_cache_key("cn-cli", entries[0]["account_key"]))}
+ self.assertEqual(kept, {"hy3"}) # The previous catalog survives a failed probe.
+
+ def test_account_mode_still_refreshes_every_account(self):
+ pool, ledger, cache, _ = self.build(2)
+ self.enterContext(patch.dict(converter.CONFIG, {"catalog_probe_mode": "account"}))
+ with patch.object(converter.credits_mod, "fetch_model_scopes", side_effect=self.scopes("hy3")):
+ converter._sync_model_catalogs(pool, ledger, self.refs(pool), set())
+ self.assertEqual(len(self.calls), 2)
+
+ def test_manual_scan_refreshes_every_account_and_reports_differences(self):
+ pool, _, cache, aggregate = self.build(3)
+ entries = pool.entries()
+ cache.put(converter.catalog_cache_key("cn-cli", entries[0]["account_key"]), model("hy3"), serves=model("hy3"))
+ with patch.object(converter.credits_mod, "fetch_model_scopes", side_effect=self.scopes("hy3", "glm-5.3")):
+ started = converter.start_catalog_scan()
+ self.assertEqual(started["status"], "running")
+ for _ in range(200):
+ view = converter.catalog_scan_view()
+ if view["status"] != "running":
+ break
+ time.sleep(0.01)
+ self.assertEqual(view["status"], "completed")
+ self.assertEqual((view["total"], view["completed"], view["success"], view["failed"]), (3, 3, 3, 0))
+ self.assertEqual(view["differences"], 1) # Only the pre-seeded account changed.
+ self.assertEqual(len(view["accounts"]), 3)
+ self.assertEqual(aggregate.row("cn-cli")["status"], "ok")
+ self.assertNotIn("token", json.dumps(view))
+
+ def test_manual_scan_reports_an_already_running_scan(self):
+ pool, _, cache, _ = self.build(1)
+ blocker = {}
+
+ def slow_fetch(*args, **kwargs):
+ blocker["started"] = True
+ time.sleep(0.05)
+ return {"picker": model("hy3"), "account": model("hy3")}
+ with patch.object(converter.credits_mod, "fetch_model_scopes", side_effect=slow_fetch):
+ first = converter.start_catalog_scan()
+ second = converter.start_catalog_scan()
+ for _ in range(200):
+ if converter.catalog_scan_view()["status"] != "running":
+ break
+ time.sleep(0.01)
+ self.assertEqual(first["status"], "running")
+ self.assertEqual(second["status"], "running")
+ self.assertTrue(blocker.get("started"))
+
+ def test_scan_reports_unavailable_catalog_without_credential_material(self):
+ pool, _, cache, _ = self.build(1)
+ with patch.dict(converter.CONFIG, {"model_cache": None}):
+ with self.assertRaises(converter.HTTPException) as raised:
+ converter.start_catalog_scan()
+ self.assertEqual(raised.exception.status_code, 503)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/tests/test_routing_scheduler.py b/tests/test_routing_scheduler.py
index 1797274..b58311a 100644
--- a/tests/test_routing_scheduler.py
+++ b/tests/test_routing_scheduler.py
@@ -12,7 +12,8 @@
from unittest.mock import patch
import converter
-from app import perf_metrics
+from app import model_policy, perf_metrics
+from app.control_store import ControlStore
from app.credits import CreditLedger
from app.routing_scheduler import MISMATCH_LIMIT, RoutingScheduler
@@ -80,6 +81,7 @@ class SchedulerPoolTests(unittest.TestCase):
def setUp(self):
temporary = self.enterContext(tempfile.TemporaryDirectory())
self.enterContext(patch.dict(os.environ, {"CODEBUDDY_AUTH_DIR": temporary}))
+ self.enterContext(patch.object(perf_metrics, "_COUNTERS", {}))
self.root = Path(temporary)
def build(self, count, mode="indexed"):
@@ -173,6 +175,32 @@ def test_credential_reload_invalidates_the_shard(self):
pool.pick("session", "glm-5.3-flash")
self.assertGreater(eligible.call_count, 0)
+ def pick_alias(self, pool, source):
+ """Select through one request-scoped alias, exactly as the resolver leaves the context."""
+ rule = model_policy.rule_for(converter.CONFIG, source)
+ token = model_policy._request_policy.set({"source": source, "upstream": rule["upstream_id"],
+ "public_model": rule["public_id"], "rule": rule})
+ try:
+ manager = pool.pick(None, rule["upstream_id"])
+ finally:
+ model_policy._request_policy.reset(token)
+ return pool.entry_for(manager)
+
+ def test_aliases_with_different_bindings_do_not_share_a_shard(self):
+ pool, _ = self.build(4)
+ store = ControlStore(self.root / "control.sqlite3")
+ self.addCleanup(store.close)
+ entries = pool.entries()
+ for source, identity in (("alias-a", entries[0]["account_key"]),
+ ("alias-b", entries[1]["account_key"])):
+ store.update_model(source, {"public_id": f"{source}-out", "upstream_id": "glm-5.3-flash",
+ "custom": True, "credential_ids": [identity]},
+ store.revision, known_models=())
+ with patch.dict(converter.CONFIG, {"control_store": store}):
+ self.assertEqual(self.pick_alias(pool, "alias-a")["id"], entries[0]["id"])
+ self.assertEqual(self.pick_alias(pool, "alias-b")["id"], entries[1]["id"])
+ self.assertEqual(len(pool._scheduler.report()["shards"]), 2)
+
def test_balance_change_invalidates_the_shard(self):
pool, ledger = self.build(4)
pool.pick("session", "glm-5.3-flash")
diff --git a/tests/test_sqlite_state.py b/tests/test_sqlite_state.py
index 0e6d607..fc2a0e2 100644
--- a/tests/test_sqlite_state.py
+++ b/tests/test_sqlite_state.py
@@ -16,6 +16,7 @@
from unittest.mock import patch
from app.admin_auth import AdminAuth, SessionStoreError
+from app.catalog_probe import AggregateCatalog
from app.control_store import ControlStore
from app.credential_cooldowns import CredentialCooldowns
from app.credits import CreditLedger, ModelCatalogCache
@@ -69,6 +70,10 @@ def create_legacy(self):
usage = UsageSnapshots(self.root / LEGACY_FILES["usage"])
usage.store("fixture.info", IDENTITY, "domestic", {"total_credits": 12, "requests": 2,
"by_day": {"2026-09-22": {"model": 12}}}, partial=True)
+ probe = self.root / LEGACY_FILES["catalog_probe"]
+ probe.write_text(json.dumps({"version": 1, "profiles": {"cn-cli": {
+ "source": IDENTITY, "source_generation": 1, "fetched_at": self.now,
+ "retry_at": self.now + 300, "failures": 0, "status": "ok", "models": ["model"]}}}))
trial = TrialLedger(self.root / LEGACY_FILES["trial"])
self.assertTrue(trial.begin(IDENTITY))
trial.finish(IDENTITY, {"ok": True, "already": False, "code": 0, "status": 200})
@@ -90,6 +95,7 @@ def test_all_legacy_sources_migrate_once_and_runtime_never_updates_json(self):
self.assertGreater(cooldowns.credential_until(IDENTITY, "cn-work"), self.now)
self.assertGreater(cooldowns.model_until(IDENTITY, "cn-work", "model"), self.now)
self.assertTrue(UsageSnapshots(store=self.state).accounts()["fixture.info"]["partial"])
+ self.assertEqual(AggregateCatalog(store=self.state).row("cn-cli")["source"], IDENTITY)
trial = TrialLedger(store=self.state)
self.assertFalse(trial.begin(IDENTITY, self.now + 3 * 86400))
auth = AdminAuth({"api_key": FIXED_KEY, "state_store": self.state})
@@ -107,7 +113,7 @@ def test_all_legacy_sources_migrate_once_and_runtime_never_updates_json(self):
auth.reconcile()
self.assertNotIn(sid, auth.sessions)
self.assertEqual(before, {name: (self.root / file).read_bytes() for name, file in LEGACY_FILES.items()})
- self.assertEqual(self.control._db.execute("SELECT count(*) FROM state_imports").fetchone()[0], 7)
+ self.assertEqual(self.control._db.execute("SELECT count(*) FROM state_imports").fetchone()[0], 8)
def test_absent_legacy_file_cannot_resurrect_later(self):
self.state.migrate(self.root)
diff --git a/web/src/api.ts b/web/src/api.ts
index c94d7f2..2cacfd0 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -280,6 +280,39 @@ export function modelResponse(value: unknown): {
: undefined,
};
}
+export type CatalogScan = {
+ status: "idle" | "running" | "completed" | "failed";
+ total: number;
+ completed: number;
+ success: number;
+ failed: number;
+ differences: number;
+ started_at: number | null;
+ finished_at: number | null;
+ accounts: RecordValue[];
+};
+export function catalogScanResponse(value: unknown): CatalogScan {
+ const data = object(value, "目录扫描");
+ const count = (field: string) => {
+ const parsed = number(data[field]);
+ if (parsed === null || parsed < 0) throw new Error(`目录扫描缺少 ${field}`);
+ return parsed;
+ };
+ const status = data.status;
+ if (status !== "idle" && status !== "running" && status !== "completed" && status !== "failed")
+ throw new Error("目录扫描状态无效");
+ return {
+ status,
+ total: count("total"),
+ completed: count("completed"),
+ success: count("success"),
+ failed: count("failed"),
+ differences: count("differences"),
+ started_at: number(data.started_at),
+ finished_at: number(data.finished_at),
+ accounts: Array.isArray(data.accounts) ? list(data.accounts, "目录扫描账号") : [],
+ };
+}
export type Credential = RecordValue & { id: string; name: string | null };
export function credentialResponse(value: unknown): Credential[] {
return list(object(value).credentials, "凭证").map((c) => {
diff --git a/web/src/pages/Models.tsx b/web/src/pages/Models.tsx
index ce51804..4f28a62 100644
--- a/web/src/pages/Models.tsx
+++ b/web/src/pages/Models.tsx
@@ -1,12 +1,14 @@
-import { useEffect, useId, useRef, useState } from "react";
+import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
api,
+ catalogScanResponse,
credentialResponse,
errorMessage,
list,
modelResponse,
object,
useResource,
+ type CatalogScan,
type Credential,
type ModelRule,
type RecordValue,
@@ -505,6 +507,32 @@ export function Models() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState