Repository navigation
Dispatch enterprise accounts to the enterprise allowance endpoint #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c016940
15bb2fd
8c49c6e
e1a05f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,7 +13,7 @@ | |
|
|
||
| import httpx | ||
|
|
||
| from .client_profiles import catalog_headers | ||
| from .client_profiles import catalog_headers, enterprise_id_of | ||
| from .site_routing import PROFILE_ENDPOINTS, profile_for_auth, profile_product | ||
|
|
||
| BROWSER_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " | ||
|
|
@@ -30,6 +30,14 @@ | |
| CHECKIN_PATHS = ("/v2/billing/meter/daily-checkin",) | ||
| CHECKIN_STATUS_PATH = "/v2/billing/meter/checkin-activity-status" | ||
| RESOURCE_PATH = "/v2/billing/meter/get-user-resource" | ||
| # Enterprise tenants do not bill through the personal resource endpoint; it answers | ||
| # with an empty Accounts structure. The official clients dispatch on the tenant id | ||
| # before the region/product split, so this path is shared by CN and intl accounts. | ||
| ENTERPRISE_USAGE_PATH = "/v2/billing/meter/get-enterprise-user-usage" | ||
| # Fallback tenant lookup for historical credentials that carry a type but no id. | ||
| ENTERPRISE_ACCOUNTS_PATH = "/console/accounts" | ||
| # An enterprise allowance reported as -1 is unlimited rather than exhausted. | ||
| UNLIMITED_ALLOWANCE = -1 | ||
| CONFIG_PATH = "/v3/config" # Official cloud model catalog | ||
| RESOURCE_PRODUCT_CODE = "p_tcaca" | ||
| CREDITS_PAGE_SIZE = 100 | ||
|
|
@@ -66,7 +74,8 @@ def hosts_for_token(access_token: str, domain: str = "") -> list[str]: | |
| return [BILLING_PROFILE_HOSTS[profile]] | ||
|
|
||
|
|
||
| def _web_headers(api_host: str, access_token: str, uid: str = "", domain: str = "") -> dict: | ||
| def _web_headers(api_host: str, access_token: str, uid: str = "", domain: str = "", | ||
| enterprise_id: str = "") -> dict: | ||
| """Build website billing headers independently of CLI model catalog headers.""" | ||
| return { | ||
| "accept": "application/json, text/plain, */*", | ||
|
|
@@ -77,6 +86,10 @@ def _web_headers(api_host: str, access_token: str, uid: str = "", domain: str = | |
| "authorization": f"Bearer {access_token}", | ||
| "x-user-id": str(uid or ""), | ||
| "x-domain": str(domain or ""), | ||
| # The website interceptor always carries the tenant id; enterprise endpoints | ||
| # reject the request without it. | ||
| "x-enterprise-id": str(enterprise_id or ""), | ||
| "x-tenant-id": str(enterprise_id or ""), | ||
| "user-agent": BROWSER_UA, | ||
| } | ||
|
|
||
|
|
@@ -93,6 +106,18 @@ def _post_json(client: httpx.Client, url: str, headers: dict, body: dict) -> tup | |
| return r.status_code, payload | ||
|
|
||
|
|
||
| def _get_json(client: httpx.Client, url: str, headers: dict) -> tuple[int, dict]: | ||
| """GET JSON and return status/payload, raising AuthExpiredError on HTTP 401.""" | ||
| r = client.get(url, headers=headers, timeout=REQUEST_TIMEOUT) | ||
| if r.status_code == 401: | ||
| raise AuthExpiredError("登录身份过期") | ||
| try: | ||
| payload = r.json() | ||
| except Exception: | ||
| payload = {} | ||
| return r.status_code, payload | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Daily check-in | ||
| # --------------------------------------------------------------------------- | ||
|
|
@@ -340,8 +365,132 @@ def _fetch_accounts_page(client, url: str, headers: dict, page: int, *, retry_em | |
| raise RuntimeError(f"积分查询失败: {last_err}") | ||
|
|
||
|
|
||
| def fetch_credits(access_token: str, uid: str = "", domain: str = "") -> dict: | ||
| """Fetch credit segments and mark page-limited results partial; HTTP 401 raises AuthExpiredError.""" | ||
| # --------------------------------------------------------------------------- | ||
| # Enterprise allowance | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| _ENTERPRISE_LIMIT_FIELDS = ("limitNum", "limit_num") | ||
| _ENTERPRISE_USED_FIELDS = ("credit", "used_num") | ||
| _ENTERPRISE_RESET_FIELDS = ("cycleResetTime", "CycleResetTime", "cycle_reset_time") | ||
| _ENTERPRISE_PARSE_ERROR = "企业配额数据无法解析" | ||
|
|
||
|
|
||
| def parse_enterprise_usage(payload: dict) -> dict | None: | ||
| """Parse the enterprise allowance payload, or return None when unreadable. | ||
|
|
||
| Accepts the three observed wrappings (``data.data``, ``data``, top level) and both | ||
| naming conventions, since the two client implementations disagree on casing. | ||
| """ | ||
| if not isinstance(payload, dict): | ||
| return None | ||
| data = payload.get("data") if isinstance(payload.get("data"), dict) else {} | ||
| nested = data.get("data") if isinstance(data.get("data"), dict) else {} | ||
| for candidate in (nested, data, payload): | ||
| if not candidate: | ||
| continue | ||
| limit = _first_number(candidate, _ENTERPRISE_LIMIT_FIELDS) | ||
| if limit is None or not math.isfinite(limit): | ||
| continue | ||
| used = _first_number(candidate, _ENTERPRISE_USED_FIELDS) | ||
| used = max(used, 0.0) if used is not None and math.isfinite(used) else 0.0 | ||
| return {"limit": limit, "used": used, | ||
| "reset_at": _first_ts(candidate, _ENTERPRISE_RESET_FIELDS)} | ||
| return None | ||
|
|
||
|
|
||
| def enterprise_id_from_accounts(payload: dict) -> str | None: | ||
| """Extract the first tenant id from a ``/console/accounts`` response.""" | ||
| if not isinstance(payload, dict): | ||
| return None | ||
| code = payload.get("code") | ||
| if code not in (0, 200, None): | ||
| return None | ||
| data = payload.get("data") if isinstance(payload.get("data"), dict) else {} | ||
| for key in ("accounts", "Accounts"): | ||
| items = data.get(key) | ||
| if isinstance(items, list) and items and isinstance(items[0], dict): | ||
| return enterprise_id_of(items[0]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Multiple tenants select the wrong allowance When Select the tenant corresponding to the credential, or reject ambiguous account lists rather than silently using the first tenant. Prompt for AI agentsIn `app/credits.py` at line 412:
**Multiple tenants select the wrong allowance**
When `/console/accounts` returns multiple tenant accounts for a credential without a stored tenant ID, `enterprise_id_from_accounts()` always takes the first account, so a credential with no stored tenant ID is dispatched using whichever tenant appears first in the response, even when the credential belongs to another listed tenant. The displayed balance and credit-aware routing then use the wrong tenant’s allowance.
Select the tenant corresponding to the credential, or reject ambiguous account lists rather than silently using the first tenant. |
||
| return None | ||
|
|
||
|
|
||
| def resolve_enterprise_id(access_token: str, uid: str = "", domain: str = "") -> str | None: | ||
| """Look up the tenant id once for credentials that only carry an enterprise type. | ||
|
|
||
| Historical backups may have ``type=ultimate`` without ``enterpriseId``. A single | ||
| attempt is enough: the caller turns a miss into a readable error instead of | ||
| silently falling back to the personal endpoint. | ||
| """ | ||
| host = hosts_for_token(access_token, domain)[0] | ||
| url = host + ENTERPRISE_ACCOUNTS_PATH | ||
| headers = _web_headers(host, access_token, uid, domain) | ||
| try: | ||
| with httpx.Client() as client: | ||
| status, payload = _get_json(client, url, headers) | ||
| except httpx.HTTPError: | ||
| return None | ||
| if status != 200: | ||
| return None | ||
| return enterprise_id_from_accounts(payload) | ||
|
|
||
|
|
||
| def _enterprise_error(reason: str) -> RuntimeError: | ||
| """An enterprise account must never be reported as having no credits.""" | ||
| return RuntimeError(f"企业配额查询失败: {reason}") | ||
|
|
||
|
|
||
| def fetch_enterprise_credits(access_token: str, uid: str = "", domain: str = "", | ||
| enterprise_id: str = "") -> dict: | ||
| """Fetch the tenant allowance instead of the personal package list. | ||
|
|
||
| The enterprise endpoint returns a single cycle allowance rather than package | ||
| segments, so it is projected as one segment to keep every downstream consumer | ||
| (expiry-aware routing, dashboards, the check-in ledger) unchanged. | ||
| """ | ||
| host = hosts_for_token(access_token, domain)[0] | ||
| url = host + ENTERPRISE_USAGE_PATH | ||
| headers = _web_headers(host, access_token, uid, domain, enterprise_id) | ||
| with httpx.Client() as client: | ||
| status, payload = _post_json(client, url, headers, {}) | ||
| if status != 200: | ||
| raise _enterprise_error(f"积分接口 HTTP {status}") | ||
| code = payload.get("code") | ||
| if code not in (0, None): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Valid success code is rejected When the enterprise usage endpoint returns a valid payload with Accept Prompt for AI agentsIn `app/credits.py` at line 457:
**Valid success code is rejected**
When the enterprise usage endpoint returns a valid payload with `code: 200`, `fetch_enterprise_credits()` rejects `code: 200` before parsing an otherwise valid allowance payload, so enterprise balance refresh fails even though the HTTP request succeeded.
Accept `200` as a success code when validating the enterprise usage payload. |
||
| raise _enterprise_error(str(payload.get("msg") or f"积分接口 code={code}")) | ||
| usage = parse_enterprise_usage(payload) | ||
| if usage is None: | ||
| raise _enterprise_error(_ENTERPRISE_PARSE_ERROR) | ||
| unlimited = usage["limit"] == UNLIMITED_ALLOWANCE | ||
| if unlimited: | ||
| # Do not fabricate a balance: an unlimited tenant has no numeric remainder. | ||
| return {"credits": None, "count": 0, "segments": [], "soonest_expiry": None, | ||
| "intl": is_international_host(host), "partial": False, "unlimited": True, | ||
|
sourcery-ai[bot] marked this conversation as resolved.
|
||
| "enterprise": True} | ||
| remaining = max(usage["limit"] - usage["used"], 0.0) | ||
| segment = { | ||
| "remaining": round(remaining, 2), | ||
| "total": round(max(usage["limit"], 0.0), 2), | ||
| "expires_at": usage["reset_at"], | ||
| "source": "企业配额", | ||
| "package_code": "", | ||
| } | ||
| segments = merge_segments(extract_segments([]) + [segment]) if remaining > 0 else [] | ||
| return {"credits": round(sum(s["remaining"] for s in segments), 2), | ||
| "count": 1 if not unlimited else 0, "segments": segments, | ||
| "soonest_expiry": soonest_expiry(segments), | ||
| "intl": is_international_host(host), "partial": False, | ||
| "unlimited": False, "enterprise": True} | ||
|
|
||
|
|
||
| def fetch_credits(access_token: str, uid: str = "", domain: str = "", | ||
| enterprise_id: str = "") -> dict: | ||
| """Fetch credit segments and mark page-limited results partial; HTTP 401 raises AuthExpiredError. | ||
|
|
||
| Enterprise tenants are dispatched first: their allowance never appears in the | ||
| personal package list, which answers with an empty Accounts structure for them. | ||
| """ | ||
| if enterprise_id: | ||
| return fetch_enterprise_credits(access_token, uid=uid, domain=domain, | ||
| enterprise_id=enterprise_id) | ||
| host = hosts_for_token(access_token, domain)[0] | ||
| url = host + RESOURCE_PATH | ||
| headers = _web_headers(host, access_token, uid, domain) | ||
|
|
@@ -549,11 +698,20 @@ def dedupe_by_identity(creds_snapshot: dict) -> dict: | |
|
|
||
|
|
||
| def aggregate_credits(creds_snapshot: dict) -> dict: | ||
| """Deduplicate accounts and aggregate regional balances, usage and earliest expiry.""" | ||
| """Deduplicate accounts and aggregate regional balances, usage and earliest expiry. | ||
|
|
||
| Unlimited enterprise tenants carry no numeric balance: they are counted | ||
| separately instead of entering the monetary conversion. | ||
| """ | ||
| groups = {k: {"remaining": 0.0, "used_by_quota": 0.0, "soonest_expiry": None} | ||
| for k in ("domestic", "international")} | ||
| for e in dedupe_by_identity(creds_snapshot).values(): | ||
| deduped = dedupe_by_identity(creds_snapshot) | ||
| unlimited = 0 | ||
| for e in deduped.values(): | ||
| c = e.get("credits") or {} | ||
| if c.get("unlimited") and c.get("enterprise"): | ||
| unlimited += 1 | ||
| continue | ||
| g = groups["international" if c.get("intl") else "domestic"] | ||
| for s in c.get("segments") or []: | ||
| rem = float(s.get("remaining") or 0) | ||
|
|
@@ -570,7 +728,8 @@ def aggregate_credits(creds_snapshot: dict) -> dict: | |
| "used_by_quota": round(sum(g["used_by_quota"] for g in groups.values()), 2), | ||
| "soonest_expiry": min(exps) if exps else None, | ||
| "partial": any(bool((e.get("credits") or {}).get("partial")) | ||
| for e in dedupe_by_identity(creds_snapshot).values()), | ||
| for e in deduped.values()), | ||
| "unlimited_accounts": unlimited, | ||
| "groups": groups} | ||
|
|
||
|
|
||
|
|
@@ -720,6 +879,10 @@ def update_credits(self, cred_id: str, result: dict): | |
| "fetched_at": time.time(), | ||
| "intl": bool(result.get("intl")), # Regional credits use independent pricing. | ||
| "partial": bool(result.get("partial")), # Expose incomplete pagination. | ||
| "enterprise": bool(result.get("enterprise")), | ||
| # Unlimited only exists on the enterprise dispatch; the double gate keeps a | ||
| # malformed personal result from ever storing it and bypassing zero-balance rules. | ||
| "unlimited": bool(result.get("unlimited")) and bool(result.get("enterprise")), | ||
| } | ||
| e["error"] = None | ||
| self._save() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium · Enterprise check-in omits tenant headers
When automatic check-in is enabled for an enterprise credential,
_checkin_request()builds its own headers withoutx-enterprise-idorx-tenant-id, and_sync_credits()invokes check-in before resolving an ID for historical credentials. The enterprise check-in request is rejected, so enabled automatic check-in does not complete.Resolve the enterprise ID before check-in and include it in the check-in request headers.
Prompt for AI agents