From 2fc7cbeb03bdeed96b4e268c70c5e367ef3276b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:55:38 +0800 Subject: [PATCH 1/3] Fix international model catalog aggregation Use the personal model table as the primary catalog and supplement missing picker and account-root entries from /v3/config. Fix WebUI regional filtering to use published profile metadata for automatic routes. Fixes #109 --- app/credits.py | 48 +++++++++++++++++++++++++++------- docs/advanced.md | 2 +- docs/advanced.zh-CN.md | 2 +- tests/test_personal_models.py | 25 ++++++++++++++++++ web/src/modelMetadata.test.tsx | 26 ++++++++++++++++++ web/src/pages/Models.tsx | 11 +++++++- 6 files changed, 101 insertions(+), 13 deletions(-) diff --git a/app/credits.py b/app/credits.py index d2ae60f..f02c069 100644 --- a/app/credits.py +++ b/app/credits.py @@ -749,21 +749,28 @@ def fetch_personal_model_scopes(access_token: str, user_agent: str = "", *, doma return _parse_model_scopes(payload, profile_product(profile), "个人模型表接口") -def fetch_model_scopes(access_token: str, user_agent: str = "", *, domain: str = "", - uid: str = "", enterprise_id: str = "", prefer_personal: bool = False) -> dict[str, list[dict]]: - """Fetch selector and account-root catalogs without assuming every candidate is servable.""" +def _merge_model_scopes(primary: dict[str, list[dict]], supplemental: dict[str, list[dict]]) -> dict[str, list[dict]]: + """Append models absent from the primary catalog while preserving its declarations.""" + merged = {} + for scope in ("picker", "account"): + rows = list(primary.get(scope) or []) + seen = {item.get("id") for item in rows if isinstance(item, dict) and item.get("id")} + rows.extend(item for item in supplemental.get(scope) or [] + if isinstance(item, dict) and item.get("id") not in seen) + merged[scope] = rows + return merged + + +def _fetch_config_model_scopes(access_token: str, user_agent: str = "", *, domain: str = "", + uid: str = "", enterprise_id: str = "") -> dict[str, list[dict]]: + """Fetch the legacy v3 model catalog and split its picker/root scopes.""" auth = {"accessToken": access_token, "domain": domain} profile = profile_for_auth(auth) - if prefer_personal and not enterprise_id: - try: - return fetch_personal_model_scopes(access_token, user_agent, domain=domain, uid=uid) - except _PersonalCatalogUnavailable: - # Older deployments and private endpoints retain the established /v3/config path. - pass headers = catalog_headers(auth, {"uid": uid, "enterpriseId": enterprise_id}, user_agent=user_agent) try: with httpx.Client() as client: - response = client.get(PROFILE_ENDPOINTS[profile] + CONFIG_PATH, headers=headers, timeout=REQUEST_TIMEOUT) + response = client.get(PROFILE_ENDPOINTS[profile] + CONFIG_PATH, headers=headers, + timeout=REQUEST_TIMEOUT) except httpx.HTTPError: raise RuntimeError("模型配置接口网络错误") from None if response.status_code == 401: @@ -777,6 +784,27 @@ def fetch_model_scopes(access_token: str, user_agent: str = "", *, domain: str = return _parse_model_scopes(payload, profile_product(profile), "模型配置接口") +def fetch_model_scopes(access_token: str, user_agent: str = "", *, domain: str = "", + uid: str = "", enterprise_id: str = "", prefer_personal: bool = False) -> dict[str, list[dict]]: + """Fetch selector and account-root catalogs, supplementing personal data with v3.""" + if prefer_personal and not enterprise_id: + try: + personal = fetch_personal_model_scopes(access_token, user_agent, domain=domain, uid=uid) + except _PersonalCatalogUnavailable: + # Older deployments and private endpoints retain the established /v3/config path. + personal = None + if personal is not None: + try: + legacy = _fetch_config_model_scopes(access_token, user_agent, domain=domain, + uid=uid, enterprise_id=enterprise_id) + except (RuntimeError, ValueError): + # Personal data is already trusted; a supplemental catalog failure must not erase it. + return personal + return _merge_model_scopes(personal, legacy) + return _fetch_config_model_scopes(access_token, user_agent, domain=domain, + uid=uid, enterprise_id=enterprise_id) + + def fetch_model_catalog(access_token: str, user_agent: str = "", *, domain: str = "", uid: str = "", enterprise_id: str = "", prefer_personal: bool = False) -> list[dict]: """Return selector models for this credential's product.""" diff --git a/docs/advanced.md b/docs/advanced.md index 5cc7785..f1335ea 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -232,7 +232,7 @@ Select client models from the WebUI or `GET /v1/models`. Raw catalogs remain cac International CLI and WorkBuddy use a deduplicated shared view from enabled, catalog-ready international accounts. A target account must have its own synchronized catalog; its existing model declarations win unchanged. Missing IDs inherit shared declarations, retaining `catalog_source` and safe `source_variants`. Conflicting inherited rates use the higher known rate, limits the smaller known value, reasoning options their intersection and differing descriptive fields are omitted; unknown prices never mean free. Domestic catalogs, credentials, balances, bindings and `auto` defaults remain independent. Shared rates are catalog references, not billing or permission guarantees. -Personal accounts first use the richer `/v2/enterprises/personal/models` table when it is available; it carries the same picker/root split plus credits, context-window and media metadata. Enterprise accounts continue to use `/v3/config`, and older deployments fall back there when the personal route is unavailable. Each refresh caches the agent picker subset as `models` and the account root table as `serves`. Routing and `GET /v1/models` merge these candidates, with picker metadata winning for duplicate IDs. Both scopes retain `disabled` and `availableModels` filtering; models without tool support are excluded. +Personal accounts first use the richer `/v2/enterprises/personal/models` table when it is available; its picker/root scopes are supplemented by missing IDs from `/v3/config`, while personal declarations win for duplicates. Enterprise accounts use `/v3/config` directly, and older deployments fall back there when the personal route is unavailable. Each refresh caches the merged picker subset as `models` and the merged account-root table as `serves`. Routing and `GET /v1/models` merge these candidates. Both scopes retain `disabled` and `availableModels` filtering; models without tool support are excluded. The root table is not a guarantee that a backend serves every listed model; measured `11102` avoidance still applies. Unknown account catalogs do not authorize dispatch and must not trigger a premature all-backends-unsupported 404; they retain the retryable readiness state. Legacy cache entries without `serves` use the picker until the next refresh. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index d91da24..3da9578 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -233,7 +233,7 @@ WebUI 可以直接上传文件;以下限制针对 `POST /admin/credentials` 国际 CLI/WorkBuddy 使用已启用、目录已就绪的国际账号生成去重共享视图。目标账号须完成自身目录同步;已有型号保留自己的完整声明,缺失型号才继承,并通过 `catalog_source`、安全 `source_variants` 标明来源。继承声明冲突时倍率取较高值、上限取较小值、思考选项取交集、描述类字段不一致即省略;未知倍率不当零。国内目录、凭据、余额、绑定及 `auto` 默认模型保持独立;共享倍率只是目录参考,不保证权限或实际扣分。 -个人账号优先使用可用的 `/v2/enterprises/personal/models` 丰富模型表,其中包含同样的选择器/账号根表划分,以及 credits、上下文窗口和媒体元数据。企业账号继续使用 `/v3/config`;旧部署没有个人端点时也回退到该接口。每次刷新将选择器子集缓存为 `models`,账号根表缓存为 `serves`。选路与 `GET /v1/models` 合并这两组候选,同名保留选择器元数据;`disabled` 和 `availableModels` 筛选仍生效,不支持工具调用的模型会被排除。 +个人账号优先使用可用的 `/v2/enterprises/personal/models` 丰富模型表,其中包含同样的选择器/账号根表划分;缺失模型再从 `/v3/config` 补充,同名模型保留个人表声明。企业账号直接使用 `/v3/config`;旧部署没有个人端点时也回退到该接口。每次刷新将合并后的选择器子集缓存为 `models`,合并后的账号根表缓存为 `serves`。选路与 `GET /v1/models` 使用这两组候选;`disabled` 和 `availableModels` 筛选仍生效,不支持工具调用的模型会被排除。 根表不保证每个模型都可调用,仍由实测 `11102` 避让兜底。未知账号目录不参与派发,也不能触发过早的「全部后端不支持」404,应保留可重试的未就绪状态。旧缓存没有 `serves` 时,在下次刷新前继续使用选择器子集。 diff --git a/tests/test_personal_models.py b/tests/test_personal_models.py index 2905a69..7ae927b 100644 --- a/tests/test_personal_models.py +++ b/tests/test_personal_models.py @@ -80,6 +80,31 @@ def test_preferred_personal_table_falls_back_to_v3(self): "https://www.codebuddy.cn/v2/enterprises/personal/models") self.assertEqual(client.get.call_args_list[1].args[0], "https://copilot.tencent.com/v3/config") + def test_preferred_personal_table_is_supplemented_by_v3(self): + v3 = {"code": 0, "data": { + "models": [ + {"id": "hy4-preview", "name": "Legacy Hy4", "credits": "x9.9", "supportsToolCall": True}, + {"id": "v3-only", "name": "V3 only", "supportsToolCall": True}, + ], + "agents": [{"name": "cli", "models": ["hy4-preview", "v3-only"]}], + }} + client = self._client(self.response(200, personal_payload()), self.response(200, v3)) + scopes = credits.fetch_model_scopes( + jwt("https://www.codebuddy.cn/auth/realms/copilot"), prefer_personal=True) + self.assertEqual([item["id"] for item in scopes["picker"]], ["auto", "hy4-preview", "v3-only"]) + self.assertEqual([item["id"] for item in scopes["account"]], + ["auto", "hy4-preview", "hunyuan-image-alpha", "v3-only"]) + self.assertEqual(scopes["picker"][1]["name"], "Hy4 preview") + self.assertEqual(client.get.call_count, 2) + self.assertEqual(client.get.call_args_list[1].args[0], "https://copilot.tencent.com/v3/config") + + def test_personal_table_survives_supplemental_v3_failure(self): + client = self._client(self.response(200, personal_payload()), self.response(503, {"code": 503})) + scopes = credits.fetch_model_scopes( + jwt("https://www.codebuddy.cn/auth/realms/copilot"), prefer_personal=True) + self.assertEqual([item["id"] for item in scopes["picker"]], ["auto", "hy4-preview"]) + self.assertEqual(client.get.call_count, 2) + def test_enterprise_preferred_fetch_uses_v3_without_personal_path(self): client = self._client(self.response(200, personal_payload())) diff --git a/web/src/modelMetadata.test.tsx b/web/src/modelMetadata.test.tsx index a86ef24..b682def 100644 --- a/web/src/modelMetadata.test.tsx +++ b/web/src/modelMetadata.test.tsx @@ -150,6 +150,24 @@ describe("model declarations", () => { revision: 1, models: [ model, + { + ...model, + id: "intl-only", + public_id: "intl-only", + upstream_id: "intl-only", + capabilities: {}, + limits: {}, + metadata_by_profile: { "intl-work": [{ id: "intl-only" }] }, + }, + { + ...model, + id: "cn-only", + public_id: "cn-only", + upstream_id: "cn-only", + capabilities: {}, + limits: {}, + metadata_by_profile: { "cn-cli": [{ id: "cn-only" }] }, + }, { ...model, id: "hy-image-3.5", @@ -189,6 +207,14 @@ describe("model declarations", () => { expect(screen.getByText("minimax-video-h3", { selector: "strong" })).toBeTruthy(); expect(screen.queryByText("hy-image-3.5", { selector: "strong" })).toBeNull(); choose(capability, ""); + const region = screen.getByRole("combobox", { name: "筛选区域" }); + choose(region, "intl"); + expect(screen.getByText("intl-only", { selector: "strong" })).toBeTruthy(); + expect(screen.queryByText("cn-only", { selector: "strong" })).toBeNull(); + choose(region, "cn"); + expect(screen.getByText("cn-only", { selector: "strong" })).toBeTruthy(); + expect(screen.queryByText("intl-only", { selector: "strong" })).toBeNull(); + choose(region, ""); fireEvent.change(screen.getByRole("textbox", { name: "搜索模型" }), { target: { value: "Display" }, }); diff --git a/web/src/pages/Models.tsx b/web/src/pages/Models.tsx index 050cd88..7e82aab 100644 --- a/web/src/pages/Models.tsx +++ b/web/src/pages/Models.tsx @@ -681,6 +681,15 @@ function sortModels(list: ModelRule[], key: SortKey): ModelRule[] { else out.sort((a, b) => a.public_id.localeCompare(b.public_id)); return out; } +function modelHasRegion(model: ModelRule, region: "cn" | "intl"): boolean { + if (model.region) return model.region === region; + if (model.profile) return model.profile.startsWith(`${region}-`); + const declarations = model.metadata_by_profile; + if (!declarations || typeof declarations !== "object" || Array.isArray(declarations)) + return false; + return Object.keys(declarations).some((profile) => profile.startsWith(`${region}-`)); +} + function matchesCatalog(model: ModelRule, query: string, filter: CatalogFilter): boolean { if (query) { const text = modelSearchText(model); @@ -690,7 +699,7 @@ function matchesCatalog(model: ModelRule, query: string, filter: CatalogFilter): if (filter.region) { if (filter.region === "auto") { if (bindingMode(model) !== "auto") return false; - } else if (model.region !== filter.region) return false; + } else if (!modelHasRegion(model, filter.region as "cn" | "intl")) return false; } if (filter.capability === "media_image") { if (!model.media_kinds?.some((kind) => kind === "image" || kind === "edit")) return false; From e2093931433e5c771aae2e0a901b12bb4f698f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:28:33 +0800 Subject: [PATCH 2/3] Harden model catalog refresh failures Keep an already fetched personal catalog when the supplemental /v3/config request fails or rejects the token, record the result as incomplete so the refresh is retried, and never replace a complete cached catalog with an incomplete one. Reject a catalog write that finishes after a newer refresh of the same account, and report supplemental failures in the manual scan and representative probe. Cover the account scope, partial cache and stale sequence cases. --- app/credits.py | 34 ++++++++++++++--- converter.py | 69 ++++++++++++++++++++++++++--------- tests/test_catalog.py | 23 ++++++++++++ tests/test_personal_models.py | 13 +++++++ 4 files changed, 116 insertions(+), 23 deletions(-) diff --git a/app/credits.py b/app/credits.py index f02c069..d185b3f 100644 --- a/app/credits.py +++ b/app/credits.py @@ -59,6 +59,15 @@ class _PersonalCatalogUnavailable(RuntimeError): """Signal that an older or non-SaaS product has no personal model route.""" +class ModelScopes(dict): + """Carry catalog completeness without changing the mapping contract.""" + + def __init__(self, values, *, complete=True, error=None): + super().__init__(values) + self.complete = bool(complete) + self.error = error + + # --------------------------------------------------------------------------- # Billing host selection # --------------------------------------------------------------------------- @@ -797,10 +806,10 @@ def fetch_model_scopes(access_token: str, user_agent: str = "", *, domain: str = try: legacy = _fetch_config_model_scopes(access_token, user_agent, domain=domain, uid=uid, enterprise_id=enterprise_id) - except (RuntimeError, ValueError): - # Personal data is already trusted; a supplemental catalog failure must not erase it. - return personal - return _merge_model_scopes(personal, legacy) + except (AuthExpiredError, RuntimeError, ValueError) as error: + # Personal data remains usable, but the caller must not mark this refresh complete. + return ModelScopes(personal, complete=False, error=error) + return ModelScopes(_merge_model_scopes(personal, legacy)) return _fetch_config_model_scopes(access_token, user_agent, domain=domain, uid=uid, enterprise_id=enterprise_id) @@ -1167,6 +1176,7 @@ def __init__(self, path: Path | None = None, ttl: float = 6 * 3600, *, store=Non self.path = Path(store.path if store is not None else path) self.ttl = max(60.0, float(ttl or 0)) self._lock = threading.Lock() + self._write_sequences: dict[str, int] = {} self._data: dict = {"version": self.SCHEMA_VERSION, "groups": {}} self._load() @@ -1188,6 +1198,7 @@ def _load(self): if not isinstance(ts, (int, float)) or not math.isfinite(ts): ts = 0 groups[group] = {"models": entry["models"], "fetched_at": ts, + "complete": entry.get("complete", True) is True, "version": (self.SCHEMA_VERSION if d["version"] == self.SCHEMA_VERSION and entry.get("version") == self.SCHEMA_VERSION @@ -1224,7 +1235,8 @@ def fresh(self, group: str) -> bool: with self._lock: g = self._data["groups"].get(group) or {} age = time.time() - float(g.get("fetched_at") or 0) - return g.get("version") == self.SCHEMA_VERSION and 0 <= age < self.ttl + return (g.get("version") == self.SCHEMA_VERSION and g.get("complete", True) is True + and 0 <= age < self.ttl) def models(self, group: str) -> list[dict]: with self._lock: @@ -1236,14 +1248,24 @@ def age(self, group: str) -> float | None: g = self._data["groups"].get(group) return time.time() - float(g.get("fetched_at") or 0) if g is not None else None - def put(self, group: str, models: list[dict], serves: list[dict] | None = None): + def put(self, group: str, models: list[dict], serves: list[dict] | None = None, *, complete=True, sequence=None): with self._lock: + current = self._data["groups"].get(group) or {} + current_sequence = self._write_sequences.get(group, 0) + if sequence is not None and sequence < current_sequence: + return False + if sequence is not None: + self._write_sequences[group] = sequence + if complete is not True and current.get("complete", True) is True and current.get("models") is not None: + return False entry = {"models": deepcopy(models), "fetched_at": time.time(), + "complete": complete is True, "version": self.SCHEMA_VERSION} if serves is not None: entry["serves"] = deepcopy(serves) self._data["groups"][group] = entry self._save() + return True def serves(self, group: str) -> list[dict]: """Return root catalog candidates, or an empty list when legacy caches omit them.""" diff --git a/converter.py b/converter.py index 23ab2ad..2c6cc91 100644 --- a/converter.py +++ b/converter.py @@ -1924,6 +1924,8 @@ def _reconcile_loop(pool: CredentialPool): _endpoint_limits: dict = {} _ENDPOINT_LIMIT_LOCK = threading.Lock() # Workers initialize semaphores concurrently. _published_catalog_signature: str | None = None # Last published catalog content; skips no-op bumps. +_catalog_sequence_lock = threading.Lock() +_catalog_sequences: dict[str, int] = {} def _housekeep_concurrency() -> int: @@ -2270,6 +2272,13 @@ def _account_catalog_key(profile, headers): headers.get("X-Enterprise-Id"))) +def _next_catalog_sequence(key: str) -> int: + with _catalog_sequence_lock: + sequence = _catalog_sequences.get(key, 0) + 1 + _catalog_sequences[key] = sequence + return sequence + + def _catalog_model_signature(model: dict, region: str): """Return the conservative signature used for automatic same-cost variant grouping.""" if not isinstance(model, dict) or model.get("supportsToolCall") is not True: @@ -2331,23 +2340,28 @@ def _build_catalog_route_groups(accounts: dict) -> tuple[dict, dict]: def _fetch_catalog(headers): - """Fetch both catalog scopes for one account token; the only upstream call involved.""" + """Fetch both catalog scopes and report whether personal/v3 supplementation completed.""" with _endpoint_semaphore("models"): 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", ""), prefer_personal=True) - return scopes["picker"], scopes["account"] + return scopes["picker"], scopes["account"], getattr(scopes, "complete", True), getattr(scopes, "error", None) -def _store_catalog(pool, cache, entry, generation, key, models, serves): - """Publish one fetched catalog only while the credential lease is still current.""" +def _store_catalog(pool, cache, entry, generation, key, models, serves, *, complete=True, sequence=None): + """Publish a catalog only while the credential lease and fetch sequence are current.""" + stored = False 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) + nonlocal stored + stored = cache.put(key, models, serves=serves, complete=complete, sequence=sequence) + if stored: + for current in pool._entries: + if current["cm"] is entry["cm"]: + current["catalog_dirty"] = not complete + if not pool.apply_if_current(entry["cm"], generation, publish): + return False + return stored def _sync_one_account_catalog(pool, ledger, entry, generation, headers, profile, failed, cache): @@ -2357,13 +2371,19 @@ def _sync_one_account_catalog(pool, ledger, entry, generation, headers, profile, key = _account_catalog_key(profile, headers) if cache.fresh(key) and not entry.get("catalog_dirty"): return None + sequence = _next_catalog_sequence(key) perf_metrics.inc("catalog_fetch_count") try: - models, serves = _fetch_catalog(headers) - if _store_catalog(pool, cache, entry, generation, key, models, serves): + models, serves, complete, supplemental_error = _fetch_catalog(headers) + if supplemental_error is not None: + _log(f"[models] {profile} 补充目录失败(保留个人目录):{_network_error_text(supplemental_error)}") + if _store_catalog(pool, cache, entry, generation, key, models, serves, + complete=complete, sequence=sequence): _log(f"[models] {profile} 模型表已刷新: 选择器 {len(models)} 个," - f"账号根表 {len(serves)} 个") - return True + f"账号根表 {len(serves)} 个" + ("(待补充 v3)" if not complete else "")) + if not complete: + failed.add(entry["id"]) + return complete failed.add(entry["id"]) except Exception as error: perf_metrics.inc("catalog_fetch_failures") @@ -2463,10 +2483,18 @@ def _probe_profile_catalog(pool, ledger, refs, failed, cache, aggregate, profile return entry, generation, headers = target["entry"], target["generation"], target["headers"] key = _account_catalog_key(profile, headers) + sequence = _next_catalog_sequence(key) perf_metrics.inc("catalog_fetch_count") try: - models, serves = _fetch_catalog(headers) - if not _store_catalog(pool, cache, entry, generation, key, models, serves): + models, serves, complete, supplemental_error = _fetch_catalog(headers) + if supplemental_error is not None: + _log(f"[models] {profile} 补充目录失败(保留个人目录):{_network_error_text(supplemental_error)}") + if not _store_catalog(pool, cache, entry, generation, key, models, serves, + complete=complete, sequence=sequence): + aggregate.failure(profile, source=entry.get("account_key")) + failed.add(entry["id"]) + return + if not complete: aggregate.failure(profile, source=entry.get("account_key")) failed.add(entry["id"]) return @@ -2538,10 +2566,17 @@ def _scan_one_catalog(pool, cache, entry): generation = cm._generation profile = profile_for_headers(headers) key = _account_catalog_key(profile, headers) + sequence = _next_catalog_sequence(key) 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): + models, serves, complete, supplemental_error = _fetch_catalog(headers) + if supplemental_error is not None: + _log(f"[models] {profile} 补充目录失败(保留个人目录):{_network_error_text(supplemental_error)}") + if not _store_catalog(pool, cache, entry, generation, key, models, serves, + complete=complete, sequence=sequence): raise RuntimeError("扫描期间凭证已变化,已跳过该账号") + if not complete: + # The personal catalog is stored, but the scan must report the missing v3 supplement. + raise RuntimeError(f"补充目录失败: {_network_error_text(supplemental_error)}") 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 diff --git a/tests/test_catalog.py b/tests/test_catalog.py index d6329af..da34a72 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -358,6 +358,29 @@ def test_empty_catalog_ttl_and_epoch_zero_age(self): self.assertFalse(cache.fresh("international")) self.assertEqual(cache.age("international"), 60) + def test_partial_catalog_never_replaces_a_complete_one(self): + with patch("app.credits.time.time", return_value=1000): + cache = ModelCatalogCache(self.path, ttl=60) + cache.put("international", [{"id": "merged"}], serves=[{"id": "merged"}], complete=True) + self.assertTrue(cache.fresh("international")) + self.assertFalse(cache.put("international", [{"id": "personal-only"}], + serves=[{"id": "personal-only"}], complete=False)) + self.assertEqual([m["id"] for m in cache.models("international")], ["merged"]) + self.assertTrue(cache.fresh("international")) + # A first fetch without any prior catalog may still cache the personal-only result. + self.assertTrue(cache.put("domestic", [{"id": "personal-only"}], complete=False)) + self.assertFalse(cache.fresh("domestic")) + self.assertEqual([m["id"] for m in cache.models("domestic")], ["personal-only"]) + + def test_stale_catalog_sequence_is_ignored(self): + with patch("app.credits.time.time", return_value=1000): + cache = ModelCatalogCache(self.path, ttl=60) + self.assertTrue(cache.put("international", [{"id": "newer"}], sequence=2)) + self.assertFalse(cache.put("international", [{"id": "older"}], sequence=1)) + self.assertEqual([m["id"] for m in cache.models("international")], ["newer"]) + self.assertTrue(cache.put("international", [{"id": "newest"}], sequence=3)) + self.assertEqual([m["id"] for m in cache.models("international")], ["newest"]) + def test_deep_copy_on_put_and_get(self): cache = ModelCatalogCache(self.path) models = select_cli_models(catalog(["fast"])) diff --git a/tests/test_personal_models.py b/tests/test_personal_models.py index 7ae927b..05aa6d7 100644 --- a/tests/test_personal_models.py +++ b/tests/test_personal_models.py @@ -106,6 +106,19 @@ def test_personal_table_survives_supplemental_v3_failure(self): self.assertEqual(client.get.call_count, 2) + def test_personal_table_survives_supplemental_v3_auth_failure(self): + client = self._client(self.response(200, personal_payload()), self.response(401, {"code": 401})) + scopes = credits.fetch_model_scopes( + jwt("https://www.codebuddy.cn/auth/realms/copilot"), prefer_personal=True) + # A supplemental rejection must not discard a catalog the personal endpoint already returned. + self.assertEqual([item["id"] for item in scopes["picker"]], ["auto", "hy4-preview"]) + self.assertEqual([item["id"] for item in scopes["account"]], + ["auto", "hy4-preview", "hunyuan-image-alpha"]) + self.assertFalse(scopes.complete) + self.assertIsInstance(scopes.error, credits.AuthExpiredError) + self.assertEqual(client.get.call_count, 2) + + def test_enterprise_preferred_fetch_uses_v3_without_personal_path(self): client = self._client(self.response(200, personal_payload())) scopes = credits.fetch_model_scopes( From 695d12f85bc8198770b893fa99f57c5b5846478f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:47:05 +0800 Subject: [PATCH 3/3] Clear ruff findings Remove unused imports and variables, drop needless f-strings, and rename ambiguous loop variables flagged by ruff across the gateway, tests, scripts and preview. Keep the re-exported trial_rewards module and mark the deliberate sys.path-before-import test files. --- app/acp_client.py | 2 +- app/desensitize.py | 2 +- app/observability.py | 1 - converter.py | 15 ++++++++------- preview/upstream.py | 2 +- tests/test_admin_api.py | 3 ++- tests/test_anthropic_adapter.py | 5 +---- tests/test_api_flow.py | 1 - tests/test_audit_action_labels.py | 2 +- tests/test_catalog_probe.py | 5 +++-- tests/test_config_migration.py | 1 - tests/test_credential_cooldowns.py | 1 - tests/test_credit_identity_dedupe.py | 1 - tests/test_credits.py | 7 ++++--- tests/test_daily_chat.py | 2 -- tests/test_enterprise_credits.py | 22 +++++++++++----------- tests/test_inference_resources.py | 1 - tests/test_message_normalization.py | 1 - tests/test_probe_max_tokens.py | 2 +- tests/test_reasoning.py | 6 +++--- tests/test_routing_scheduler.py | 15 ++++++++++----- tests/test_runtime_endpoints.py | 4 ++-- tests/test_security.py | 2 +- tests/test_stream_status_contract.py | 1 - tests/test_travel.py | 2 +- 25 files changed, 51 insertions(+), 55 deletions(-) diff --git a/app/acp_client.py b/app/acp_client.py index cd6f6f9..dd9ed45 100644 --- a/app/acp_client.py +++ b/app/acp_client.py @@ -16,7 +16,7 @@ import json import select import time -from http.client import HTTPConnection, HTTPSConnection, HTTPSConnection as _HTTPS +from http.client import HTTPConnection, HTTPSConnection as _HTTPS from urllib.parse import urlsplit from .credits import BROWSER_UA diff --git a/app/desensitize.py b/app/desensitize.py index f401a1b..5be8a46 100644 --- a/app/desensitize.py +++ b/app/desensitize.py @@ -583,7 +583,7 @@ def apply_system_prompt_mode(body: dict, profile: str, mode: str) -> dict: print(f"{changed} | 原文: {s}") if d != s: print(f" | 脱敏: {d}") - print(f" | 可见字符相同,差异为零宽空格 U+200B") + print(" | 可见字符相同,差异为零宽空格 U+200B") print() print("=== messages 脱敏(只处理 system)===") msgs = [ diff --git a/app/observability.py b/app/observability.py index 8d2e657..442f7e3 100644 --- a/app/observability.py +++ b/app/observability.py @@ -13,7 +13,6 @@ from dataclasses import dataclass, field import json import time -from typing import Any from app.audit_store import AuditStore, METRICS, number, safe_agent, safe_attempt, safe_label from app.request_context import PATHS, current_context, ensure_context diff --git a/converter.py b/converter.py index 2c6cc91..07e92f8 100644 --- a/converter.py +++ b/converter.py @@ -67,13 +67,14 @@ def apply_system_prompt_mode(body, profile, mode): from app import auth_oauth from app.settings import SCHEMA -from app import trial_rewards +# Re-exported for app.runtime_management and the tests, which reach it as ``gateway.trial_rewards``. +from app import trial_rewards # noqa: F401 from app import buddy, daily_chat, growth_chains, growth_tasks, checkin as checkin_service, model_policy, travel from app.credential_cooldowns import CredentialCooldowns from app.model_blocks import ModelBlocks from app.usage_snapshots import UsageSnapshots from app.client_hangup import ClientHungUp, await_or_hangup -from app.observability import (AuditMiddleware, observe_recovery, observe_route, observe_stream_mode, +from app.observability import (observe_recovery, observe_route, observe_stream_mode, observe_responses_projection, observe_usage, observe_attempt, observe_failure, observe_failure_seq, observe_discard, observe_nested) @@ -106,7 +107,7 @@ def apply_system_prompt_mode(body, profile, mode): from app.safe_logging import format_log_body, sanitize_log_text from app import perf_metrics from app.startup import capture_launch, load_startup_env, restart_process, run_server -from app.site_routing import (DOMESTIC, INTERNATIONAL, PROFILE_ENDPOINTS, site_for_auth, site_for_headers, +from app.site_routing import (INTERNATIONAL, PROFILE_ENDPOINTS, site_for_headers, profile_for_auth, profile_for_headers, profile_region, profile_product, profile_site, chat_url_for_headers, search_url_for_headers, fetch_url_for_headers, refresh_url_for_auth, media_url_for_headers, embedding_url_for_headers, @@ -114,8 +115,8 @@ def apply_system_prompt_mode(body, profile, mode): from app import dsml, mcp_server, server_tools, web_fetch, web_search, media_generation, media_store, embeddings, rerank from app.inbound_limits import anthropic_path from app.client_profiles import (CLI_VERSION, CLI_USER_AGENT, credential_headers, credential_refresh_headers, - catalog_cache_key, account_key, canonical_uid, enterprise_id_of, identity_email, - is_enterprise_account, normalize_uid) + catalog_cache_key, account_key, canonical_uid, enterprise_id_of, + is_enterprise_account) try: from app import credits as credits_mod except ImportError: # Disable credit maintenance when its module is unavailable. @@ -4125,7 +4126,7 @@ def _guard_request_size(body: dict) -> int: raise HTTPException(status_code=413, detail={"error": { "message": f"处理后的请求体超过网关上限 {limit} 字节,请缩短历史或压缩图片", "type": "invalid_request_error", "code": "request_too_large", "max_bytes": limit}}) - except (ValueError, UnicodeError) as error: + except (ValueError, UnicodeError): raise HTTPException(status_code=400, detail={"error": { "message": "请求体包含无法序列化的 JSON 值", "type": "invalid_request_error"}}) from None return size @@ -7489,7 +7490,7 @@ def main(): sys.stderr.write("按 Ctrl+C 退出。\n\n") # Record service startup. - _log(f"==== converter 启动 ====") + _log("==== converter 启动 ====") CONFIG["launch"] = launch try: diff --git a/preview/upstream.py b/preview/upstream.py index 2183c1b..423d9c0 100644 --- a/preview/upstream.py +++ b/preview/upstream.py @@ -594,7 +594,7 @@ def travel_depart(self, request, body, state): return Reply(payload={"code": 400, "msg": "already traveling"}) if _departed_today(state) >= DAILY_TRIPS: return Reply(payload={"code": 400, "msg": "daily limit reached"}) - location = next((l for l in LOCATIONS if l["id"] == body.get("location_id")), LOCATIONS[0]) + location = next((item for item in LOCATIONS if item["id"] == body.get("location_id")), LOCATIONS[0]) today = time.strftime("%Y-%m-%d") state.departures = {today: _departed_today(state) + 1} state.trip = {"state": "traveling", "location": location, "arrive_at": time.time() + TRAVEL_SECONDS, diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index f84d2f8..bba7936 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -609,7 +609,8 @@ def refresh_token(self, uid): return f"header.{claims}.signature" def test_refresh_import_rejects_bad_endpoint_and_batches(self): - post = lambda body: self.client.post("/admin/credentials/refresh-import", headers=self.headers, json=body) + def post(body): + return self.client.post("/admin/credentials/refresh-import", headers=self.headers, json=body) self.assertEqual(post({"profile": "unknown", "refresh_tokens": ["a"]}).status_code, 400) for tokens in ([], [""], [1], ["x" * 8193], ["a"] * 51): with self.subTest(count=len(tokens)): diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 898389b..37d13da 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -251,12 +251,9 @@ def test_stream_converter_text(): if not evt_block: continue lines = evt_block.strip().split("\n") - event_type = None data_json = None for evt_line in lines: - if evt_line.startswith("event: "): - event_type = evt_line[7:] - elif evt_line.startswith("data: "): + if evt_line.startswith("data: "): data_json = json.loads(evt_line[6:]) if data_json: all_events.append(data_json) diff --git a/tests/test_api_flow.py b/tests/test_api_flow.py index 9774742..fd535f2 100644 --- a/tests/test_api_flow.py +++ b/tests/test_api_flow.py @@ -7,7 +7,6 @@ import asyncio from copy import deepcopy from email.utils import formatdate -import json import threading import time import unittest diff --git a/tests/test_audit_action_labels.py b/tests/test_audit_action_labels.py index 5539438..b5ae1b8 100644 --- a/tests/test_audit_action_labels.py +++ b/tests/test_audit_action_labels.py @@ -18,7 +18,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) # Allow direct execution. -from app.audit_store import CREDIT_CHANGES +from app.audit_store import CREDIT_CHANGES # noqa: E402 - sys.path is prepared above SOURCES = sorted([*(ROOT / "app").glob("*.py"), ROOT / "converter.py"]) # Local helpers whose first stage argument becomes "buddy." + stage, and admin_api's diff --git a/tests/test_catalog_probe.py b/tests/test_catalog_probe.py index 1a5d8c8..2ab356a 100644 --- a/tests/test_catalog_probe.py +++ b/tests/test_catalog_probe.py @@ -12,7 +12,7 @@ from unittest.mock import patch import converter -from app import model_policy, perf_metrics +from app import perf_metrics from app.catalog_probe import AggregateCatalog, valid_row from app.control_store import ControlStore from app.credits import CreditLedger, ModelCatalogCache @@ -58,7 +58,8 @@ def test_choose_prefers_unknown_then_source_then_the_stalest(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"]] + def age_of(candidate): + return 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"], "c") # Unknown first. diff --git a/tests/test_config_migration.py b/tests/test_config_migration.py index 28b00d9..3f097e1 100644 --- a/tests/test_config_migration.py +++ b/tests/test_config_migration.py @@ -4,7 +4,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import hashlib -import os import sqlite3 import tempfile import unittest diff --git a/tests/test_credential_cooldowns.py b/tests/test_credential_cooldowns.py index b4fdfdb..aba5643 100644 --- a/tests/test_credential_cooldowns.py +++ b/tests/test_credential_cooldowns.py @@ -6,7 +6,6 @@ import sys import tempfile import time -import time import unittest from pathlib import Path from types import SimpleNamespace diff --git a/tests/test_credit_identity_dedupe.py b/tests/test_credit_identity_dedupe.py index d669d95..6e27b4b 100644 --- a/tests/test_credit_identity_dedupe.py +++ b/tests/test_credit_identity_dedupe.py @@ -7,7 +7,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. -from app import credits from app.credits import CreditLedger, aggregate_credits, dedupe_by_identity IDENTITY = "a" * 64 diff --git a/tests/test_credits.py b/tests/test_credits.py index e4c4618..e49331a 100644 --- a/tests/test_credits.py +++ b/tests/test_credits.py @@ -699,9 +699,10 @@ def test_fetch_credits_tolerates_unrelated_shape_noise(): def test_fetch_credits_paginates_until_short_page(): """Aggregate credit pages, stop on short pages and mark capped results partial.""" token = _jwt("https://www.codebuddy.cn/x") - account = lambda i: {"PackageName": f"p{i}", "PackageCode": f"c{i}", - "SlicePeriodUsageDetails": [{"SlicePeriodCapacityRemainPrecise": "1", - "DeductionEndTime": None}]} + def account(i): + return {"PackageName": f"p{i}", "PackageCode": f"c{i}", + "SlicePeriodUsageDetails": [{"SlicePeriodCapacityRemainPrecise": "1", + "DeductionEndTime": None}]} full_page = {"code": 0, "data": {"Response": {"Data": {"Accounts": [account(i) for i in range(100)]}}}} short_page = {"code": 0, "data": {"Response": {"Data": {"Accounts": [account(1000)]}}}} seen = [] diff --git a/tests/test_daily_chat.py b/tests/test_daily_chat.py index 3d9db11..82bf195 100644 --- a/tests/test_daily_chat.py +++ b/tests/test_daily_chat.py @@ -10,14 +10,12 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -import json import time import unittest from unittest.mock import patch import converter from app import acp_client, daily_chat -from app.control_store import ControlStore from tests import test_credential_actions as fixtures diff --git a/tests/test_enterprise_credits.py b/tests/test_enterprise_credits.py index b46c805..4b8fd82 100644 --- a/tests/test_enterprise_credits.py +++ b/tests/test_enterprise_credits.py @@ -23,7 +23,7 @@ from app import credits import converter from app.client_profiles import ( - CLI_USER_AGENT, enterprise_id_of, is_enterprise_account, + enterprise_id_of, is_enterprise_account, ) from app.credits import ( UNLIMITED_ALLOWANCE, @@ -166,7 +166,7 @@ def handler(request): def test_remaining_is_limit_minus_used(self): seen, handler = self._capture(ENTERPRISE_PAYLOAD) - with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with httpx.Client(transport=httpx.MockTransport(handler)): with _patch_client(handler): result = fetch_enterprise_credits(CN_TOKEN, uid="uid-1", domain="www.codebuddy.cn", @@ -186,7 +186,7 @@ def test_unlimited_is_flagged_without_a_number(self): """limitNum == -1 means unlimited: never report a fabricated balance.""" payload = {"code": 0, "data": {"limitNum": UNLIMITED_ALLOWANCE, "credit": 12.0}} _seen, handler = self._capture(payload) - with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with httpx.Client(transport=httpx.MockTransport(handler)): with _patch_client(handler): result = fetch_enterprise_credits(CN_TOKEN, uid="u", domain="www.codebuddy.cn", enterprise_id="t") @@ -197,7 +197,7 @@ def test_unlimited_is_flagged_without_a_number(self): def test_exhausted_allowance_yields_no_segments(self): payload = {"code": 0, "data": {"limitNum": 100, "credit": 100}} _seen, handler = self._capture(payload) - with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with httpx.Client(transport=httpx.MockTransport(handler)): with _patch_client(handler): result = fetch_enterprise_credits(CN_TOKEN, uid="u", domain="www.codebuddy.cn", enterprise_id="t") @@ -207,7 +207,7 @@ def test_exhausted_allowance_yields_no_segments(self): def test_api_error_raises_readable_failure(self): payload = {"code": 40001, "msg": "tenant not found"} _seen, handler = self._capture(payload) - with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with httpx.Client(transport=httpx.MockTransport(handler)): with _patch_client(handler): with self.assertRaises(RuntimeError) as caught: fetch_enterprise_credits(CN_TOKEN, uid="u", domain="www.codebuddy.cn", @@ -216,7 +216,7 @@ def test_api_error_raises_readable_failure(self): def test_unparsable_payload_raises_instead_of_reporting_zero(self): _seen, handler = self._capture({"code": 0, "data": {"unexpected": True}}) - with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with httpx.Client(transport=httpx.MockTransport(handler)): with _patch_client(handler): with self.assertRaises(RuntimeError): fetch_enterprise_credits(CN_TOKEN, uid="u", domain="www.codebuddy.cn", @@ -233,7 +233,7 @@ def handler(request): seen["url"] = str(request.url) return httpx.Response(200, json=ENTERPRISE_PAYLOAD) - with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with httpx.Client(transport=httpx.MockTransport(handler)): with _patch_client(handler): fetch_credits(CN_TOKEN, uid="u", domain="www.codebuddy.cn", enterprise_id="tenant-a") @@ -249,7 +249,7 @@ def handler(request): # The personal list answers with an empty Accounts array. return httpx.Response(200, json={"code": 0, "data": {"Response": {"Data": {"Accounts": []}}}}) - with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with httpx.Client(transport=httpx.MockTransport(handler)): with _patch_client(handler): result = fetch_credits(CN_TOKEN, uid="u", domain="www.codebuddy.cn") @@ -266,7 +266,7 @@ def handler(request): seen["urls"].append(str(request.url)) return httpx.Response(200, json={"code": 0, "data": {"Response": {"Data": {"Accounts": []}}}}) - with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with httpx.Client(transport=httpx.MockTransport(handler)): with _patch_client(handler): fetch_credits(CN_TOKEN, uid="u", domain="www.codebuddy.cn", enterprise_id="") @@ -300,7 +300,7 @@ def test_resolution_returns_id_and_swallows_transport_failure(self): def handler(request): return httpx.Response(200, json=payload) - with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with httpx.Client(transport=httpx.MockTransport(handler)): with _patch_client(handler): resolved = credits.resolve_enterprise_id(CN_TOKEN, uid="u", domain="www.codebuddy.cn") self.assertEqual(resolved, "t-resolved") @@ -308,7 +308,7 @@ def handler(request): def failing(_request): raise httpx.ConnectError("offline") - with httpx.Client(transport=httpx.MockTransport(failing)) as client: + with httpx.Client(transport=httpx.MockTransport(failing)): with _patch_client(failing): self.assertIsNone(credits.resolve_enterprise_id(CN_TOKEN, uid="u", domain="www.codebuddy.cn")) diff --git a/tests/test_inference_resources.py b/tests/test_inference_resources.py index a559969..f935fbf 100644 --- a/tests/test_inference_resources.py +++ b/tests/test_inference_resources.py @@ -7,7 +7,6 @@ import asyncio from concurrent.futures import ThreadPoolExecutor from contextlib import suppress -import json import os import threading from types import SimpleNamespace diff --git a/tests/test_message_normalization.py b/tests/test_message_normalization.py index 3e62374..3cdea62 100644 --- a/tests/test_message_normalization.py +++ b/tests/test_message_normalization.py @@ -1,6 +1,5 @@ """Verify international image-run normalization without changing domestic requests or replay policy.""" from copy import deepcopy -import json from pathlib import Path import sys import unittest diff --git a/tests/test_probe_max_tokens.py b/tests/test_probe_max_tokens.py index 12ce380..2bff27b 100644 --- a/tests/test_probe_max_tokens.py +++ b/tests/test_probe_max_tokens.py @@ -14,7 +14,7 @@ probe = importlib.util.module_from_spec(spec) spec.loader.exec_module(probe) -from app import output_probes +from app import output_probes # noqa: E402 - the script path is prepared above def run(replies, claimed=None, tiers=(40000, 100000), retries=2, budget=600): diff --git a/tests/test_reasoning.py b/tests/test_reasoning.py index dc6902d..f015562 100644 --- a/tests/test_reasoning.py +++ b/tests/test_reasoning.py @@ -78,8 +78,8 @@ def test_sse_replay_reasoning_before_content(self): self.assertIn("正文", content) self.assertEqual(sum("reasoning_content" in line for line in lines), 1) # Reasoning deltas precede text deltas. - first_reasoning = next(i for i, l in enumerate(lines) if "reasoning_content" in l) - first_content = next(i for i, l in enumerate(lines) if '"content": "正' in l or '"content":"正' in l) + first_reasoning = next(i for i, line in enumerate(lines) if "reasoning_content" in line) + first_content = next(i for i, line in enumerate(lines) if '"content": "正' in line or '"content":"正' in line) self.assertLess(first_reasoning, first_content) def test_reasoning_coalescer_preserves_multiple_choices(self): @@ -115,7 +115,7 @@ class TestReplayEnvelope(unittest.TestCase): def test_replayed_chunks_share_stable_envelope(self): merged = _merge_chat_sse_text(_SSE) lines = _chat_result_to_sse_lines(merged) - chunks = [json.loads(l[6:]) for l in lines if l.startswith("data: ") and l != "data: [DONE]"] + chunks = [json.loads(line[6:]) for line in lines if line.startswith("data: ") and line != "data: [DONE]"] self.assertTrue(chunks) ids = {c["id"] for c in chunks} self.assertEqual(len(ids), 1) and next(iter(ids)).startswith("chatcmpl-") diff --git a/tests/test_routing_scheduler.py b/tests/test_routing_scheduler.py index bb295d5..c72369e 100644 --- a/tests/test_routing_scheduler.py +++ b/tests/test_routing_scheduler.py @@ -37,7 +37,8 @@ def setUp(self): def test_legacy_never_reuses_a_shard(self): scheduler = RoutingScheduler() calls = [] - evaluate = lambda entry: calls.append(entry) or entry["id"] != "/cred-0" + def evaluate(entry): + return 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, @@ -50,7 +51,8 @@ def test_legacy_never_reuses_a_shard(self): def test_indexed_builds_once_per_revision_set(self): scheduler = RoutingScheduler() calls = [] - evaluate = lambda entry: calls.append(entry) or True + def evaluate(entry): + return 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) @@ -60,7 +62,8 @@ def test_indexed_builds_once_per_revision_set(self): def test_shard_cache_evicts_least_recently_used(self): scheduler = RoutingScheduler(limit=2) - evaluate = lambda entry: True + def evaluate(entry): + return True for model in ("m1", "m2"): scheduler.candidates(mode="indexed", revisions=(1,), model=model, region=None, entries=self.entries, evaluate=evaluate) @@ -78,7 +81,8 @@ def test_conservative_rollout_defaults(self): def test_shadow_returns_legacy_and_records_mismatches(self): scheduler = RoutingScheduler() allowed = {"value": 0} - evaluate = lambda entry: allowed["value"] == 0 + def evaluate(entry): + return 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. @@ -135,7 +139,8 @@ def build(self, count, mode="indexed"): 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) + def pick(skey=None, **kw): + return (pool.pick(skey, "glm-5.3-flash", **kw) or None) for _ in range(6): manager = pick() observed.append(Path(manager.path).name) diff --git a/tests/test_runtime_endpoints.py b/tests/test_runtime_endpoints.py index 1df3b13..69c51a2 100644 --- a/tests/test_runtime_endpoints.py +++ b/tests/test_runtime_endpoints.py @@ -113,7 +113,8 @@ def test_multiple_candidates_are_rejected_before_reaching_upstream(self): def test_tool_arguments_must_be_objects_of_declared_tools(self): """Reject non-object tool arguments and unknown declared-tool names.""" body = {"tools": TOOLS} - call = lambda name, args: [{"id": "c1", "function": {"name": name, "arguments": args}}] + def call(name, args): + return [{"id": "c1", "function": {"name": name, "arguments": args}}] healthy = converter._tool_calls_healthy self.assertTrue(healthy(None, body)) self.assertTrue(healthy(call("synthetic_tool", "{}"), body)) @@ -895,7 +896,6 @@ def test_main_starts_no_maintenance_thread_before_hydration(self): stack.enter_context(patch.object(converter, "seed_credentials")) stack.enter_context(patch.object(converter, "preflight", return_value=True)) stack.enter_context(patch.object(runtime_management, "install")) - real_thread = converter.threading.Thread def note_thread(*args, **kwargs): events.append(("thread", converter.CONFIG.get("usage_daily") is not None)) diff --git a/tests/test_security.py b/tests/test_security.py index 929aa79..66b1dbb 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -17,7 +17,7 @@ import converter from app import credential_io from app.credential_io import ( - CredentialFileError, MAX_CREDENTIAL_BYTES, atomic_write_credential, read_import_file, + CredentialFileError, MAX_CREDENTIAL_BYTES, atomic_write_credential, ) from tests.admin_session import direct_request diff --git a/tests/test_stream_status_contract.py b/tests/test_stream_status_contract.py index a3aecf0..b96e05e 100644 --- a/tests/test_stream_status_contract.py +++ b/tests/test_stream_status_contract.py @@ -7,7 +7,6 @@ import asyncio import json -import time import unittest from unittest.mock import patch diff --git a/tests/test_travel.py b/tests/test_travel.py index 8d33287..598abd6 100644 --- a/tests/test_travel.py +++ b/tests/test_travel.py @@ -371,7 +371,7 @@ def other_process(request): result, calls = self.run_trip([IDLE, CONFIG], buddy_context=context, on_request=other_process) self.assertTrue(result['departure_pending']) self.assertFalse(any(r.method == 'POST' for r in calls)) - attempt = self.store.reserve_travel_write('reserved', 'depart', 21) + self.store.reserve_travel_write('reserved', 'depart', 21) result, calls = self.run_trip([TRAVELING], buddy_context={'store': self.store, 'identity': 'reserved'}, read_only=True) self.assertTrue(result['departure_pending']) self.assertEqual(self.store.travel_write_record('reserved')['phase'], 'reserved')