diff --git a/app/credits.py b/app/credits.py index 4e5603a..d2ae60f 100644 --- a/app/credits.py +++ b/app/credits.py @@ -15,7 +15,7 @@ import httpx -from .client_profiles import catalog_headers, enterprise_id_of +from .client_profiles import catalog_headers, credential_headers, enterprise_id_of from .safe_logging import sanitize_log_text from .site_routing import PROFILE_ENDPOINTS, profile_for_auth, profile_product @@ -42,6 +42,7 @@ # An enterprise allowance reported as -1 is unlimited rather than exhausted. UNLIMITED_ALLOWANCE = -1 CONFIG_PATH = "/v3/config" # Official cloud model catalog +PERSONAL_MODELS_PATH = "/v2/enterprises/personal/models" # Rich personal model catalog RESOURCE_PRODUCT_CODE = "p_tcaca" CREDITS_PAGE_SIZE = 100 CREDITS_MAX_PAGES = 20 # Mark capped results partial instead of reporting complete balances. @@ -54,6 +55,10 @@ class AuthExpiredError(Exception): """Signal an expired token from a billing HTTP 401 response.""" +class _PersonalCatalogUnavailable(RuntimeError): + """Signal that an older or non-SaaS product has no personal model route.""" + + # --------------------------------------------------------------------------- # Billing host selection # --------------------------------------------------------------------------- @@ -700,11 +705,61 @@ def select_cli_models(data: dict) -> list[dict]: return select_product_models(data, "cli") +def _parse_model_scopes(payload: object, product: str, label: str) -> dict[str, list[dict]]: + if not isinstance(payload, dict): + raise ValueError(f"{label}格式错误: response") + if payload.get("code") != 0: + raise RuntimeError(f"{label}返回非成功状态") + data = payload.get("data") + if not isinstance(data, dict): + raise ValueError(f"{label}格式错误: data") + return {"picker": select_product_models(data, product), + "account": select_product_models(data, product, scope="account")} + + +def fetch_personal_model_scopes(access_token: str, user_agent: str = "", *, domain: str = "", + uid: str = "", enterprise_id: str = "") -> dict[str, list[dict]]: + """Fetch the richer personal model table used by the desktop cloud-agent client.""" + if enterprise_id: + raise ValueError("企业账号不能请求个人模型表") + auth = {"accessToken": access_token, "domain": domain} + profile = profile_for_auth(auth) + headers = credential_headers(auth, {"uid": uid}) + headers.update({"Accept": "application/json, text/plain, */*", + "X-Requested-With": "XMLHttpRequest", + "X-Request-ID": secrets.token_hex(16)}) + if user_agent: + headers["User-Agent"] = user_agent + try: + with httpx.Client() as client: + response = client.get(BILLING_PROFILE_HOSTS[profile] + PERSONAL_MODELS_PATH, + headers=headers, timeout=REQUEST_TIMEOUT) + except httpx.HTTPError: + raise RuntimeError("个人模型表接口网络错误") from None + if response.status_code == 401: + raise AuthExpiredError("登录身份过期") + if response.status_code in (404, 405, 501): + raise _PersonalCatalogUnavailable(f"个人模型表接口 HTTP {response.status_code}") + if response.status_code != 200: + raise RuntimeError(f"个人模型表接口 HTTP {response.status_code}") + try: + payload = response.json() + except ValueError: + raise ValueError("个人模型表接口返回无法解析") from None + 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 = "") -> dict[str, list[dict]]: + 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.""" 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: @@ -719,20 +774,14 @@ def fetch_model_scopes(access_token: str, user_agent: str = "", *, domain: str = payload = response.json() except ValueError: raise ValueError("模型配置接口返回无法解析") from None - if not isinstance(payload, dict): - raise ValueError("模型配置格式错误: response") - if payload.get("code") != 0: - raise RuntimeError("模型配置接口返回非成功状态") - product = profile_product(profile) - return {"picker": select_product_models(payload.get("data"), product), - "account": select_product_models(payload.get("data"), product, scope="account")} + return _parse_model_scopes(payload, profile_product(profile), "模型配置接口") def fetch_model_catalog(access_token: str, user_agent: str = "", *, domain: str = "", - uid: str = "", enterprise_id: str = "") -> list[dict]: + uid: str = "", enterprise_id: str = "", prefer_personal: bool = False) -> list[dict]: """Return selector models for this credential's product.""" return fetch_model_scopes(access_token, user_agent, domain=domain, uid=uid, - enterprise_id=enterprise_id)["picker"] + enterprise_id=enterprise_id, prefer_personal=prefer_personal)["picker"] # --------------------------------------------------------------------------- diff --git a/converter.py b/converter.py index 58ec0dc..e2e894b 100644 --- a/converter.py +++ b/converter.py @@ -2263,7 +2263,8 @@ def _fetch_catalog(headers): 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", "")) + uid=headers.get("X-User-Id", ""), enterprise_id=headers.get("X-Enterprise-Id", ""), + prefer_personal=True) return scopes["picker"], scopes["account"] diff --git a/docs/advanced.md b/docs/advanced.md index 6373ddb..8fe46f1 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. -Each `/v3/config` 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; 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. 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 694ba89..d97026d 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` 默认模型保持独立;共享倍率只是目录参考,不保证权限或实际扣分。 -每次 `/v3/config` 刷新将选择器子集缓存为 `models`,账号根表缓存为 `serves`。选路与 `GET /v1/models` 合并这两组候选,同名保留选择器元数据;`disabled` 和 `availableModels` 筛选仍生效,不支持工具调用的模型会被排除。 +个人账号优先使用可用的 `/v2/enterprises/personal/models` 丰富模型表,其中包含同样的选择器/账号根表划分,以及 credits、上下文窗口和媒体元数据。企业账号继续使用 `/v3/config`;旧部署没有个人端点时也回退到该接口。每次刷新将选择器子集缓存为 `models`,账号根表缓存为 `serves`。选路与 `GET /v1/models` 合并这两组候选,同名保留选择器元数据;`disabled` 和 `availableModels` 筛选仍生效,不支持工具调用的模型会被排除。 根表不保证每个模型都可调用,仍由实测 `11102` 避让兜底。未知账号目录不参与派发,也不能触发过早的「全部后端不支持」404,应保留可重试的未就绪状态。旧缓存没有 `serves` 时,在下次刷新前继续使用选择器子集。 diff --git a/preview/upstream.py b/preview/upstream.py index 0c767ab..2183c1b 100644 --- a/preview/upstream.py +++ b/preview/upstream.py @@ -155,6 +155,7 @@ def __init__(self, accounts=ACCOUNTS): "/v2/plugin/auth/token": self.login_token, "/v2/plugin/login/account": self.login_account, "/v3/config": self.catalog, + "/v2/enterprises/personal/models": self.personal_models, "/console/accounts": self.console_accounts, "/v2/billing/meter/get-user-resource": self.resources, "/v2/billing/meter/get-enterprise-user-usage": self.enterprise_usage, @@ -325,6 +326,16 @@ def catalog(self, request, body, state): models[0]["isDefault"] = True return ok({"models": models, "agents": agents}) + def personal_models(self, request, body, state): + if state.account.enterprise: + return fail(403, "enterprise account has no personal model table") + reply = self.catalog(request, body, state) + data = reply.payload["data"] + data.update(endpoint=f"https://{request.url.host}", mergeStrategy="merge", + defaultVideoModel=next((name for name in state.account.media + if "video" in media_generation.model_kinds(name)), None)) + return reply + def console_accounts(self, request, body, state): account = state.account item = {"uid": account.uid, "nickname": account.nickname} diff --git a/tests/test_personal_models.py b/tests/test_personal_models.py new file mode 100644 index 0000000..2905a69 --- /dev/null +++ b/tests/test_personal_models.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Tests for the richer personal model table and its compatibility fallback.""" + +import base64 +import json +import sys +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock, patch + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app import credits + + +def jwt(issuer): + payload = base64.urlsafe_b64encode(json.dumps({"iss": issuer}).encode()).decode().rstrip("=") + return f"header.{payload}.sig" + + +def personal_payload(): + return {"code": 0, "data": { + "models": [ + {"id": "auto", "name": "Auto", "supportsToolCall": True, "isDefault": True}, + {"id": "hy4-preview", "name": "Hy4 preview", "credits": "x0.29", + "maxInputTokens": 960000, "contextWindow": {"defaultLength": 300000}, + "supportsToolCall": True}, + {"id": "hunyuan-image-alpha", "name": "Hunyuan Image Alpha", + "tags": ["text-to-image"]}, + ], + "agents": [{"name": "cli", "tags": ["cli", "default"], + "models": ["auto", "hy4-preview"]}], + "endpoint": "https://copilot.tencent.com", + "mergeStrategy": "merge", + }} + + +class PersonalModelTableTests(TestCase): + def _client(self, *responses): + client = MagicMock() + client.get.side_effect = list(responses) + factory = patch("app.credits.httpx.Client") + mocked = factory.start() + self.addCleanup(factory.stop) + mocked.return_value.__enter__.return_value = client + return client + + @staticmethod + def response(status, payload): + return httpx.Response(status, json=payload) + + def test_personal_endpoint_preserves_rich_fields_and_scopes(self): + client = self._client(self.response(200, personal_payload())) + scopes = credits.fetch_personal_model_scopes( + jwt("https://www.codebuddy.cn/auth/realms/copilot"), + user_agent="CLI/test", uid="user-1") + 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"]) + rich = scopes["account"][1] + self.assertEqual(rich["credits"], "x0.29") + self.assertEqual(rich["contextWindow"]["defaultLength"], 300000) + url = client.get.call_args.args[0] + self.assertEqual(url, "https://www.codebuddy.cn/v2/enterprises/personal/models") + headers = client.get.call_args.kwargs["headers"] + self.assertEqual(headers["X-Requested-With"], "XMLHttpRequest") + self.assertEqual(len(headers["X-Request-ID"]), 32) + self.assertNotIn("user-1", headers["X-Request-ID"]) + + def test_preferred_personal_table_falls_back_to_v3(self): + client = self._client(self.response(404, {"code": 404}), + self.response(200, personal_payload())) + 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_args_list[0].args[0], + "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_enterprise_preferred_fetch_uses_v3_without_personal_path(self): + client = self._client(self.response(200, personal_payload())) + scopes = credits.fetch_model_scopes( + jwt("https://www.codebuddy.cn/auth/realms/copilot"), + enterprise_id="tenant-a", prefer_personal=True) + self.assertEqual([item["id"] for item in scopes["picker"]], ["auto", "hy4-preview"]) + self.assertEqual(client.get.call_count, 1) + self.assertEqual(client.get.call_args.args[0], "https://copilot.tencent.com/v3/config") + + +if __name__ == "__main__": + import unittest + + unittest.main(verbosity=2) diff --git a/tests/test_preview.py b/tests/test_preview.py index 48c88ad..2d89000 100644 --- a/tests/test_preview.py +++ b/tests/test_preview.py @@ -92,6 +92,18 @@ def test_catalog_and_usage(self): usage = credits.fetch_request_usage(token, uid=uid, domain=domain) self.assertEqual(usage["requests"], 30 * ACCOUNTS["dune"].daily_requests) + def test_personal_model_table_has_rich_catalog_fields(self): + token, uid, domain = login("aurora") + response = httpx.get("https://www.codebuddy.cn/v2/enterprises/personal/models", + headers={"Authorization": f"Bearer {token}", "X-User-Id": uid, + "X-Domain": domain, "X-Requested-With": "XMLHttpRequest"}) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertEqual(payload["code"], 0) + self.assertIn("agents", payload["data"]) + self.assertIn("models", payload["data"]) + self.assertTrue(payload["data"]["models"]) + def test_checkin_once_per_day(self): token, uid, domain = login("cedar") self.assertEqual(credits.daily_checkin(token, uid, domain)["state"], "success")