[Bug]: Usage → Limits shows one Codex tile per environment for a hub-routed Codex instance, though rateLimits/read already returns the matching ChatGPT account id#23054

opened19d agobyWWhiteWarrior625

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server, Usage → Limits (client)

Summary

A Codex instance that routes turns through a custom model_provider (a CLIProxyAPI hub) gets its own Usage → Limits tile on every connected environment, even though T3 can prove that the tile is the same ChatGPT account as the hub source's tile. With one Codex subscription and three connected machines, the Codex pool shows four identical columns: one "Codex Hub" per machine, plus the hub source's account.

This is not the same as #10701 or #11515. Those keep email-less instances separate because "T3 cannot prove those are the same account." In this case the proof is already in the probe response.

Environment

  • T3 Code 0.1.2-nightly.20261021.3486 (Linux desktop host + two Windows environments)
  • codex-cli 0.163.0
  • One ChatGPT Pro login, used both as the native ~/.codex/auth.json and as the single Codex credential in the CLIProxyAPI hub
  • Usage limit source: kind: cliproxy, pointed at the same hub

Steps to reproduce

  1. On each environment, configure the codex instance with launch args equivalent to:
    text
    1-c model_provider="cliproxy"
    2-c model_providers.cliproxy.base_url="http://HUB:8317/v1"
    3-c model_providers.cliproxy.env_key="CLIPROXY_API_KEY"
    4-c model_providers.cliproxy.wire_api="responses"
    Keep the normal ChatGPT login in ~/.codex/auth.json.
  2. Add a CLIProxyAPI usage limit source whose only Codex credential is that same ChatGPT account.
  3. Connect the environments and open Usage → Limits.

Expected behavior

There is one Codex column for the account.

Actual behavior

There is one column per environment's Codex instance plus one for the hub account, all with the same plan, the same percentage and the same reset time.

Cause

For this instance the Codex app-server returns:

json
1// account/read
2{"account": null, "requiresOpenaiAuth": false, "workspaceRouting": null}
3// account/rateLimits/read
4{"rateLimits": {"planType": "pro", "primary": {"usedPercent": 100, "windowDurationMins": 10080, ...}},
5 "accountId": "7e0865dd-…", ...}

account/rateLimits/read still reads limits from the native ChatGPT login. It also returns accountId, which accountProbeStatus copies into auth.workspaceId. The hub account has the same value as workspaceId (its id_token.chatgpt_account_id).

However, the client merge key (W(driver, email, usageLimits) in the Limits pool builder) returns null without an email or a credentialFingerprint. Because of that, workspaceId is never consulted, and each instance falls back to ${environmentId}:${instanceId}.

Suggested fix

When there is no email and no credential fingerprint, but a Codex workspaceId (ChatGPT account id) is present, build the account key from it (for example codex:workspace:<id>). Apply the same rule to hub accounts so that both sides meet. The same identity gap is described in #13992 / #10835. This case is a concrete instance where the stable id is already in the snapshot.

A related point: for a custom-provider instance, the limits come from the native login, not from the accounts the turns actually use. That only matches when the native login is also the hub's only account (see #11515).

Workaround

Set the instance's shadowHomePath to an empty overlay with no auth.json. Its limit read then fails and the tile disappears. In exchange, every environment shows the #11873 notice "Codex could not read usage (JSON-RPC -32600)".

🤖 Generated with Claude Code

Synced from GitHub

A note on the workaround: it doesn't work on Windows unless symlinks are allowed. materializeCodexShadowHome calls fs.symlink for every entry in the shared home and has no fallback. On a Windows host with Developer Mode off and T3 running unelevated, it fails with EPERM (WinError 1314), and the instance doesn't build. On a Windows host where symlinks are allowed, the same setting works.

🤖 Generated with Claude Code

Synced from GitHub

There's a better workaround that also avoids the #11873 notice. In the shadow home, write a real auth.json with {"auth_mode":"apikey","OPENAI_API_KEY":"<hub client key>"}, and set model_providers.cliproxy.requires_openai_auth=true in the launch args. account/read then returns {"type":"apiKey"}, the instance's usage becomes unsupported, and Limits shows only the hub account. Turns still go through the hub.

🤖 Generated with Claude Code

Same bug on 0.1.3-nightly.20261026.3726 (macOS arm64 desktop + Linux host), codex-cli 0.164.0 (macOS) / 0.162.1 (Linux).

Setup: two environments, each with a Codex instance routed through a CLIProxyAPI hub via a custom model_provider (requires_openai_auth=false), with the normal ChatGPT login kept in ~/.codex/auth.json. The hub is also added as a usage limit source, and its Codex account is the same ChatGPT login.

Result: with All environments selected, Usage → Limits shows three Codex bars with identical numbers: one per environment plus the hub source.

Captured on macOS with codex-cli 0.164.0 and the instance's exact launch args:

  • account/read → {"account": null, "requiresOpenaiAuth": false, "workspaceRouting": null}
  • account/rateLimits/read → real limits plus "accountId": "495f8ab6-…", which is the same chatgptAccountId that account/read returns when the default provider is used.

Checked against the v0.1.3-nightly.20261026.3726 tag and current main: accountKey() in packages/shared/src/usageLimits.ts still returns null without an email or credentialFingerprint, so the workspaceId from rateLimits/read is never used as a merge key. The suggested codex:workspace: fallback would fix this case.

Produced by T3 Code triage with Claude Code (Claude Opus 5.5).

Synced from GitHub

Same bug confirmed on a single environment, with a wrinkle that affects the suggested fix.

Environment

  • T3 Code 0.1.4-nightly.20261027.3770 (Linux desktop, single environment)
  • codex-cli 0.165.0
  • CLIProxyAPI 8.0.46, usage limit source kind: cliproxy, same host (http://127.0.0.1:8317)
  • Codex routed through the hub via ~/.codex/config.toml (requires_openai_auth = false), normal ChatGPT login kept in ~/.codex/auth.json
  • Two Codex credentials in the hub — two seats of the same ChatGPT Team workspace

Actual

With one environment selected, Limits renders three segments for two accounts:

SegmentRemainingResetCredits
Codex (grey)62%3h17m≡1
Codex (grey)62%3h17m≡1
SG (green, hub)100%4h39m≡2

The two grey segments are identical (same %, same reset epoch, same credit count) and match the hub's read for the native login account. The green hub segment is the second seat. So: 1 native snapshot drawn twice + 1 of 2 hub accounts. The second hub account is not rendered at all.

Header percentages are the mean across all three segments, so the duplicate drags them down (81% and 100% real, displayed as 75% / 96%).

This reproduces the accountKey() → null → ${environmentId}:${instanceId} fallback described above, but without multiple environments, so the per-environment hypothesis isn't required to trigger it.

Why the proposed codex:workspace:<id> fix would not dedupe this case

In our setup the native login and both hub accounts share one chatgpt_account_id, because they are seats in the same Team workspace:

1auth.json account_id = 0cb70b3e-3f1b-4e7d-aea4-d4f23db8f398
2auth.json id_token email = gamingjedi6905@gmail.com
3auth.json id_token sub = google-oauth2|113101582387223660814
4hub acct 1 email = gamingjedi6905@gmail.com (same user as native)
5hub acct 1 id_token.chatgpt_account_id = 0cb70b3e-… (same workspace)
6hub acct 1 sub = google-oauth2|1131015823
7hub acct 2 email = <second seat>@gmail.com
8hub acct 2 id_token.chatgpt_account_id = 0cb70b3e-… (same workspace)
9hub acct 2 sub = google-oauth2|1082459214

So a codex:workspace:<chatgpt_account_id> key would collapse all three rows into one, and would additionally merge two genuinely distinct seats that have separate balances (62% vs 100%). For multi-seat single-workspace pools, chatgpt_account_id is a workspace identity, not an account identity. The stable per-seat discriminator here is the token sub (or the hub credential filename / auth_index), which is already available on the hub side.

Two things this suggests:

  1. Dedupe native-vs-hub by matching the native login's sub against the hub credential's sub, not by workspace id.
  2. Keep hub accounts distinct from each other when their sub differs, even if chatgpt_account_id matches — otherwise same-workspace seats silently merge and one seat's balance disappears from the pool.

Separate: only one of two hub accounts is rendered

Even ignoring the duplicate, the hub source returns two Codex accounts and Limits shows one green segment. Read directly from the hub, both are present and healthy:

1GET /v8/management/credentials
2 gamingjedi6905@gmail.com status=active auth_index=d0805fef1d0400db
3 <second seat>@gmail.com status=active auth_index=0ff069b1403c8b65
4
5/usage per credential: 33% / 5% and 0% / 0%

CliproxyApi.readAccounts / readAccount spans complete Success in the server trace, so the read isn't failing. If the two hub accounts are being collapsed by workspaceId in the collector, that is a distinct defect from the native-snapshot duplicate and worth splitting out.

Happy to test a patch build if useful — the machine has both seats live and reproduces on demand.

Synced from GitHub

Correction to my comment above, after checking the collector against live data — two of my claims were wrong, and the root cause is narrower than I said.

Correction 1: no hub account is missing

I claimed the second hub account "is not rendered at all." That's wrong. Matching each segment to the hub's live reset_at epochs:

SegmentRemainingReset epochMaps to
Codex (grey)62%1793225937seat 1
Codex (grey)62%1793225937seat 1 again
SG (green, hub)100%1793230826seat 2

Both seats are present — the distinct reset epochs prove it. The three segments are two seats plus one duplicate. I misread the green row as the only hub row.

Correction 2: the duplicate is native-vs-hub for the same seat

The duplicate is the native rateLimits/read snapshot of seat 1 colliding with the hub's row for seat 1. That is exactly the failure mode #23054 describes — accountKey() returning null for the native row because it carries no email, so it falls back to ${environmentId}:${instanceId} and never compares against the hub row that does have an email.

So the cause is confirmed, and my earlier framing ("two hub accounts collapsed by workspaceId") was a guess I shouldn't have stated as likely. It isn't happening.

Correction 3: the codex:workspace:<id> fallback is fine — my objection was wrong

I argued the workspace fallback would collapse genuinely distinct seats. Re-reading accountKey(), the email check runs before it:

ts
1if (email) return `${driver}:email:${email}`;
2if (fingerprint) return ...;
3// only here does workspaceId get consulted

Hub rows always carry an email (readAccount sets base.email from the credential file), so they never reach the workspace branch. Both seats keep distinct codex:email:* keys and stay separate. Only the email-less native row would pick up codex:workspace:<id> — which is precisely the row that needs to match the hub seat owning that workspace.

I withdrew a correct fix on a wrong premise. The suggested fix stands, and it resolves this case:

Key todayKey with workspace fallback
seat 1 native → codex:home:codexseat 1 native → codex:workspace:0cb70b3e-…
seat 1 hub → codex:email:gamingjedi6905@…seat 1 hub → codex:email:gamingjedi6905@…
seat 2 hub → codex:email:<seat2>@…seat 2 hub → codex:email:<seat2>@…

…which still leaves native and hub under different keys, because one is keyed by workspace and the other by email. So the workspace fallback alone does not dedupe this case either — the two sides have to agree on a key and the native path has to learn the email, or the hub row has to be matched by workspace when an email-keyed row already claims it.

Suggested minimal fix: in the collector, before falling back, match an email-less native row to a hub account whose workspaceId equals the native workspaceId and whose balance/reset matches — then drop the native row. Native limits on a custom provider are untrusted anyway (per triage on #11515), so suppressing the native row when a hub source covers the same workspace is the conservative fix.

Caveat on all of the above: derived from the shipped bundle (0.1.4-nightly.20261027.3770, client assets are minified) plus live hub reads — not from source. Happy to redo it against packages/shared/src/usageLimits.ts on main if that's more useful than a minified reconstruction.

Synced from GitHub

Confirmed this still reproduces on T3 Code 0.1.7-nightly.20261101.4048, with codex-cli 0.166.0 and CLIProxyAPI 8.0.48.

The duplicate is visible with one environment selected, so it does not require the All environments view: the Codex Session and Weekly bars each contain a native Codex segment and a hub-account segment for the same subscription, with matching remaining percentages, reset times and reset-credit count. Selecting All environments adds further native reports from the linked machines.

Reproduction

  1. Keep a normal ChatGPT subscription login in the CLI's auth file.
  2. Configure Codex to use a custom model_provider targeting CLIProxyAPI's Responses endpoint, with requires_openai_auth = false.
  3. Add a cliproxy usage source containing that same subscription account.
  4. Open Usage → Limits and select just that environment.

Expected: the same subscription is represented once. Actual: native and hub reports remain separate. This was observed in Windows desktop and Linux AppImage clients, as well as the server-served client. The desktop archives have not been modified.

Read-only evidence

The CLI probe returned:

json
1// account/read: relevant fields
2{"account": null, "requiresOpenaiAuth": false}
3
4// account/rateLimits/read: relevant fields, identifier replaced
5{"accountId": "<WORKSPACE_ID>", "rateLimits": "<VALID_LIMIT_SNAPSHOT>"}

The hub reports an email and the same id_token.chatgpt_account_id; those identity values were compared privately and matched in this single-subscription setup. No raw identifiers, credentials or auth exports are included here.

The shipped client has the identity-resolution sequence described in this issue: the base key requires an email or credential fingerprint, and the resolver returns null before consulting workspaceId if that base key is absent. The email-less native report therefore falls back to an instance/environment key, while the hub report has an email-based key.

Please retain the multi-seat/workspace ambiguity discussed above when evaluating a fix. This reproduction establishes the duplicate for one subscription; it does not establish that workspace ID alone safely identifies every subscription seat, or that matching percentages alone prove identity. A supported way to suppress native quota collection for a hub-routed instance would also avoid duplicate reporting without disabling the agent or altering sign-ins.

We are keeping the installed app stock and tracking this upstream. A temporary server-client diagnostic modification was reverted; this comment does not claim an upstream fix or visual verification of a patched build.

Synced from GitHub

Same on T3 Code 0.1.7-nightly.20261102.4066, codex-cli 0.166.0, CLIProxyAPI 8.0.44, two linked environments sharing one ChatGPT Plus account:

  • Linux x64 (Rocky Linux 9.8, a RHEL 9 rebuild, background service): hosts the hub. Codex uses model_provider = "cliproxy" with no local login, so its native probe fails (#11873). A cliproxy usage limit source points at that hub.
  • macOS desktop: Codex keeps its ChatGPT login (codex login status → "Logged in using ChatGPT") and routes through the same hub via model_provider = "cliproxy", requires_openai_auth = false.

Usage → Limits → All environments shows two Weekly segments in the Codex pool with identical remaining percent, reset time and reset-credit count: a native Codex segment with no account chip, and the hub account's segment with an initials chip. The native report carries no email, so accountKey yields no shared key and the two reports never merge, as described in #28882.

Triage by Claude Code (claude-fable-5-1) via t3 triage.