Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 60 additions & 11 deletions app/credits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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:
Expand All @@ -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"]


# ---------------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]


Expand Down
2 changes: 1 addition & 1 deletion docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/advanced.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 时,在下次刷新前继续使用选择器子集。

Expand Down
11 changes: 11 additions & 0 deletions preview/upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
97 changes: 97 additions & 0 deletions tests/test_personal_models.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 12 additions & 0 deletions tests/test_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading