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
5 changes: 5 additions & 0 deletions app/auth_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import httpx

from .client_profiles import canonical_uid
from .site_routing import profile_for_auth

PLUGIN_PREFIX = "/v2/plugin"
Expand Down Expand Up @@ -93,6 +94,10 @@ def validate_cred_data(data) -> tuple[str | None, str | None]:
candidate = arr[0] if isinstance(arr, list) and arr and isinstance(arr[0], dict) else None
acct = candidate if candidate else (acct if isinstance(acct, dict) else None)
uid = str((acct or {}).get("uid") or "")
if not uid:
# A uid-less credential with a real email is still identifiable; the same
# canonical fallback is used by the identity hash so both agree.
uid = canonical_uid(acct)
if not uid:
return None, "缺少 account.uid"
auth = data.get("auth") if isinstance(data.get("auth"), dict) else {}
Expand Down
56 changes: 56 additions & 0 deletions app/client_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,62 @@ def is_enterprise_account(account) -> bool:
return isinstance(account_type, str) and account_type.strip().lower() in ("ultimate", "exclusive")


def normalize_uid(value) -> str:
"""Coerce a uid to its canonical string form, treating blanks as absent.

A numeric uid is a real identity: it must hash the same as its string form and
must never be mistaken for a missing uid, which would let two different
accounts collapse onto one key.
"""
if isinstance(value, bool) or value is None:
return ""
if isinstance(value, int):
return str(value)
if isinstance(value, float):
# Reject non-finite and fractional values: they are not account identifiers.
return str(int(value)) if value.is_integer() else ""
if isinstance(value, str):
return value.strip()
return ""


# Display placeholders that must never be used as an identity.
_PLACEHOLDER_IDENTITIES = frozenset({"unknown", "手动添加", "未命名账号"})


def identity_email(account) -> str | None:
"""Return a real email usable as an identity when no uid exists.

Placeholders, and values that merely repeat another field, are rejected: they
would merge unrelated accounts onto a single key.
"""
if not isinstance(account, dict):
return None
email = account.get("email")
if not isinstance(email, str):
return None
text = email.strip()
if not text or "@" not in text or text.lower() in _PLACEHOLDER_IDENTITIES:
return None
if text == normalize_uid(account.get("uid")):
return None
nickname = account.get("nickname")
if isinstance(nickname, str) and text == nickname.strip():
return None
return text.lower()


def canonical_uid(account) -> str:
"""The identity uid for an account block, falling back to a real email.

Every caller that derives an identity (import validation, hashing, sync
reconciliation, duplicate detection) must use this one function so the keys
they compare are always the same key.
"""
return normalize_uid((account or {}).get("uid") if isinstance(account, dict) else None) \
or (identity_email(account) or "")


def catalog_cache_key(profile: str, identity: str | None = None) -> str:
revision = CLI_VERSION if profile_product(profile) == "cli" else f"{WORKBUDDY_VERSION}:{WORKBUDDY_CLI_VERSION}"
key = f"{profile}:{revision}"
Expand Down
14 changes: 11 additions & 3 deletions app/credits.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,12 +449,20 @@ def fetch_enterprise_credits(access_token: str, uid: str = "", domain: str = "",
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, {})
try:
with httpx.Client() as client:
status, payload = _post_json(client, url, headers, {})
except AuthExpiredError:
raise
except httpx.HTTPError as error:
# A transport failure must not surface as a raw network exception.
raise _enterprise_error(f"积分接口网络失败: {type(error).__name__}") from None
if status != 200:
raise _enterprise_error(f"积分接口 HTTP {status}")
code = payload.get("code")
if code not in (0, None):
# Accept both success codes: the gateway uses 0 for the enterprise allowance and
# 200 for some other billing routes, and these helpers must not disagree.
if code not in (0, 200, None):
raise _enterprise_error(str(payload.get("msg") or f"积分接口 code={code}"))
usage = parse_enterprise_usage(payload)
if usage is None:
Expand Down
88 changes: 47 additions & 41 deletions converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False,
profile_for_auth, profile_for_headers, profile_region, profile_product,
profile_site, chat_url_for_headers, refresh_url_for_auth)
from app.client_profiles import (CLI_VERSION, CLI_USER_AGENT, credential_headers, catalog_cache_key,
account_key, enterprise_id_of, is_enterprise_account)
account_key, canonical_uid, enterprise_id_of, identity_email,
is_enterprise_account, normalize_uid)
try:
from app import credits as credits_mod
except ImportError: # Disable credit maintenance when its module is unavailable.
Expand Down Expand Up @@ -199,14 +200,10 @@ def find_auth_files() -> list[Path]:


def _cred_uid(path) -> Optional[str]:
"""Read the account UID for deduplication, or return None when unavailable."""
"""Read the canonical account identity for deduplication, or return None."""
try:
d = json.loads(Path(path).read_text(encoding="utf-8"))
acct = d.get("account")
if not isinstance(acct, dict):
arr = d.get("accounts")
acct = arr[0] if isinstance(arr, list) and arr and isinstance(arr[0], dict) else {}
return acct.get("uid")
return _credential_account(d).get("uid")
except Exception:
return None

Expand All @@ -227,35 +224,37 @@ def _credential_account(data: dict) -> dict:
return account if isinstance(account, dict) else {}


def _identity_email(account: dict) -> str | None:
"""Return a real email usable as an identity when no uid exists.
def _credential_identity(data: dict) -> str:
"""Hash the canonical account identity, falling back to a real email without a uid.

Display placeholders and values that merely repeat another field must never be
used: they would merge unrelated accounts onto a single key.
The uid is normalised through the shared helper so a numeric uid hashes exactly
like its string form and is never mistaken for a missing one.
"""
email = account.get("email")
if not isinstance(email, str):
return None
text = email.strip()
if "@" not in text or text.lower() == "unknown" or text in ("手动添加", "未命名账号"):
return None
if text == str(account.get("nickname") or "").strip():
return None
if text == str(account.get("uid") or "").strip():
return None
return text.lower()


def _credential_identity(data: dict) -> str:
"""Hash the stable account identity, falling back to a real email without a uid."""
account = _credential_account(data)
uid = account.get("uid")
if not (isinstance(uid, str) and uid.strip()):
uid = _identity_email(account) or ""
return account_key(profile_for_auth(data.get("auth") or {}), uid,
return account_key(profile_for_auth(data.get("auth") or {}), canonical_uid(account),
enterprise_id_of(account))


def _enterprise_dispatch_id(token: str, account: dict, *, uid: str = "", domain: str = "") -> str:
"""Return the tenant id that selects the enterprise billing endpoint.

A stored tenant id is used directly. A credential that is enterprise by type
alone (historical backups) needs one lookup; if that lookup cannot confirm the
tenant the sync must fail with a readable error instead of quietly querying the
personal endpoint, which answers with an empty package list for enterprise
accounts and would surface as "no credits available".
"""
enterprise_id = enterprise_id_of(account)
if enterprise_id is not None:
return enterprise_id
if not is_enterprise_account(account):
return ""
resolved = credits_mod.resolve_enterprise_id(token, uid=uid, domain=domain)
if not resolved:
raise RuntimeError("企业账号缺少企业 ID,无法查询企业配额(已跳过个人版接口以避免误报余额)")
return resolved


def _cred_identity(path) -> str | None:
try:
return _credential_identity(json.loads(Path(path).read_text(encoding="utf-8")))
Expand Down Expand Up @@ -395,8 +394,12 @@ def summary(self) -> dict:
auth = s.get("auth") or {}
acct = _credential_account(s)
profile = profile_for_auth(auth)
# ``uid`` here is the canonical identity value (a real email when no uid
# exists), identical to the input of ``account_key`` below, so callers that
# gate on it treat a uid-less account exactly like the pool does.
identity_uid = canonical_uid(acct)
return {
"uid": str(acct.get("uid") or "") or None,
"uid": identity_uid or None,
"account_key": _credential_identity(s),
"site": profile_site(profile),
"profile": profile, "region": profile_region(profile), "product": profile_product(profile),
Expand Down Expand Up @@ -1503,18 +1506,23 @@ def _sync_credits(pool, ledger, entry, *, checkin, failed, expected_identity=Non
try:
with cm._lock:
try:
if expected_identity is not None and cm.summary().get("account_key") != expected_identity:
# Read the identity from the credential itself, never rebuilt from headers: a
# uid-less account identified by email would otherwise hash to a different key
# here than the one the pool recorded, and its sync would be rejected forever.
summary = cm.summary()
if expected_identity is not None and summary.get("account_key") != expected_identity:
failed.add(cid)
return None
headers = cm.get_headers()
finally:
generation = cm._generation
profile = profile_for_headers(headers)
identity = account_key(profile, headers.get("X-User-Id"), headers.get("X-Enterprise-Id"))
identity = summary.get("account_key")
if entry.get("account_key") and entry["account_key"] != identity:
failed.add(cid)
return None # A path now owned by another account must be rescheduled with its own preferences.
site = site_for_headers(headers)
# The upstream uid header is used verbatim; the local identity above is the key.
token, uid, domain = _bearer_token(headers), headers.get("X-User-Id", ""), headers.get("X-Domain", "")
day = daily_chat.today()
if checkin and model_policy.credential_auto_checkin(CONFIG, entry) and not ledger.checkin_done(cid, day):
Expand Down Expand Up @@ -1570,16 +1578,14 @@ def can_chat():
_sync_error(pool, ledger, entry, generation, "dailychat", error)
if not model_policy.credential_enabled(CONFIG, entry):
return None
# An enterprise type without a stored tenant id still bills through the
# enterprise endpoint, so resolve the id once before dispatching.
account = _credential_account(cm._session())
enterprise_id = enterprise_id_of(account)
if enterprise_id is None and is_enterprise_account(account):
enterprise_id = credits_mod.resolve_enterprise_id(token, uid=uid, domain=domain) or ""
# An enterprise account always bills through the enterprise endpoint, so an
# unresolvable tenant must fail loudly rather than silently fall back to the
# personal endpoint, which would report it as having no credits at all.
enterprise_id = _enterprise_dispatch_id(token, _credential_account(cm._session()),
uid=uid, domain=domain)
balance = credits_mod.fetch_credits(
token, uid=uid, domain=domain,
# Enterprise tenants bill through a different endpoint; the dispatch key
# travels from the credential so both views agree.
# The dispatch key travels from the credential so both views agree.
enterprise_id=enterprise_id or "")
if bool(balance.get("intl")) != (site == INTERNATIONAL):
raise ValueError("积分响应与凭据站点不一致")
Expand Down
Loading
Loading