Skip to content

Dispatch enterprise accounts to the enterprise allowance endpoint - #60

Merged
maiphucgiang merged 4 commits into
maiphucgiang:mainfrom
moshouhot:feat/enterprise-account-credits
Oct 14, 2026
Merged

maiphucgiang merged 4 commits into
maiphucgiang:mainfrom
moshouhot:feat/enterprise-account-credits

Conversation

@moshouhot

@moshouhot moshouhot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #59

Enterprise credentials never report a balance: the personal resource endpoint answers with an empty Accounts structure for them, so parsing fails and every consumer (WebUI, credit-aware routing, check-in ledger) sees a credential worth nothing.

What changed

Tenant detection (app/client_profiles.py)

  • enterprise_id_of() reads both enterpriseId and enterprise_id. The header builder and the billing dispatch now share it, so the two views cannot disagree.
  • is_enterprise_account() also honours type ∈ {ultimate, exclusive}, because historical backups can carry the tier without the id.

Billing dispatch (app/credits.py, converter.py)

  • The enterprise branch runs before the personal path. The enterprise response is a single cycle allowance (credit = used, limitNum = total), projected as one segment so expiry-aware routing and dashboards stay unchanged.
  • limitNum == -1 is reported as unlimited with no numeric balance rather than a fabricated zero.
  • A missing tenant id is resolved once via /console/accounts. A failed lookup raises a readable error instead of falling back to the personal endpoint, which would misreport an enterprise account as having no credits.

Identity robustness (converter.py, app/auth_oauth.py)

  • An empty account object no longer shadows a populated accounts[0].
  • An account without a uid falls back to a real email while rejecting placeholders such as unknown or 手动添加 that would merge unrelated accounts.
  • converter.py::_sync_credits() now passes the tenant id to the billing layer.

Compatibility

account_key is unchanged — inputs and hash untouched, so existing routing rules and the credit ledger keep working with no migration. Personal accounts still use the personal endpoint.

Verification

Live enterprise credential: 1995.93 (= 2000 − 4.07), previously null.

账号                       类型        识别    积分(改动后)   路径
fangwanying              ultimate   企业    1995.93      企业
moshouhot@gmail.com      personal   个人    100.0        个人
ilangel                  personal   个人    3476.75      个人
昊                        personal   个人    3438.31      个人

Against the 11-credential fixture every account_key stays byte-identical, all personal accounts still resolve through RESOURCE_PATH, and the pre-existing suite shows no new failures (25 Windows-environment failures before and after). 24 new tests in tests/test_enterprise_credits.py cover the detection matrix, all payload wrappings, the unlimited marker, the resolution fallback and the personal-path regression guard.

Prior art

changexbc/workbuddy-switch → crates/wb-switch-core/src/modules/credits.rs::get_credit_expiry (their issue #167 documents the same dispatch).

Summary by Sourcery

Dispatch enterprise accounts through tenant-aware allowance billing while preserving personal-account behavior and existing account identities.

New Features:

  • Route enterprise credentials to the enterprise allowance service and expose their remaining or unlimited allowance across routing, ledgers, dashboards, and credential views.

Bug Fixes:

  • Prevent enterprise accounts from being misreported as having zero credits and avoid anonymous audit-dimension buckets for unidentified models, profiles, and credentials.
  • Improve account identity recovery when exports contain empty account objects or missing and placeholder UIDs.

Enhancements:

  • Unify enterprise tenant detection across request headers and billing dispatch, including legacy account formats and tenant-ID resolution.

Tests:

  • Add enterprise billing, tenant detection, payload parsing, unlimited allowance, identity fallback, routing, ledger, dashboard, and personal-endpoint regression coverage.

Enterprise credentials never report a balance: the personal resource endpoint
answers with an empty Accounts structure for them, so parsing fails with
"积分查询失败: 积分接口返回缺少 Accounts 字段" and every consumer — the WebUI,
credit-aware routing and the check-in ledger — sees a credential worth nothing.

Tenant detection:
* enterprise_id_of() reads both naming conventions; the header builder and the
  billing dispatch now share it so the two views cannot disagree.
* is_enterprise_account() also honours type in {ultimate, exclusive}, because
  historical backups can carry the tier without the id.
* An empty account object no longer shadows a populated accounts[0], and an
  account without a uid falls back to a real email while rejecting placeholders
  such as "unknown" or "手动添加" that would merge unrelated accounts.

Billing dispatch:
* The enterprise branch runs before the personal path. The enterprise response
  is a single cycle allowance (used = credit, total = limitNum), projected as one
  segment so expiry-aware routing and dashboards stay unchanged.
* limitNum == -1 is reported as unlimited with no numeric balance rather than a
  fabricated zero.
* A missing tenant id is resolved once via /console/accounts; a failed lookup
  raises a readable error instead of falling back to the personal endpoint, which
  would misreport an enterprise account as having no credits.

account_key is unchanged: the inputs and the hash are untouched, so existing
routing rules and the credit ledger keep working with no migration.

Verified on a live enterprise credential (1995.93 = 2000 - 4.07) and against
the 11-credential fixture, where every personal account still uses the personal
endpoint. 24 new tests; the pre-existing suite shows no new failures.
@sourcery-ai

sourcery-ai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Enterprise credentials are now detected consistently, optionally resolved to a tenant, and dispatched to the enterprise allowance endpoint while preserving the existing credit-segment consumers, account keys, and personal-account behavior.

Sequence diagram for enterprise credit dispatch

sequenceDiagram
    participant Sync as _sync_credits
    participant Profile as client_profiles
    participant Billing as credits
    participant Accounts as ConsoleAccounts
    participant Enterprise as EnterpriseBilling
    participant Personal as PersonalBilling

    Sync->>Profile: _credential_account()
    Sync->>Profile: enterprise_id_of(account)
    alt Enterprise type without tenant id
        Sync->>Profile: is_enterprise_account(account)
        Sync->>Billing: resolve_enterprise_id(token, uid, domain)
        Billing->>Accounts: GET /console/accounts
        Accounts-->>Billing: enterprise_id
        Billing-->>Sync: enterprise_id
    else Tenant id already present
        Sync->>Profile: is_enterprise_account(account)
    end
    Sync->>Billing: fetch_credits(token, uid, domain, enterprise_id)
    alt enterprise_id is present
        Billing->>Billing: fetch_enterprise_credits(...)
        Billing->>Enterprise: POST /v2/billing/meter/get-enterprise-user-usage
        Enterprise-->>Billing: credit, limitNum, cycleResetTime
        Billing-->>Sync: one credit segment or unlimited result
    else Personal account
        Billing->>Personal: POST /v2/billing/meter/get-user-resource
        Personal-->>Billing: personal package segments
        Billing-->>Sync: personal credit segments
    end
Loading

File-Level Changes

Change Details Files
Adds shared enterprise-account detection and keeps billing headers aligned with dispatch.
  • Recognize camelCase and snake_case tenant IDs.
  • Treat ultimate/exclusive account types as enterprise when the ID is absent.
  • Use the resolved tenant ID for both enterprise headers.
app/client_profiles.py
Routes enterprise credentials to a dedicated allowance API and converts its response into the existing credit-segment model.
  • Call the enterprise usage endpoint before the personal resource endpoint.
  • Parse nested and alternate payload shapes into used, limit, and reset data.
  • Represent the allowance as one segment, handle unlimited allowances without a numeric balance, and fail loudly on lookup or parsing errors.
  • Resolve missing tenant IDs through /console/accounts.
app/credits.py
Makes credential identity extraction resilient while preserving existing account keys and wires enterprise context into credit synchronization.
  • Prefer a populated account object over an empty one.
  • Fall back to a validated email when uid is missing.
  • Pass enterprise status and tenant ID through credit synchronization without changing account-key hashing.
app/auth_oauth.py
converter.py
Adds regression coverage for enterprise detection, parsing, dispatch, unlimited allowances, tenant resolution, and personal-account behavior.
  • Cover alternate identity fields, payload wrappings, and error cases.
  • Verify enterprise requests carry tenant headers and personal accounts remain on the personal endpoint.
tests/test_enterprise_credits.py

Assessment against linked issues

Issue Objective Addressed Explanation
#59 Identify enterprise accounts, including historical accounts identified by type, and dispatch them to the enterprise usage endpoint before the personal billing path. ❌ Accounts with a tenant ID are dispatched correctly, and type-only accounts attempt tenant resolution. However, if that lookup fails, converter.py passes an empty enterprise_id to fetch_credits(), which then falls through to the personal endpoint. This does not fully handle the type-only enterprise fallback described in the issue.
#59 Parse enterprise allowance responses and report remaining credits as limitNum minus credit, honoring the reset time and the unlimited marker. ✅
#59 Pass enterprise IDs through to billing requests while preserving personal-account behavior and existing account-key compatibility. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 10 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="converter.py" line_range="1572" />
<code_context>
+        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 ""
+        balance = credits_mod.fetch_credits(
+            token, uid=uid, domain=domain,
</code_context>
<issue_to_address>
**Enterprise lookups use personal billing**

When an enterprise account has no stored tenant ID and `/console/accounts` fails or returns no ID, `resolve_enterprise_id()` returns `None`, and `or ""` passes an empty ID to `fetch_credits()`. The enterprise branch is skipped, so the personal endpoint returns a wrong balance or error instead of the readable tenant-lookup error.

Do not convert a failed tenant lookup to an empty string; propagate the lookup failure before calling `fetch_credits()`.

Also at `app/credits.py:491`, `converter.py:1573-1577`.
</issue_to_address>

### Comment 2
<location path="app/credits.py" line_range="466" />
<code_context>
+    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,
+                "enterprise": True}
+    remaining = max(usage["limit"] - usage["used"], 0.0)
</code_context>
<issue_to_address>
**Unlimited accounts appear exhausted**

When an enterprise usage response has `limitNum == -1`, `fetch_enterprise_credits()` returns `credits=None` and `unlimited=True`, but `CreditLedger.update_credits()` drops the unlimited marker. Ledger consumers cannot distinguish an unlimited allowance from a missing balance, and `CredentialPool._has_credit()` treats it as zero, excluding the account from paid-model routing.

Persist the unlimited marker in the ledger and make credit-aware routing treat an unlimited account as eligible.

Also at `app/credits.py:467`.
</issue_to_address>

### Comment 3
<location path="converter.py" line_range="253-255" />
<code_context>
     account = _credential_account(data)
-    return account_key(profile_for_auth(data.get("auth") or {}), account.get("uid"), account.get("enterpriseId"))
+    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,
+                       enterprise_id_of(account))

</code_context>
<issue_to_address>
**Email identities fail credit sync**

When a credential has no UID but has a usable email, or has a numeric UID, `_credential_identity()` hashes the email or an empty fallback, while `_sync_credits()` reconstructs the identity from the UID header. The keys differ, so sync rejects the credential before updating its balance; binding the new key can also leave prior state behind.

Derive the sync identity from the same credential identity inputs used by `_credential_identity()`.
</issue_to_address>

### Comment 4
<location path="app/credits.py" line_range="457" />
<code_context>
+    if status != 200:
+        raise _enterprise_error(f"积分接口 HTTP {status}")
+    code = payload.get("code")
+    if code not in (0, None):
+        raise _enterprise_error(str(payload.get("msg") or f"积分接口 code={code}"))
+    usage = parse_enterprise_usage(payload)
</code_context>
<issue_to_address>
**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.
</issue_to_address>

### Comment 5
<location path="app/credits.py" line_range="91" />
<code_context>
         "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,
</code_context>
<issue_to_address>
**Enterprise check-in omits tenant headers**

When automatic check-in is enabled for an enterprise credential, `_checkin_request()` builds its own headers without `x-enterprise-id` or `x-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.
</issue_to_address>

### Comment 6
<location path="app/credits.py" line_range="412" />
<code_context>
+    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])
+    return None
+
</code_context>
<issue_to_address>
**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.
</issue_to_address>

### Comment 7
<location path="converter.py" line_range="254" />
<code_context>
-    return account_key(profile_for_auth(data.get("auth") or {}), account.get("uid"), account.get("enterpriseId"))
+    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,
+                       enterprise_id_of(account))
</code_context>
<issue_to_address>
**UID-less credentials cannot be imported**

When a credential has a valid email and token but no `account.uid`, `validate_cred_data()` rejects the credential before import or seeding reaches `_credential_identity()` and its email fallback. The credential cannot enter the normal flow.

Allow valid UID-less credentials through validation so the email fallback can identify them.

Also at `converter.py:255`.
</issue_to_address>

### Comment 8
<location path="converter.py" line_range="252-253" />
<code_context>
+    """Hash the stable account identity, falling back to a real email without a uid."""
     account = _credential_account(data)
-    return account_key(profile_for_auth(data.get("auth") or {}), account.get("uid"), account.get("enterpriseId"))
+    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,
</code_context>
<issue_to_address>
**Numeric UIDs collapse account identities**

When a credential has a numeric `account.uid` and no usable email, `_credential_identity()` treats a numeric `uid` as missing and replaces it with an email or an empty string. Since `validate_cred_data()` accepts numeric UIDs by converting them to strings, credentials with different numeric UIDs and no usable email can receive the same account key, merging their identity-based routing and account state.

Normalize a present UID to a string for identity hashing, and use the email fallback only when the UID is absent or blank.
</issue_to_address>

### Comment 9
<location path="converter.py" line_range="255" />
<code_context>
+    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,
+                       enterprise_id_of(account))

</code_context>
<issue_to_address>
**Duplicate accounts enter the pool**

When multiple existing credential files have no uid but share the same valid email and profile, `_credential_identity` now gives uid-less credentials with the same email a shared identity, but `CredentialPool.reload` checks and records duplicate identities only when `uid` is truthy. Both files are admitted as separate credentials, so one account can be routed and synchronized multiple times.

Apply duplicate detection using the canonical identity whenever it is available, including for uid-less credentials.
</issue_to_address>

### Comment 10
<location path="converter.py" line_range="253-255" />
<code_context>
     account = _credential_account(data)
-    return account_key(profile_for_auth(data.get("auth") or {}), account.get("uid"), account.get("enterpriseId"))
+    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,
+                       enterprise_id_of(account))

</code_context>
<issue_to_address>
**Existing account state becomes orphaned**

When a credential already has persisted state and gains a fallback email identity or stores its tenant only as `enterprise_id`, `_credential_identity()` now produces a different `account_key()` than the prior calculation. Existing ledger state and account-scoped settings remain under the old key, and binding the new key can reset saved credit and check-in state.

Preserve the prior identity key for existing credentials or migrate their persisted state and settings to the new key.

Also at `converter.py:256`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 10 findings to address first, and if the tenant lookup or allowance parsing is wrong, the resulting balance is written to the credit ledger and may affect credit-aware routing until refreshed. Reverting stops the new dispatch, but does not itself undo the stored reading; a subsequent successful refresh can recompute it.

Blocking findings: converter.py:1572, app/credits.py:466, converter.py:255, app/credits.py:457, app/credits.py:91, and 5 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread converter.py
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 ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Enterprise lookups use personal billing

When an enterprise account has no stored tenant ID and /console/accounts fails or returns no ID, resolve_enterprise_id() returns None, and or "" passes an empty ID to fetch_credits(). The enterprise branch is skipped, so the personal endpoint returns a wrong balance or error instead of the readable tenant-lookup error.

Do not convert a failed tenant lookup to an empty string; propagate the lookup failure before calling fetch_credits().

Also at app/credits.py:491, converter.py:1573-1577.

Prompt for AI agents
In `converter.py` at line 1572:

**Enterprise lookups use personal billing**

When an enterprise account has no stored tenant ID and `/console/accounts` fails or returns no ID, `resolve_enterprise_id()` returns `None`, and `or ""` passes an empty ID to `fetch_credits()`. The enterprise branch is skipped, so the personal endpoint returns a wrong balance or error instead of the readable tenant-lookup error.

Do not convert a failed tenant lookup to an empty string; propagate the lookup failure before calling `fetch_credits()`.

Also at `app/credits.py:491`, `converter.py:1573-1577`.

Comment thread app/credits.py
Comment thread converter.py
Comment on lines +253 to +255
if not (isinstance(uid, str) and uid.strip()):
uid = _identity_email(account) or ""
return account_key(profile_for_auth(data.get("auth") or {}), uid,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Email identities fail credit sync

When a credential has no UID but has a usable email, or has a numeric UID, _credential_identity() hashes the email or an empty fallback, while _sync_credits() reconstructs the identity from the UID header. The keys differ, so sync rejects the credential before updating its balance; binding the new key can also leave prior state behind.

Derive the sync identity from the same credential identity inputs used by _credential_identity().

Prompt for AI agents
In `converter.py` at lines 253-255:

**Email identities fail credit sync**

When a credential has no UID but has a usable email, or has a numeric UID, `_credential_identity()` hashes the email or an empty fallback, while `_sync_credits()` reconstructs the identity from the UID header. The keys differ, so sync rejects the credential before updating its balance; binding the new key can also leave prior state behind.

Derive the sync identity from the same credential identity inputs used by `_credential_identity()`.

Comment thread app/credits.py
if status != 200:
raise _enterprise_error(f"积分接口 HTTP {status}")
code = payload.get("code")
if code not in (0, None):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 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.

Prompt for AI agents
In `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.

Comment thread app/credits.py
"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 ""),

Copy link
Copy Markdown

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 without x-enterprise-id or x-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
In `app/credits.py` at line 91:

**Enterprise check-in omits tenant headers**

When automatic check-in is enabled for an enterprise credential, `_checkin_request()` builds its own headers without `x-enterprise-id` or `x-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.

Comment thread app/credits.py
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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · 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.

Prompt for AI agents
In `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.

Comment thread converter.py
return account_key(profile_for_auth(data.get("auth") or {}), account.get("uid"), account.get("enterpriseId"))
uid = account.get("uid")
if not (isinstance(uid, str) and uid.strip()):
uid = _identity_email(account) or ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · UID-less credentials cannot be imported

When a credential has a valid email and token but no account.uid, validate_cred_data() rejects the credential before import or seeding reaches _credential_identity() and its email fallback. The credential cannot enter the normal flow.

Allow valid UID-less credentials through validation so the email fallback can identify them.

Also at converter.py:255.

Prompt for AI agents
In `converter.py` at line 254:

**UID-less credentials cannot be imported**

When a credential has a valid email and token but no `account.uid`, `validate_cred_data()` rejects the credential before import or seeding reaches `_credential_identity()` and its email fallback. The credential cannot enter the normal flow.

Allow valid UID-less credentials through validation so the email fallback can identify them.

Also at `converter.py:255`.

Comment thread converter.py
Comment on lines +252 to +253
uid = account.get("uid")
if not (isinstance(uid, str) and uid.strip()):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Numeric UIDs collapse account identities

When a credential has a numeric account.uid and no usable email, _credential_identity() treats a numeric uid as missing and replaces it with an email or an empty string. Since validate_cred_data() accepts numeric UIDs by converting them to strings, credentials with different numeric UIDs and no usable email can receive the same account key, merging their identity-based routing and account state.

Normalize a present UID to a string for identity hashing, and use the email fallback only when the UID is absent or blank.

Prompt for AI agents
In `converter.py` at lines 252-253:

**Numeric UIDs collapse account identities**

When a credential has a numeric `account.uid` and no usable email, `_credential_identity()` treats a numeric `uid` as missing and replaces it with an email or an empty string. Since `validate_cred_data()` accepts numeric UIDs by converting them to strings, credentials with different numeric UIDs and no usable email can receive the same account key, merging their identity-based routing and account state.

Normalize a present UID to a string for identity hashing, and use the email fallback only when the UID is absent or blank.

Comment thread converter.py
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Duplicate accounts enter the pool

When multiple existing credential files have no uid but share the same valid email and profile, _credential_identity now gives uid-less credentials with the same email a shared identity, but CredentialPool.reload checks and records duplicate identities only when uid is truthy. Both files are admitted as separate credentials, so one account can be routed and synchronized multiple times.

Apply duplicate detection using the canonical identity whenever it is available, including for uid-less credentials.

Prompt for AI agents
In `converter.py` at line 255:

**Duplicate accounts enter the pool**

When multiple existing credential files have no uid but share the same valid email and profile, `_credential_identity` now gives uid-less credentials with the same email a shared identity, but `CredentialPool.reload` checks and records duplicate identities only when `uid` is truthy. Both files are admitted as separate credentials, so one account can be routed and synchronized multiple times.

Apply duplicate detection using the canonical identity whenever it is available, including for uid-less credentials.

Comment thread converter.py
Comment on lines +253 to +255
if not (isinstance(uid, str) and uid.strip()):
uid = _identity_email(account) or ""
return account_key(profile_for_auth(data.get("auth") or {}), uid,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Existing account state becomes orphaned

When a credential already has persisted state and gains a fallback email identity or stores its tenant only as enterprise_id, _credential_identity() now produces a different account_key() than the prior calculation. Existing ledger state and account-scoped settings remain under the old key, and binding the new key can reset saved credit and check-in state.

Preserve the prior identity key for existing credentials or migrate their persisted state and settings to the new key.

Also at converter.py:256.

Prompt for AI agents
In `converter.py` at lines 253-255:

**Existing account state becomes orphaned**

When a credential already has persisted state and gains a fallback email identity or stores its tenant only as `enterprise_id`, `_credential_identity()` now produces a different `account_key()` than the prior calculation. Existing ledger state and account-scoped settings remain under the old key, and binding the new key can reset saved credit and check-in state.

Preserve the prior identity key for existing credentials or migrate their persisted state and settings to the new key.

Also at `converter.py:256`.

An enterprise tenant with limitNum=-1 holds no countable remainder, yet
the ledger dropped the unlimited/enterprise flags, so the WebUI rendered
an indistinguishable dash and credit-aware routing treated the account
as zero balance.

Store both flags with a double gate (unlimited requires enterprise), let
routing short-circuit on them instead of reading a fabricated number,
exclude such accounts from the monetary conversion while reporting their
count, and render the allowance as 无限 in the dashboard and credentials
pages. Usage statistics still count these accounts unchanged.
Legacy versions wrote stats rows with an empty dimension_key when the
model was unknown, and the dashboard merged them into the ranking where
the WebUI rendered the entry as 未识别模型. Skip empty keys both when
aggregating new records (the global row still counts the request) and
when reading back historical rows.
@maiphucgiang maiphucgiang self-assigned this Oct 14, 2026
@maiphucgiang
maiphucgiang merged commit 764f2ac into maiphucgiang:main Oct 14, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enterprise accounts: credits always fail — billing calls get-user-resource instead of get-enterprise-user-usage

2 participants