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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ follows [Semantic Versioning](https://semver.org/).

### Fixed

- Read known Claude plan labels from the available tier fields without showing
internal identifiers or guessing a plan from an unrelated substring.
- Accept duplicate Claude Desktop cache entries when their eligible access
tokens are identical. Distinct matching tokens still require a clear login;
an ambiguous-token failure preserves the selected CLI source.
- Preload the tray usage window hidden after completed onboarding, before
connection recovery or provider collection. The first click reuses the loaded
renderer instead of starting its initial load.
Expand Down
5 changes: 5 additions & 0 deletions docs/DESKTOP-CONNECTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ below it to apply the choice. Existing profiles keep their current source.
Keychain prompt. The candidate requests account allowance using Desktop's
current access token. This interface can change and is not a documented
third-party consumer API contract.
Duplicate eligible cache entries with the same access token count as one
credential. Different matching tokens remain ambiguous; MeterUsage does not
choose the first token or the one with the latest expiry. An ambiguous-token
failure leaves the previously selected CLI source available. This handling has
synthetic fixture coverage; live cache compatibility remains unverified.
Alternatively, choose **Claude Code CLI** and **Use CLI** to read local Claude
Code activity. CLI allowance requires a companion usage file; this option
does not provide Claude website token history.
Expand Down
8 changes: 8 additions & 0 deletions docs/KB.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ Release documentation refresh: 2026-10-16; build 54 source review is unchanged.

## Product and source facts

- Electron Claude plan labels recognize known organization, seat and user tiers
in that order. An unknown higher-priority value permits a known fallback;
unknown-only metadata stays unavailable instead of exposing internal labels.
Claude Desktop treats identical eligible access tokens as one credential,
while distinct tokens remain ambiguous. Ambiguous-token Desktop connection
failures preserve selected CLI collection. These changes have synthetic fixture proof; live
Desktop compatibility remains separate.

- Unreleased Electron source adds opt-in Muse Code allowance with `muse serve`
and the official experimental MSP `usage/read` method. The source starts
disabled, reads no credential files and sends no model turn. It preserves
Expand Down
4 changes: 3 additions & 1 deletion docs/PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ transport; do not run the helper interactively or record its output. The main
process decrypts `oauth:tokenCacheV2` from Claude Desktop's `config.json` and
reads only the active organization cookie from a read-only cookie database.
It selects account-scoped current access-token/expiry fields, skips refresh
tokens, and rejects ambiguous, legacy or expired entries. It never writes to
tokens, and rejects ambiguous, legacy or expired entries. Identical tokens in
eligible entries count as one credential; distinct tokens remain ambiguous.
It never writes to
Claude stores or rotates their tokens.

The access token authenticates a GET to
Expand Down
52 changes: 52 additions & 0 deletions docs/plans/cliproxy-hub.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# CLIProxyAPI Hub connection

Status: Research complete; implementation needs an existing saved-history source.

## Goal and source contract

Connect MeterUsage to an existing CLIProxyAPI history source and show retained
token activity grouped by provider and upstream account. Keep proxy activity
separate from local transcripts and subscription allowance to avoid double
counting or assigning activity to an unrelated account.

CLIProxyAPI 8.0.35 exposes a usage queue that removes records when read. It does
not expose the legacy historical snapshot endpoints. This integration must not
consume that queue, change proxy configuration, or install a new collector.
A saved export or a documented history service must be identified first.

Sources: [v8 usage contract](https://help.router-for.me/management/apiv8#usage),
[v8.0.35 routes](https://github.com/router-for-me/CLIProxyAPI/blob/ae3ee3be303fb07fb8173279923413005bf32177/internal/api/server_management_v8.go),
[legacy snapshot format](https://github.com/router-for-me/CLIProxyAPI/blob/db63f9b5d60e643ca060901cfe67ffc5ce63ac63/internal/api/handlers/management/usage.go).

## Proposed minimum

1. Verify the saved source's format, retention and non-destructive read contract.
Do not advertise complete history where coverage is unknown.
2. Add one explicit Hub connection in Settings. For an authenticated HTTP source,
keep its management key in main-process memory and OS-backed encrypted
storage; fail closed without secure storage. Reject redirects and credentials
embedded in URLs. Source details must precede transport implementation.
3. Read one bounded snapshot per refresh. Join reported `auth_index` values to
projected provider metadata where the source supplies that mapping. Keep
unknown or ambiguous identities in an unattributed group; model aliases are
not evidence of a subscription account.
4. Publish only numeric usage, dates and reduced display labels. Never publish
client API keys, credential files, email addresses, raw errors or headers.
Request counts must remain requests rather than sessions or messages.
5. Reuse the account heatmap's unavailable-day handling. Use reported total
tokens when component counters can overlap. Replace snapshots rather than
adding repeated cumulative totals. Do not merge Hub and local token totals.

## Verification and limits

Synthetic checks must cover authentication failures, cancellation, source
replacement, payload limits, identity ambiguity, missing dates, snapshot reset,
overlapping token categories and repeated polling. Renderer checks must verify
provider/account labels, unavailable history and separate local/Hub scope.
Native secure storage and the actual history source need independent execution
proof before runtime support is claimed.

No Hub adapter or credential store is implemented by this plan. The existing
local Claude heatmap and Desktop allowance remain separate data sources. No
server deployment, queue consumer, provider login or app installation follows
from this plan.
9 changes: 5 additions & 4 deletions src/main/providers/claude-desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,13 @@ export function desktopCredential(config: string, organization: string, password
const entry = object(value), scopes = match?.[4].split(/\s+/);
return match && match[1].toLowerCase() === account && uuid.test(match[2]) && match[3].toLowerCase() === organization.toLowerCase() && scopes?.includes("user:profile") && scopes.includes("user:inference") && typeof entry.expiresAt === "number" && Number.isFinite(entry.expiresAt) && entry.expiresAt > now + 120000 && !!text(entry.token);
});
// Fail closed for ambiguous caches instead of guessing which login to use.
if (candidates.length !== 1) {
if (candidates.length > 1) reading?.("ambiguous");
// Identical eligible tokens are one credential; distinct tokens stay ambiguous.
const tokens = [...new Set(candidates.map(([, value]) => object(value).token))];
if (tokens.length !== 1) {
if (tokens.length > 1) reading?.("ambiguous");
throw unavailable();
}
return { token: text(object(candidates[0][1]).token)!, identity: createHash("sha256").update(account + ":" + organization.toLowerCase()).digest("hex") };
return { token: text(tokens[0])!, identity: createHash("sha256").update(account + ":" + organization.toLowerCase()).digest("hex") };
} finally { plaintext.fill(0); }
} catch { throw unavailable(); }
}
Expand Down
10 changes: 6 additions & 4 deletions src/main/providers/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@ export function parseClaudeQuota(s: string, now = Date.now()): Quota {
export const claudeTierSelection = { oauthAccount: { organizationRateLimitTier: true, seatTier: true, userRateLimitTier: true } } as const;
export function parseClaudePlan(s: string): string {
const r = object(selectJSON(s, claudeTierSelection)), a = object(r.oauthAccount);
const tier = text(a.organizationRateLimitTier) ?? text(a.seatTier) ?? text(a.userRateLimitTier);
if (!tier) throw new Unavailable("noData");
const v = tier.toLowerCase();
return /max_?20x/.test(v) ? "Max 20×" : /max_?5x/.test(v) ? "Max 5×" : v.includes("enterprise") ? "Enterprise" : v.includes("team") ? "Team" : v.includes("pro") ? "Pro" : v.includes("free") ? "Free" : displayText(tier) ?? "Unknown";
const labels: Record<string, string> = { max_20x: "Max 20×", max20x: "Max 20×", max_5x: "Max 5×", max5x: "Max 5×", pro: "Pro", free: "Free", team: "Team", enterprise: "Enterprise" };
for (const tier of [a.organizationRateLimitTier, a.seatTier, a.userRateLimitTier].map(text)) {
const key = tier?.toLowerCase().replace(/^default_claude_/, ""), label = key && Object.hasOwn(labels, key) ? labels[key] : undefined;
if (label) return label;
}
throw new Unavailable("noData");
}
export function parseOpenRouter(s: string, creditsData?: string, now = Date.now()): Quota {
const r = object(json(s).data), usedRaw = number(r.usage), used = usedRaw === undefined ? undefined : Math.max(0, usedRaw), limit = number(r.limit);
Expand Down
55 changes: 55 additions & 0 deletions tests/connections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,61 @@ it("selects only current account/org access tokens and rejects stale, ambiguous
expect(() => desktopCredential(config({ [cacheKey()]: entry() }), "other-org", password, now)).toThrow();
expect(() => desktopCredential(config({ [cacheKey()]: entry() }), org, "wrong-password", now)).toThrow();
});
it.each([
cacheKey().replace(client, "55555555-5555-5555-5555-555555555555"),
cacheKey().replace("user:profile user:inference", "user:inference user:profile"),
])("accepts identical eligible Claude tokens under cache key %s regardless of record order", duplicateKey => {
const entries = {
[cacheKey()]: entry(),
[duplicateKey]: entry("synthetic-access-one", now + 240000),
[cacheKey().replace(client, "66666666-6666-6666-6666-666666666666")]: entry("synthetic-expiring", now + 120000),
[cacheKey("44444444-4444-4444-4444-444444444444")]: entry("synthetic-other-account"),
[cacheKey().replace(org, "77777777-7777-7777-7777-777777777777")]: entry("synthetic-other-org"),
[cacheKey().replace("api.anthropic.com", "example.invalid")]: entry("synthetic-other-host"),
[cacheKey().replace(" user:inference", "")]: entry("synthetic-missing-scope"),
};
const expected = desktopCredential(config({ [cacheKey()]: entry() }), org, password, now);
for (const records of [Object.entries(entries), Object.entries(entries).reverse()]) {
expect(desktopCredential(config(Object.fromEntries(records)), org, password, now)).toEqual(expected);
}
});
it.each(["identical", "distinct"])("handles %s Claude cache tokens when switching from CLI to Desktop", async kind => {
const { launch, preferences } = await prefs(); desktop(launch.home);
const root = join(launch.home, "Library/Application Support/Claude");
writeFileSync(join(root, "config.json"), config({
[cacheKey()]: entry(),
[cacheKey().replace(client, "55555555-5555-5555-5555-555555555555")]: entry(kind === "identical" ? "synthetic-access-one" : "synthetic-access-two"),
}));
const before = ["config.json", "Cookies"].map(name => readFileSync(join(root, name)));
mkdirSync(launch.data, { recursive: true });
writeFileSync(join(launch.data, "claude-usage.json"), '{"five_hour":{"used_percentage":17}}');
const helper = vi.fn<Command>(async () => password), http = vi.fn<HTTP>(async () => '{"five_hour":{"utilization":25}}');
const connections = new DesktopConnections(launch, preferences, "/synthetic/helper", () => {}, helper, http, () => now);
const source = () => compose(launch, preferences, { connections }).find(s => s.slot.provider === "claude")!;
expect(connections.state.find(s => s.provider === "claude")?.source).toBe("cli");
expect((await source().quota!()).windows[0].usedPercent).toBe(17);
if (kind === "identical") {
expect(await connections.connect("claude", async () => {}, async () => true)).toBe(true);
expect(connections.state.find(s => s.provider === "claude")).toMatchObject({ source: "account", status: "connected" });
const connected = source();
expect(connected.accountBound).toBe(true); expect(connected.activity).toBeUndefined(); expect(connected.plan).toBeUndefined();
expect((await connected.quota!()).windows[0].usedPercent).toBe(25);
expect(http.mock.calls.map(([url, , token]) => [url, token])).toEqual(Array(2).fill([endpoints.claudeAllowance, "synthetic-access-one"]));
expect(helper.mock.calls.map(([, args]) => args)).toEqual([["--allow-ui"], [], [], []]);
await connections.useLocal("claude");
await expect(connected.quota!()).rejects.toMatchObject({ code: "notSignedIn" });
expect(http).toHaveBeenCalledTimes(2); expect(helper).toHaveBeenCalledTimes(4);
} else {
await expect(connections.connect("claude", async () => {}, async () => true)).rejects.toThrow("multiple matching allowance tokens");
expect(connections.state.find(s => s.provider === "claude")?.status).toBe("failed");
expect(http).not.toHaveBeenCalled(); expect(helper).toHaveBeenCalledOnce();
}
expect(preferences.values.desktopClaudeIdentity).toBe("");
expect(connections.state.find(s => s.provider === "claude")).toMatchObject({ source: "cli", localAvailable: true });
expect(source().accountBound).toBeUndefined(); expect(source().activity).toBeTypeOf("function"); expect(source().plan).toBeTypeOf("function");
expect((await source().quota!()).windows[0].usedPercent).toBe(17);
for (const [i, name] of ["config.json", "Cookies"].entries()) expect(readFileSync(join(root, name))).toEqual(before[i]);
});
it("normalizes local Claude file, SQLite, decryption, JSON and helper failures to notSignedIn", async () => {
const home = temp();
await expect(readDesktopCredential(home, password, now)).rejects.toMatchObject({ code: "notSignedIn" });
Expand Down
15 changes: 15 additions & 0 deletions tests/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ describe("selective privacy boundary", () => {
expect(scalars).toEqual(['"max_20x"']); expect(JSON.stringify(result)).not.toContain("private");
expect(parseClaudePlan(input)).toBe("Max 20×");
});
it("recognizes the default Claude Max 5x tier alias", () => {
expect(parseClaudePlan(String.raw`{"oauthAccount":{"organizationRateLimitTier":"default_claude_max_5x"}}`)).toBe("Max 5×");
});
it("falls back from an unknown organization tier without exposing internal metadata", () => {
const scalars: string[] = [];
const input = String.raw`{"oauthAccount":{"organizationRateLimit\u0054ier":"internal_org_42","seatTier":"default_claude_ai","userRateLimitTier":"max_5x","email":"private@example.invalid","unknown":"private_metadata"}}`;
selectJSON(input, claudeTierSelection, scalar => scalars.push(scalar));
expect(parseClaudePlan(input)).toBe("Max 5×");
expect(parseClaudePlan(String.raw`{"oauthAccount":{"organizationRateLimitTier":"internal_profile","seatTier":"max_5x"}}`)).toBe("Max 5×");
expect(parseClaudePlan(String.raw`{"oauthAccount":{"organizationRateLimitTier":"project_internal","seatTier":"max_5x"}}`)).toBe("Max 5×");
expect(scalars).toEqual(['"internal_org_42"', '"default_claude_ai"', '"max_5x"']);
expect(() => parseClaudePlan(String.raw`{"oauthAccount":{"organizationRateLimitTier":"internal_org_42","seatTier":"default_claude_ai"}}`)).toThrow();
expect(() => parseClaudePlan(String.raw`{"oauthAccount":{}}`)).toThrow();
expect(parseClaudePlan(String.raw`{"oauthAccount":{"organizationRateLimitTier":"pro","seatTier":"max_5x"}}`)).toBe("Pro");
});
it("skips transcript bodies and tools before scalar decoding", () => {
const input = JSON.stringify({ type: "assistant", timestamp: "2026-10-06T11:00:00Z", message: { model: "claude-sonnet-4-6", content: [{ text: "PRIVATE_CONTENT" }], tool: { arguments: "PRIVATE_ARGUMENTS" }, usage: { input_tokens: 0, output_tokens: 7 } } });
const scalars: string[] = []; selectJSON(input, claudeLineSelection, scalar => scalars.push(scalar));
Expand Down
Loading