From 2387e98db4a50fd4c69a756649712083c8c06e72 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Fri, 14 Aug 2026 23:19:46 +0800 Subject: [PATCH 1/4] feat(usage): show ZCode Coding Plan quota in the status bar Adds a ZCode provider meter alongside Claude, Codex, Gemini, Grok, and the other usage providers: - New bounded fetcher reads the Coding Plan credential from the ZCode CLI config and calls the quota endpoint on the allowlisted official hosts only, with no shell and a 15-second timeout. - Wires zcode into the shared rate-limit state, polling/stale handling, status bar, provider menu, tooltip, settings search, and the one-shot default-on status item migration. - Uses Z as the compact narrow-layout badge. - Status item visibility is detection-gated on the ZCode CLI, which this stacks on top of the ZCode first-class agent PR. --- .../rate-limit-service-test-harness.ts | 3 + .../service-account-target-selection.test.ts | 4 + .../service-antigravity-usage.test.ts | 2 + .../rate-limits/service-cursor-usage.test.ts | 2 + .../service-inactive-account-previews.test.ts | 4 + .../service-live-claude-usage.test.ts | 4 + .../rate-limits/service-minimax-usage.test.ts | 4 + .../service-refresh-orchestration.test.ts | 18 ++ .../service-window-activation.test.ts | 4 + .../service/service-full-cycle-application.ts | 14 +- .../service/service-full-cycle-preparation.ts | 13 +- .../rate-limits/service/service-polling.ts | 3 +- src/main/rate-limits/service/service-state.ts | 9 +- src/main/rate-limits/service/service-types.ts | 1 + .../rate-limits/zcode-usage-fetcher.test.ts | 231 ++++++++++++++++ src/main/rate-limits/zcode-usage-fetcher.ts | 250 ++++++++++++++++++ .../appearance-status-bar-search.test.ts | 10 + .../settings/appearance-status-bar-search.ts | 2 + ...pearance-status-bar-zcode-toggle-search.ts | 34 +++ .../status-bar/StatusBarProviderSegment.tsx | 2 + .../status-bar/StatusBarVisibilityMenu.tsx | 12 + .../status-bar-agent-gating.test.ts | 3 + .../status-bar/status-bar-agent-gating.ts | 3 +- .../status-bar-provider-visibility.test.ts | 15 +- .../status-bar-provider-visibility.ts | 7 +- .../src/components/status-bar/tooltip.test.ts | 15 +- .../src/components/status-bar/tooltip.tsx | 8 +- .../components/status-bar/usage-error-copy.ts | 3 + .../usage-provider-settings-target.ts | 2 + .../status-bar/use-status-bar-controller.ts | 19 +- src/renderer/src/i18n/locales/en.json | 12 +- ...ui-hydration-workspace-preferences.test.ts | 12 +- .../ui/ui-slice-hydration-status-bar-items.ts | 4 +- src/shared/persisted-ui-state-types.ts | 2 + src/shared/rate-limit-state-factory.ts | 1 + src/shared/rate-limit-types.test.ts | 1 + src/shared/rate-limit-types.ts | 2 + src/shared/rpc-contract/client-ui-params.ts | 2 + src/shared/status-bar-defaults.ts | 1 + src/shared/ui-chrome-types.ts | 1 + 40 files changed, 708 insertions(+), 31 deletions(-) create mode 100644 src/main/rate-limits/zcode-usage-fetcher.test.ts create mode 100644 src/main/rate-limits/zcode-usage-fetcher.ts create mode 100644 src/renderer/src/components/settings/appearance-status-bar-zcode-toggle-search.ts diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index 021de01108d3..f51839d108b9 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -11,6 +11,7 @@ import { readGrokAuthSession } from './grok-auth' import { fetchCursorRateLimits } from './cursor-fetcher' import { readCursorAuthSession } from './cursor-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' +import { fetchZcodeRateLimits } from './zcode-usage-fetcher' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' export type Deferred = { @@ -92,6 +93,7 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) vi.mocked(fetchCursorRateLimits).mockImplementation(async () => unavailableProvider('cursor')) + vi.mocked(fetchZcodeRateLimits).mockImplementation(async () => unavailableProvider('zcode')) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -110,6 +112,7 @@ export function resetRateLimitProviderMocks(): void { status: 'unavailable' }) vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor')) + vi.mocked(fetchZcodeRateLimits).mockResolvedValue(unavailableProvider('zcode')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 2deeadfcdaad..bb5de4b64856 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -32,6 +32,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 9f2fc2c8244c..3696f434dec5 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -39,6 +39,8 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) + vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-cursor-usage.test.ts b/src/main/rate-limits/service-cursor-usage.test.ts index 37984b89a522..3646f9f85863 100644 --- a/src/main/rate-limits/service-cursor-usage.test.ts +++ b/src/main/rate-limits/service-cursor-usage.test.ts @@ -23,6 +23,8 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: v vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) + vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 300292efcc21..23f0a0e6bbac 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -39,6 +39,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 3c51a09aa5ff..07f6ccf0d272 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -36,6 +36,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 8b2253364723..93f95de78474 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -33,6 +33,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index b184d51eda6a..a3bc6eb2c6a1 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -7,6 +7,7 @@ import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' +import { fetchZcodeRateLimits } from './zcode-usage-fetcher' import { readGrokAuthSession } from './grok-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' import { @@ -40,6 +41,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) @@ -73,6 +78,19 @@ describe('RateLimitService', () => { resetRateLimitProviderMocks() }) + it('publishes a ZCode quota snapshot alongside the other providers', async () => { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 8)) + vi.mocked(fetchZcodeRateLimits).mockResolvedValue(okProvider('zcode', 42)) + const service = new RateLimitService() + + await service.refresh() + + expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().zcode?.session?.usedPercent).toBe(42) + expect(service.getState().codex?.session?.usedPercent).toBe(8) + }) + it('does not reread Grok auth when callers read state snapshots', () => { vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'ok', diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 7c83bd7d0260..54296c396b9b 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -41,6 +41,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 9e9d9506a051..0cdfdff5f1bc 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -36,7 +36,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ miniMaxResult ], grokResultPromise, - cursorResultPromise + cursorResultPromise, + zcodeResultPromise } = prepared if (signal.aborted) { return @@ -193,12 +194,17 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.state.minimax }) - const [grokSettled, cursorSettled] = await Promise.all([grokResultPromise, cursorResultPromise]) + const [grokSettled, cursorSettled, zcodeSettled] = await Promise.all([ + grokResultPromise, + cursorResultPromise, + zcodeResultPromise + ]) if (signal.aborted) { return } const grok = settleSiblingProviderResult('grok', grokSettled) const cursor = settleSiblingProviderResult('cursor', cursorSettled) + const zcode = settleSiblingProviderResult('zcode', zcodeSettled) // Why: the stale policy keeps a recent snapshot through a failed refresh, but // a snapshot belonging to a different Cursor account must not survive the // switch — the Accounts pane would name the new account beside the old @@ -212,10 +218,12 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ previousCursorAccount !== cursorAccount this.trackActiveFailureStreak('grok', grok) this.trackActiveFailureStreak('cursor', cursor) + this.trackActiveFailureStreak('zcode', zcode) this.updateState({ ...this.state, grok: this.applyStalePolicy(grok, previousState.grok), - cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor) + cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor), + zcode: this.applyStalePolicy(zcode, previousState.zcode) }) } } diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index df36011ab312..55a2cc2a0622 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -5,6 +5,7 @@ import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' import { fetchCursorRateLimits } from '../cursor-fetcher' import { readCursorAuthSession } from '../cursor-auth' +import { fetchZcodeRateLimits } from '../zcode-usage-fetcher' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' import { createHash } from 'node:crypto' import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection' @@ -44,6 +45,7 @@ export type FetchAllCyclePrepared = { ] grokResultPromise: Promise cursorResultPromise: Promise + zcodeResultPromise: Promise } export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { @@ -130,7 +132,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ? this.withFetchingStatus(null, 'minimax') : this.withFetchingStatus(previousState.minimax, 'minimax'), grok: this.withFetchingStatus(previousState.grok, 'grok'), - cursor: this.withFetchingStatus(previousState.cursor, 'cursor') + cursor: this.withFetchingStatus(previousState.cursor, 'cursor'), + zcode: this.withFetchingStatus(previousState.zcode, 'zcode') }) // Why: the Cursor probe reads the macOS Keychain, so it is awaited inside the @@ -145,6 +148,11 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (reason) => ({ status: 'rejected', reason }) as const ) + const zcodeResultPromise = fetchZcodeRateLimits({ signal }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + const missingWslCodexHome = codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) const grokResultPromise = fetchGrokRateLimits({ @@ -231,7 +239,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ miniMaxResult ], grokResultPromise, - cursorResultPromise + cursorResultPromise, + zcodeResultPromise } } } diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index e49c003cbb1d..402495080c27 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -79,7 +79,8 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue minimax: this.state.minimax, grok: this.state.grok, antigravity: this.state.antigravity, - cursor: this.state.cursor + cursor: this.state.cursor, + zcode: this.state.zcode } return Object.entries(byProvider).map(([provider, limits]) => ({ provider: provider as ActiveRateLimitProvider, diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index a10bfa3f3c00..7af82c6c3135 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -32,7 +32,8 @@ export abstract class RateLimitServiceState { antigravity: null, minimax: null, grok: null, - cursor: null + cursor: null, + zcode: null } protected grokAuthConfigured = readGrokAuthSession().status === 'ok' // Why: the Cursor probe reads the macOS Keychain, so it cannot run synchronously @@ -52,7 +53,8 @@ export abstract class RateLimitServiceState { minimax: 0, grok: 0, antigravity: 0, - cursor: 0 + cursor: 0, + zcode: 0 } // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. protected activeFailureStreakByProvider: Record = { @@ -64,7 +66,8 @@ export abstract class RateLimitServiceState { minimax: 0, grok: 0, antigravity: 0, - cursor: 0 + cursor: 0, + zcode: 0 } protected mainWindow: BrowserWindow | null = null protected detachWindowListeners: (() => void) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 9783f37f77df..a3998c13abe4 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -110,6 +110,7 @@ export type InternalRateLimitState = { minimax: ProviderRateLimits | null grok: ProviderRateLimits | null cursor: ProviderRateLimits | null + zcode: ProviderRateLimits | null } export function normalizePollingInterval(ms: number): number { diff --git a/src/main/rate-limits/zcode-usage-fetcher.test.ts b/src/main/rate-limits/zcode-usage-fetcher.test.ts new file mode 100644 index 000000000000..168a16d004c1 --- /dev/null +++ b/src/main/rate-limits/zcode-usage-fetcher.test.ts @@ -0,0 +1,231 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchZcodeRateLimits } from './zcode-usage-fetcher' + +let dir: string +let configPath: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-zcode-usage-')) + configPath = join(dir, 'config.json') + vi.stubGlobal('fetch', vi.fn()) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-12T06:00:00.000Z')) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + rmSync(dir, { recursive: true, force: true }) +}) + +function writeConfig(overrides: Record = {}): void { + mkdirSync(dir, { recursive: true }) + writeFileSync( + configPath, + JSON.stringify({ + model: { main: 'bigmodel-coding-plan/GLM-5.2' }, + provider: { + other: { options: { apiKey: 'ignored', baseURL: 'https://example.com/v1' } }, + 'bigmodel-coding-plan': { + options: { + apiKey: 'test-secret', + baseURL: 'https://open.bigmodel.cn/api/anthropic' + } + } + }, + ...overrides + }) + ) +} + +describe('fetchZcodeRateLimits', () => { + it('returns unavailable without a supported Coding Plan credential', async () => { + writeFileSync(configPath, JSON.stringify({ provider: {} })) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result).toMatchObject({ provider: 'zcode', status: 'unavailable' }) + expect(fetch).not.toHaveBeenCalled() + }) + + it('queries the matching quota endpoint and maps rolling, weekly, and MCP limits', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + level: 'max', + limits: [ + { + type: 'TIME_LIMIT', + unit: 5, + number: 1, + percentage: 3, + nextResetTime: 1_787_000_000_000 + }, + { + type: 'TOKENS_LIMIT', + unit: 6, + number: 1, + percentage: 44, + nextResetTime: 1_786_600_000_000 + }, + { + type: 'TOKENS_LIMIT', + unit: 3, + number: 5, + percentage: 12, + nextResetTime: 1_786_500_000_000 + } + ] + } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(fetch).toHaveBeenCalledWith( + 'https://open.bigmodel.cn/api/monitor/usage/quota/limit', + expect.objectContaining({ + method: 'GET', + redirect: 'error', + headers: expect.objectContaining({ Authorization: 'test-secret' }) + }) + ) + expect(result).toMatchObject({ provider: 'zcode', status: 'ok', planType: 'max' }) + expect(result.session).toEqual({ + usedPercent: 12, + windowMinutes: 300, + resetsAt: 1_786_500_000_000, + resetDescription: null + }) + expect(result.weekly?.usedPercent).toBe(44) + expect(result.monthly?.usedPercent).toBe(3) + }) + + it('uses CREDIT_LIMIT counts when the reported percentage is stale', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + code: 200, + data: { + limits: [ + { + type: 'CREDIT_LIMIT', + unit: 3, + number: 5, + usage: 2_000, + currentValue: 500, + remaining: 1_500, + percentage: 1 + }, + { + type: 'CREDIT_LIMIT', + unit: 6, + number: 1, + usage: 10_000, + remaining: 8_000, + percentage: 0 + } + ] + } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(25) + expect(result.weekly?.usedPercent).toBe(20) + expect(result.monthly).toBeNull() + }) + + it('selects the legacy string model provider when several accounts are configured', async () => { + writeConfig({ + model: 'zai/GLM-5.3', + provider: { + zai: { options: { apiKey: 'selected-key', baseURL: 'https://api.z.ai/v1' } }, + bigmodel: { options: { apiKey: 'other-key', baseURL: 'https://open.bigmodel.cn/v1' } } + } + }) + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { limits: [{ type: 'CREDIT_LIMIT', unit: 3, number: 5, percentage: 20 }] } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('ok') + expect(fetch).toHaveBeenCalledWith( + 'https://api.z.ai/api/monitor/usage/quota/limit', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'selected-key' }) + }) + ) + }) + + it('does not substitute a different account when the selected provider lacks a key', async () => { + writeConfig({ + model: { main: 'unconfigured/GLM-5.3' }, + provider: { zai: { options: { apiKey: 'other-account', baseURL: 'https://api.z.ai/v1' } } } + }) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + }) + + it('rejects a nonstandard HTTPS port before sending the key', async () => { + writeConfig({ + provider: { zai: { options: { apiKey: 'secret', baseURL: 'https://api.z.ai:4444/v1' } } } + }) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + }) + + it('supports the Z.AI endpoint without exposing credentials in errors', async () => { + writeConfig({ + model: { main: 'zai/GLM-5.2' }, + provider: { + zai: { options: { apiKey: 'never-log-me', baseURL: 'https://api.z.ai/api/anthropic' } } + } + }) + vi.mocked(fetch).mockResolvedValue(new Response('denied', { status: 401 })) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(fetch).toHaveBeenCalledWith( + 'https://api.z.ai/api/monitor/usage/quota/limit', + expect.any(Object) + ) + expect(result.status).toBe('error') + expect(result.error).toBe('ZCode quota request failed (401)') + expect(JSON.stringify(result)).not.toContain('never-log-me') + }) + + it('rejects malformed successful responses', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ success: true, data: {} }))) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + }) +}) diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts new file mode 100644 index 000000000000..c5ab9691e1b3 --- /dev/null +++ b/src/main/rate-limits/zcode-usage-fetcher.ts @@ -0,0 +1,250 @@ +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' + +const API_TIMEOUT_MS = 15_000 +const SUPPORTED_HOSTS = new Set(['api.z.ai', 'open.bigmodel.cn', 'dev.bigmodel.cn']) + +type ZcodeProviderOptions = { + apiKey?: unknown + baseURL?: unknown +} + +type ZcodeConfig = { + model?: string | { main?: unknown } + provider?: Record +} + +type QuotaLimit = { + type?: unknown + unit?: unknown + number?: unknown + usage?: unknown + currentValue?: unknown + remaining?: unknown + percentage?: unknown + nextResetTime?: unknown +} + +type QuotaResponse = { + success?: unknown + code?: unknown + msg?: unknown + data?: { + level?: unknown + limits?: unknown + } +} + +type ZcodeUsageCredentials = { + apiKey: string + quotaUrl: string +} + +function unavailable(error: string): ProviderRateLimits { + return { + provider: 'zcode', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error, + status: 'unavailable', + usageMetadata: { source: 'web', failureKind: 'missing-credentials' } + } +} + +function failed(error: string, failureKind: 'network' | 'server' | 'parse'): ProviderRateLimits { + return { + provider: 'zcode', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error, + status: 'error', + usageMetadata: { source: 'web', failureKind } + } +} + +function readCredentials(configPath: string): ZcodeUsageCredentials | null { + let config: ZcodeConfig + try { + config = JSON.parse(readFileSync(configPath, 'utf8')) as ZcodeConfig + } catch { + return null + } + + const mainModel = typeof config.model === 'string' ? config.model : config.model?.main + const mainProvider = typeof mainModel === 'string' ? mainModel.split('/', 1)[0] : null + // A quota from another configured account must never appear as the selected model's quota. + const candidates = mainProvider + ? Object.entries(config.provider ?? {}).filter(([id]) => id === mainProvider) + : Object.entries(config.provider ?? {}) + if (!mainProvider && candidates.length !== 1) { + return null + } + + for (const [, provider] of candidates) { + const apiKey = provider.options?.apiKey + const baseURL = provider.options?.baseURL + if ( + typeof apiKey !== 'string' || + !apiKey.trim() || + /[\r\n]/.test(apiKey) || + typeof baseURL !== 'string' + ) { + continue + } + try { + const parsed = new URL(baseURL) + if ( + parsed.protocol !== 'https:' || + !SUPPORTED_HOSTS.has(parsed.hostname) || + (parsed.port !== '' && parsed.port !== '443') + ) { + continue + } + return { + apiKey: apiKey.trim(), + quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit` + } + } catch { + continue + } + } + return null +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function asUsedPercent(limit: QuotaLimit): number | null { + const total = asNumber(limit.usage) + if (total !== null && total > 0) { + const current = asNumber(limit.currentValue) + const remaining = asNumber(limit.remaining) + if (current !== null || remaining !== null) { + const used = current ?? total - (remaining ?? 0) + return Math.min(100, Math.max(0, (used / total) * 100)) + } + } + const reported = asNumber(limit.percentage) + return reported === null ? null : Math.min(100, Math.max(0, reported)) +} + +function asResetTime(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +function asWindowMinutes(limit: QuotaLimit): number | null { + if (limit.type === 'TIME_LIMIT' && limit.unit === 5 && limit.number === 1) { + // Z.ai's monthly MCP marker is encoded as one minute. + return 30 * 24 * 60 + } + const multipliers: Record = { 1: 1440, 3: 60, 5: 1, 6: 10080 } + const unit = asNumber(limit.unit) + const count = asNumber(limit.number) + if (unit === null || count === null || !Number.isInteger(count) || count <= 0) { + return null + } + const multiplier = multipliers[unit] + return multiplier ? count * multiplier : null +} + +function asWindow(limit: QuotaLimit | undefined): RateLimitWindow | null { + if (!limit) { + return null + } + const usedPercent = asUsedPercent(limit) + const windowMinutes = asWindowMinutes(limit) + if (usedPercent === null || windowMinutes === null) { + return null + } + return { + usedPercent, + windowMinutes, + resetsAt: asResetTime(limit.nextResetTime), + resetDescription: null + } +} + +export async function fetchZcodeRateLimits( + options: { + configPath?: string + signal?: AbortSignal + } = {} +): Promise { + const configPath = options.configPath ?? join(homedir(), '.zcode', 'cli', 'config.json') + const credentials = readCredentials(configPath) + if (!credentials) { + return unavailable('ZCode Coding Plan credentials are not configured') + } + + let response: Response + try { + const signal = options.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + : AbortSignal.timeout(API_TIMEOUT_MS) + response = await fetch(credentials.quotaUrl, { + method: 'GET', + redirect: 'error', + headers: { + Authorization: credentials.apiKey, + 'Accept-Language': 'en-US,en', + 'Content-Type': 'application/json' + }, + signal + }) + } catch (error) { + return failed(error instanceof Error ? error.message : 'ZCode quota request failed', 'network') + } + + if (!response.ok) { + return failed(`ZCode quota request failed (${response.status})`, 'server') + } + + let payload: QuotaResponse + try { + payload = (await response.json()) as QuotaResponse + } catch { + return failed('Could not parse ZCode quota response', 'parse') + } + if ( + payload.success !== true || + (payload.code !== undefined && payload.code !== 0 && payload.code !== 200) || + !Array.isArray(payload.data?.limits) + ) { + const message = typeof payload.msg === 'string' ? payload.msg : 'Invalid ZCode quota response' + return failed(message, 'parse') + } + + const limits = payload.data.limits.filter( + (value): value is QuotaLimit => typeof value === 'object' && value !== null + ) + const planLimits = limits + .filter((limit) => limit.type === 'TOKENS_LIMIT' || limit.type === 'CREDIT_LIMIT') + .map(asWindow) + .filter((limit): limit is RateLimitWindow => limit !== null) + .sort((left, right) => left.windowMinutes - right.windowMinutes) + const session = planLimits.find((limit) => limit.windowMinutes === 300) ?? null + const weekly = planLimits.find((limit) => limit.windowMinutes === 10080) ?? null + const monthly = asWindow(limits.find((limit) => limit.type === 'TIME_LIMIT')) + if (!session && !weekly && !monthly) { + return failed('ZCode quota response contained no usable limits', 'parse') + } + + return { + provider: 'zcode', + session, + weekly, + monthly, + planType: typeof payload.data.level === 'string' ? payload.data.level : null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { source: 'web', credentialSource: configPath } + } +} diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.test.ts b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts index 3518bcba8aac..bc8596d564d3 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.test.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts @@ -31,6 +31,16 @@ describe('getStatusBarToggles', () => { ) }) + it('includes ZCode quota in Appearance search', () => { + const toggle = getStatusBarToggles().find((entry) => entry.id === 'zcode') + + expect(toggle).toMatchObject({ + title: 'ZCode Usage', + toggleDescription: 'Show ZCode Coding Plan quota usage.' + }) + expect(toggle?.keywords).toEqual(expect.arrayContaining(['zcode', 'zai', 'glm'])) + }) + it('includes MiniMax usage so Appearance can toggle the default-on status item', () => { const miniMaxToggle = getStatusBarToggles().find((entry) => entry.id === 'minimax') diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 8a4d65f21fd1..16749f4ab311 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -6,6 +6,7 @@ import { getAntigravityStatusBarToggleSearchEntry } from './appearance-status-ba import { getMiniMaxStatusBarToggleSearchEntry } from './appearance-status-bar-minimax-toggle-search' import { getGrokStatusBarToggleSearchEntry } from './appearance-status-bar-grok-toggle-search' import { getCursorStatusBarToggleSearchEntry } from './appearance-status-bar-cursor-toggle-search' +import { getZcodeStatusBarToggleSearchEntry } from './appearance-status-bar-zcode-toggle-search' export const getStatusBarToggles = createLocalizedCatalog( (): readonly { @@ -169,6 +170,7 @@ export const getStatusBarToggles = createLocalizedCatalog( getMiniMaxStatusBarToggleSearchEntry(), getGrokStatusBarToggleSearchEntry(), getCursorStatusBarToggleSearchEntry(), + getZcodeStatusBarToggleSearchEntry(), { id: 'ssh', title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), diff --git a/src/renderer/src/components/settings/appearance-status-bar-zcode-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-zcode-toggle-search.ts new file mode 100644 index 000000000000..1d618a65dc39 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-zcode-toggle-search.ts @@ -0,0 +1,34 @@ +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export function getZcodeStatusBarToggleSearchEntry(): { + id: StatusBarItem + title: string + description: string + keywords: string[] + toggleDescription: string +} { + return { + id: 'zcode', + title: translate('auto.components.settings.appearance.search.zcodeUsageTitle', 'ZCode Usage'), + description: translate( + 'auto.components.settings.appearance.search.zcodeUsageDescription', + 'Show ZCode Coding Plan quota usage in the status bar.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.zcode', 'zcode'), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword('auto.components.settings.appearance.search.zai', 'zai'), + ...translateSearchKeyword('auto.components.settings.appearance.search.glm', 'glm') + ], + toggleDescription: translate( + 'settings.appearance.statusBar.zcodeToggleDescription', + 'Show ZCode Coding Plan quota usage.' + ) + } +} diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 3a9bc0cba485..c70222d11ccb 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -85,6 +85,8 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { return 'R' case 'cursor': return 'U' + case 'zcode': + return 'Z' case 'codex': return 'X' } diff --git a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx index a2dab03d9c09..c98f91c673ec 100644 --- a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx +++ b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx @@ -133,6 +133,18 @@ export function StatusBarVisibilityMenu({ {translate('auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok Usage')} )} + {isStatusBarItemAvailable('zcode', detectedAgentIds) && ( + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('zcode') + }} + > + + {translate('auto.components.status.bar.StatusBar.zcodeUsageMenu', 'ZCode Usage')} + + )} { diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts index cd98315df505..4c7ada35a47d 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts @@ -20,6 +20,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', null)).toBe(true) expect(isStatusBarItemAvailable('antigravity', null)).toBe(true) expect(isStatusBarItemAvailable('grok', null)).toBe(true) + expect(isStatusBarItemAvailable('zcode', null)).toBe(true) }) it('hides CLI items not detected on PATH', () => { @@ -28,6 +29,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('antigravity', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false) + expect(isStatusBarItemAvailable('zcode', ['claude', 'kimi'])).toBe(false) }) it('shows CLI items detected on PATH', () => { @@ -36,5 +38,6 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true) expect(isStatusBarItemAvailable('antigravity', ['antigravity'])).toBe(true) expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true) + expect(isStatusBarItemAvailable('zcode', ['zcode'])).toBe(true) }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index 145abef3a900..85f641b96d2d 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -13,7 +13,8 @@ const CLI_GATED_ITEMS: ReadonlySet = new Set([ 'gemini', 'kimi', 'antigravity', - 'grok' + 'grok', + 'zcode' ]) export function isStatusBarItemAvailable( diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index b144ddbdeaea..d702a59d5a05 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -459,7 +459,8 @@ describe('isUsageEmptyState', () => { antigravity: provider('unavailable', { provider: 'antigravity' }), minimax: provider('unavailable', { provider: 'minimax' }), grok: provider('unavailable', { provider: 'grok' }), - cursor: provider('unavailable', { provider: 'cursor' }) + cursor: provider('unavailable', { provider: 'cursor' }), + zcode: provider('unavailable', { provider: 'zcode' }) }, usageSettings() ) @@ -478,7 +479,8 @@ describe('isUsageEmptyState', () => { antigravity: provider('unavailable', { provider: 'antigravity' }), minimax: provider('unavailable', { provider: 'minimax' }), grok: provider('unavailable', { provider: 'grok' }), - cursor: provider('unavailable', { provider: 'cursor' }) + cursor: provider('unavailable', { provider: 'cursor' }), + zcode: provider('unavailable', { provider: 'zcode' }) }, usageSettings({ codexManagedAccounts: [ @@ -512,7 +514,8 @@ describe('isUsageEmptyState', () => { antigravity: null, minimax: provider('unavailable', { provider: 'minimax' }), grok: provider('unavailable', { provider: 'grok' }), - cursor: provider('unavailable', { provider: 'cursor' }) + cursor: provider('unavailable', { provider: 'cursor' }), + zcode: provider('unavailable', { provider: 'zcode' }) }, usageSettings() ) @@ -531,7 +534,8 @@ describe('isUsageEmptyState', () => { antigravity: null, grok: provider('unavailable', { provider: 'grok' }), minimax: provider('unavailable', { provider: 'minimax' }), - cursor: provider('unavailable', { provider: 'cursor' }) + cursor: provider('unavailable', { provider: 'cursor' }), + zcode: provider('unavailable', { provider: 'zcode' }) }, usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) ) @@ -552,7 +556,8 @@ describe('isUsageEmptyState', () => { antigravity: null, grok: provider('unavailable', { provider: 'grok' }), minimax: provider('unavailable', { provider: 'minimax' }), - cursor: provider('unavailable', { provider: 'cursor' }) + cursor: provider('unavailable', { provider: 'cursor' }), + zcode: provider('unavailable', { provider: 'zcode' }) }, usageSettings({ antigravityUsageConfigured: true }) ) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 086540781d71..3622abac43c8 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -34,6 +34,7 @@ type UsageProviderSnapshots = { minimax: ProviderRateLimits | null | undefined grok: ProviderRateLimits | null | undefined cursor: ProviderRateLimits | null | undefined + zcode?: ProviderRateLimits | null } type UsageProviderId = ProviderRateLimits['provider'] @@ -181,7 +182,8 @@ export function isUsageEmptyState( antigravitySnapshotPending || isProviderSnapshotPending(providers.minimax) || isProviderSnapshotPending(providers.grok) || - isProviderSnapshotPending(providers.cursor) + isProviderSnapshotPending(providers.cursor) || + isProviderSnapshotPending(providers.zcode) ) { return false } @@ -195,6 +197,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.antigravity) && !isProviderConfigured(providers.minimax) && !isProviderConfigured(providers.grok) && - !isProviderConfigured(providers.cursor) + !isProviderConfigured(providers.cursor) && + !isProviderConfigured(providers.zcode) ) } diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index fd7561e250cd..f08f1e4a1ebd 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -54,7 +54,8 @@ const PROVIDER_IDS: ProviderRateLimits['provider'][] = [ 'opencode-go', 'kimi', 'minimax', - 'grok' + 'grok', + 'zcode' ] afterEach(() => { @@ -294,6 +295,18 @@ describe('provider usage error copy', () => { }) describe('getWindowSections', () => { + it('keeps ZCode Coding Plan windows separate from MCP quota', () => { + const session = { usedPercent: 25, windowMinutes: 300, resetsAt: null, resetDescription: null } + const weekly = { usedPercent: 40, windowMinutes: 10080, resetsAt: null, resetDescription: null } + const mcp = { usedPercent: 10, windowMinutes: 43200, resetsAt: null, resetDescription: null } + const sections = getWindowSections( + provider({ provider: 'zcode', session, weekly, monthly: mcp }) + ) + + expect(sections.map((section) => section.label)).toEqual(['Session', 'Weekly', 'MCP']) + expect(sections[2].window).toBe(mcp) + }) + it('returns buckets as sections when present', () => { const p: ProviderRateLimits = { provider: 'gemini', diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index be15ae18c02f..f3d49bf7590f 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -97,6 +97,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'grok') { return } + if (provider === 'zcode') { + return + } if (provider === 'cursor') { return } @@ -186,7 +189,10 @@ export function getWindowSections( } if (p.monthly !== undefined && p.monthly !== null) { sections.push({ - label: translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'), + label: + p.provider === 'zcode' + ? translate('auto.components.status.bar.tooltip.zcode.mcp', 'MCP') + : translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'), window: p.monthly }) } diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index a0a6cabf1a9a..95942ddb5b72 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -26,6 +26,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'grok') { return 'Grok' } + if (provider === 'zcode') { + return 'ZCode' + } if (provider === 'cursor') { return 'Cursor' } diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index 39380e135950..b531bffd161e 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -21,7 +21,9 @@ export function getUsageProviderAccountsSectionId( case 'cursor': return 'accounts-cursor' case 'kimi': + case 'zcode': // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. + // ZCode likewise owns its Coding Plan credential in ~/.zcode/cli/config.json. return null } } diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index b7f57c7f1e7b..425b1396d020 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -99,7 +99,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { return null } - const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, zcode } = + rateLimits // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. @@ -125,6 +126,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) const visibleGrok = getVisibleUsageProvider('grok', grok, usageSettings) const visibleCursor = getVisibleUsageProvider('cursor', cursor, usageSettings) + const visibleZcode = getVisibleUsageProvider('zcode', zcode, usageSettings) const showClaude = visibleClaude !== null && statusBarItems.includes('claude') && @@ -154,6 +156,10 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { // Why: a Cursor session can come from the IDE alone, so PATH detection of // cursor-agent would hide a real meter from IDE-only users. const showCursor = visibleCursor !== null && statusBarItems.includes('cursor') + const showZcode = + visibleZcode !== null && + statusBarItems.includes('zcode') && + isStatusBarItemAvailable('zcode', detectedAgentIds) // Why: OpenCode Go is web/cookie-auth, not a CLI on PATH, so detection-gating doesn't apply. const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings) const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go') @@ -172,11 +178,12 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showAntigravity || showMiniMax || showGrok || - showCursor + showCursor || + showZcode const anyVisible = hasVisibleUsageMeters || showResourceUsage // Why: include Settings so durable managed accounts count — a configured user isn't shown the empty state while snapshots hydrate. const isEmptyUsageState = isUsageEmptyState( - { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor }, + { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, zcode }, usageSettings ) // Why: one-time nudge — once dismissed, stays hidden even if providers reconnect later. @@ -190,7 +197,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { antigravity?.status === 'fetching' || minimax?.status === 'fetching' || grok?.status === 'fetching' || - cursor?.status === 'fetching' + cursor?.status === 'fetching' || + zcode?.status === 'fetching' const compact = containerWidth < 900 const iconOnly = containerWidth < 500 @@ -210,7 +218,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showKimi ? visibleKimi : null, showMiniMax ? visibleMiniMax : null, showGrok ? visibleGrok : null, - showCursor ? visibleCursor : null + showCursor ? visibleCursor : null, + showZcode ? visibleZcode : null ].filter((p): p is ProviderRateLimits => p !== null) const handleManageAccounts = (): void => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 14a780e7612c..113bcfc846d0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -151,7 +151,8 @@ "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI.", - "cursorToggleDescription": "Show Cursor plan usage when signed in through cursor-agent or Cursor IDE." + "cursorToggleDescription": "Show Cursor plan usage when signed in through cursor-agent or Cursor IDE.", + "zcodeToggleDescription": "Show ZCode Coding Plan quota usage." }, "menuBarIcon": { "title": "Show Menu Bar Icon", @@ -3820,7 +3821,8 @@ "floatingTerminalNewActivity": "{{label}}, new activity", "codexSignInSuccess": "Signed in to Codex", "codexSignInError": "Codex sign-in failed. Please try again.", - "cursorUsageMenu": "Cursor Usage" + "cursorUsageMenu": "Cursor Usage", + "zcodeUsageMenu": "ZCode Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -3967,6 +3969,7 @@ } }, "tooltip": { + "zcode": { "mcp": "MCP" }, "cedb7b99e3": "% used", "6d6df77f41": "No data available", "7f7f208060": "Monthly", @@ -9357,6 +9360,11 @@ "c5b9f8d1e3": "xai", "usagePercentageDisplayTitle": "Usage percentages", "usagePercentageDisplayDescription": "Choose whether provider limits show the percentage used or remaining.", + "zcodeUsageTitle": "ZCode Usage", + "zcodeUsageDescription": "Show ZCode Coding Plan quota usage in the status bar.", + "zcode": "zcode", + "zai": "zai", + "glm": "glm", "tray": { "tray": "tray", "system": "system tray", diff --git a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts index 0a380a0625a1..815ae36b523e 100644 --- a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts +++ b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts @@ -140,7 +140,8 @@ describe('createUISlice hydratePersistedUI', () => { 'minimax', 'antigravity', 'grok', - 'cursor' + 'cursor', + 'zcode' ]) expect(setUI).toHaveBeenCalledWith({ statusBarItems: [ @@ -151,14 +152,16 @@ describe('createUISlice hydratePersistedUI', () => { 'minimax', 'antigravity', 'grok', - 'cursor' + 'cursor', + 'zcode' ], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, - _cursorStatusBarDefaultAdded: true + _cursorStatusBarDefaultAdded: true, + _zcodeStatusBarDefaultAdded: true }) }) @@ -175,7 +178,8 @@ describe('createUISlice hydratePersistedUI', () => { _minimaxStatusBarDefaultAdded: true, _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, - _cursorStatusBarDefaultAdded: true + _cursorStatusBarDefaultAdded: true, + _zcodeStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts index 47c770804c23..26b6071d64dd 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts @@ -8,6 +8,7 @@ const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' const DEFAULT_ON_CURSOR_STATUS_BAR_ITEM: StatusBarItem = 'cursor' +const DEFAULT_ON_ZCODE_STATUS_BAR_ITEM: StatusBarItem = 'zcode' export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { let items = migrateStatusBarItems(ui.statusBarItems) @@ -17,7 +18,8 @@ export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { ['_minimaxStatusBarDefaultAdded', DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM], ['_antigravityStatusBarDefaultAdded', DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM], ['_grokStatusBarDefaultAdded', DEFAULT_ON_GROK_STATUS_BAR_ITEM], - ['_cursorStatusBarDefaultAdded', DEFAULT_ON_CURSOR_STATUS_BAR_ITEM] + ['_cursorStatusBarDefaultAdded', DEFAULT_ON_CURSOR_STATUS_BAR_ITEM], + ['_zcodeStatusBarDefaultAdded', DEFAULT_ON_ZCODE_STATUS_BAR_ITEM] ] as const for (const [flag, item] of defaults) { if (!ui[flag] && !items.includes(item)) { diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 6b1f20b58b2a..293efe942e4c 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -120,6 +120,8 @@ export type PersistedUIState = { _grokStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on Cursor status item. */ _cursorStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on ZCode status item. */ + _zcodeStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index 3dade988f0b9..494f56e4dff1 100644 --- a/src/shared/rate-limit-state-factory.ts +++ b/src/shared/rate-limit-state-factory.ts @@ -12,6 +12,7 @@ export function createEmptyRateLimitState(overrides: Partial = { minimax: null, grok: null, cursor: null, + zcode: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, opencodeGoApiKeyConfigured: false, diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index b24e40385794..556af5785659 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -17,6 +17,7 @@ describe('RateLimitState', () => { minimax: null, grok: null, cursor: null, + zcode: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, opencodeGoApiKeyConfigured: false, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 48ff7c7abe35..6b06f09d10e9 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -58,6 +58,7 @@ export type ProviderRateLimits = { | 'grok' | 'antigravity' | 'cursor' + | 'zcode' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -139,6 +140,7 @@ export type RateLimitState = { minimax: ProviderRateLimits | null grok: ProviderRateLimits | null cursor: ProviderRateLimits | null + zcode: ProviderRateLimits | null /** * True when a MiniMax session cookie is persisted on disk. The cookie lives * outside GlobalSettings, so this flag is the durable signal that the diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index b78a984889e4..3a7f0388b38f 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -70,6 +70,7 @@ export const StatusBarItem = z.enum([ 'minimax', 'grok', 'cursor', + 'zcode', 'ssh', 'resource-usage', 'ports' @@ -184,6 +185,7 @@ export const UiUpdateFields = z _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), _cursorStatusBarDefaultAdded: z.boolean().optional(), + _zcodeStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 2eb855d9dd5c..c32958ee9491 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -10,6 +10,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'minimax', 'grok', 'cursor', + 'zcode', 'ssh', 'resource-usage', 'ports' diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts index d9aee820949e..8d55b56f3e52 100644 --- a/src/shared/ui-chrome-types.ts +++ b/src/shared/ui-chrome-types.ts @@ -63,6 +63,7 @@ export type StatusBarItem = | 'minimax' | 'grok' | 'cursor' + | 'zcode' | 'ssh' | 'resource-usage' | 'ports' From 543af740122b38623594d59d5e56a73f681ddf45 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 28 Sep 2026 11:12:33 +0800 Subject: [PATCH 2/4] fix(usage): label ZCode MCP quota in roster --- .../status-bar/UsageRosterPanel.test.tsx | 29 +++++++++++++++++++ .../status-bar/UsageRosterPanel.tsx | 3 ++ 2 files changed, 32 insertions(+) diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx index de4f5a0e06f8..4f538ea852fa 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -247,6 +247,35 @@ describe('UsageRow', () => { expect(markup).toContain('25%') expect(markup).toContain('60%') }) + + it('labels ZCode MCP separately from Coding Plan windows in both roster modes', () => { + const zcode: ProviderRateLimits = { + provider: 'zcode', + session: { usedPercent: 1, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 94, windowMinutes: 10_080, resetsAt: null, resetDescription: null }, + monthly: { usedPercent: 97, windowMinutes: 43_200, resetsAt: null, resetDescription: null }, + updatedAt: mocks.now, + status: 'ok', + error: null + } + const renderRow = (mode: 'verbose' | 'compact') => + renderToStaticMarkup( + + ) + + expect(renderRow('verbose')).toContain('data-usage-window="MCP"') + expect(renderRow('verbose')).toContain('MCP') + expect(renderRow('verbose')).not.toContain('30d') + expect(renderRow('compact')).toContain('MCP') + expect(renderRow('compact')).not.toContain('30d') + }) }) describe('UsageRosterPanel density picker', () => { diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx index feb066fadca4..6163f1ba5489 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -49,6 +49,9 @@ function shortLabel( if (section.window === p.fableWeekly) { return 'Fable' } + if (p.provider === 'zcode' && section.window === p.monthly) { + return section.label + } return useRemainingDuration ? formatRateLimitWindowChipLabel(section.window) : formatWindowLabel(section.window.windowMinutes) From 869cb0a854e9f6e5b3036d55581907df94653445 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 28 Sep 2026 11:36:14 +0800 Subject: [PATCH 3/4] fix(usage): guard ZCode account identity and reset display --- .../service-refresh-orchestration.test.ts | 38 +++++++ .../service/service-full-cycle-application.ts | 11 +- .../rate-limits/zcode-usage-fetcher.test.ts | 61 ++++++++++ src/main/rate-limits/zcode-usage-fetcher.ts | 107 +++++++++++------- .../status-bar-provider-visibility.test.ts | 32 +++--- .../status-bar-provider-visibility.ts | 2 +- 6 files changed, 193 insertions(+), 58 deletions(-) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index a3bc6eb2c6a1..9577a87d6a3f 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -91,6 +91,44 @@ describe('RateLimitService', () => { expect(service.getState().codex?.session?.usedPercent).toBe(8) }) + it('does not keep a previous ZCode account quota after a failed account switch', async () => { + vi.mocked(fetchZcodeRateLimits) + .mockResolvedValueOnce({ + ...okProvider('zcode', 42), + usageMetadata: { source: 'web', authProvenance: 'account-a' } + }) + .mockResolvedValueOnce({ + ...errorProvider('zcode', 'request failed'), + usageMetadata: { source: 'web', authProvenance: 'account-b', failureKind: 'network' } + }) + const service = new RateLimitService() + + await service.refresh() + await service.refresh() + + expect(service.getState().zcode?.status).toBe('error') + expect(service.getState().zcode?.session).toBeNull() + }) + + it('keeps a recent ZCode quota after a failed retry for the same account', async () => { + vi.mocked(fetchZcodeRateLimits) + .mockResolvedValueOnce({ + ...okProvider('zcode', 42), + usageMetadata: { source: 'web', authProvenance: 'account-a' } + }) + .mockResolvedValueOnce({ + ...errorProvider('zcode', 'request failed'), + usageMetadata: { source: 'web', authProvenance: 'account-a', failureKind: 'network' } + }) + const service = new RateLimitService() + + await service.refresh() + await service.refresh() + + expect(service.getState().zcode?.status).toBe('error') + expect(service.getState().zcode?.session?.usedPercent).toBe(42) + }) + it('does not reread Grok auth when callers read state snapshots', () => { vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'ok', diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 0cdfdff5f1bc..70b58933c163 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -216,6 +216,12 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ previousCursorAccount !== undefined && cursorAccount !== undefined && previousCursorAccount !== cursorAccount + const previousZcodeAccount = previousState.zcode?.usageMetadata?.authProvenance + const zcodeAccount = zcode.usageMetadata?.authProvenance + const sameZcodeAccount = + previousZcodeAccount !== undefined && + zcodeAccount !== undefined && + previousZcodeAccount === zcodeAccount this.trackActiveFailureStreak('grok', grok) this.trackActiveFailureStreak('cursor', cursor) this.trackActiveFailureStreak('zcode', zcode) @@ -223,7 +229,10 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ ...this.state, grok: this.applyStalePolicy(grok, previousState.grok), cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor), - zcode: this.applyStalePolicy(zcode, previousState.zcode) + zcode: + zcode.status === 'error' && !sameZcodeAccount + ? zcode + : this.applyStalePolicy(zcode, previousState.zcode) }) } } diff --git a/src/main/rate-limits/zcode-usage-fetcher.test.ts b/src/main/rate-limits/zcode-usage-fetcher.test.ts index 168a16d004c1..b587d196be0f 100644 --- a/src/main/rate-limits/zcode-usage-fetcher.test.ts +++ b/src/main/rate-limits/zcode-usage-fetcher.test.ts @@ -51,6 +51,20 @@ describe('fetchZcodeRateLimits', () => { expect(fetch).not.toHaveBeenCalled() }) + it('does not infer an account from the only configured provider without a selected model', async () => { + writeConfig({ + model: {}, + provider: { + zai: { options: { apiKey: 'sole-account', baseURL: 'https://api.z.ai/v1' } } + } + }) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + }) + it('queries the matching quota endpoint and maps rolling, weekly, and MCP limits', async () => { writeConfig() vi.mocked(fetch).mockResolvedValue( @@ -148,6 +162,33 @@ describe('fetchZcodeRateLimits', () => { expect(result.monthly).toBeNull() }) + it('drops an implausible five-hour reset without discarding the quota value', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + limits: [ + { + type: 'CREDIT_LIMIT', + unit: 3, + number: 5, + percentage: 25, + nextResetTime: Date.now() + 10 * 60 * 60_000 + } + ] + } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.session?.usedPercent).toBe(25) + expect(result.session?.resetsAt).toBeNull() + }) + it('selects the legacy string model provider when several accounts are configured', async () => { writeConfig({ model: 'zai/GLM-5.3', @@ -219,6 +260,26 @@ describe('fetchZcodeRateLimits', () => { expect(JSON.stringify(result)).not.toContain('never-log-me') }) + it('changes the non-secret account identity when the selected key changes', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue(new Response('denied', { status: 401 })) + + const first = await fetchZcodeRateLimits({ configPath }) + writeConfig({ + provider: { + 'bigmodel-coding-plan': { + options: { apiKey: 'new-key', baseURL: 'https://open.bigmodel.cn/api/anthropic' } + } + } + }) + const second = await fetchZcodeRateLimits({ configPath }) + + expect(first.usageMetadata?.authProvenance).toMatch(/^[a-f0-9]{64}$/) + expect(second.usageMetadata?.authProvenance).not.toBe(first.usageMetadata?.authProvenance) + expect(JSON.stringify(first)).not.toContain('test-secret') + expect(JSON.stringify(second)).not.toContain('new-key') + }) + it('rejects malformed successful responses', async () => { writeConfig() vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ success: true, data: {} }))) diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts index c5ab9691e1b3..19cc73771a80 100644 --- a/src/main/rate-limits/zcode-usage-fetcher.ts +++ b/src/main/rate-limits/zcode-usage-fetcher.ts @@ -1,3 +1,4 @@ +import { createHmac, randomBytes } from 'node:crypto' import { readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' @@ -5,6 +6,7 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limi const API_TIMEOUT_MS = 15_000 const SUPPORTED_HOSTS = new Set(['api.z.ai', 'open.bigmodel.cn', 'dev.bigmodel.cn']) +const CREDENTIAL_IDENTITY_KEY = randomBytes(32) type ZcodeProviderOptions = { apiKey?: unknown @@ -40,6 +42,7 @@ type QuotaResponse = { type ZcodeUsageCredentials = { apiKey: string quotaUrl: string + authProvenance: string } function unavailable(error: string): ProviderRateLimits { @@ -55,7 +58,11 @@ function unavailable(error: string): ProviderRateLimits { } } -function failed(error: string, failureKind: 'network' | 'server' | 'parse'): ProviderRateLimits { +function failed( + error: string, + failureKind: 'network' | 'server' | 'parse', + authProvenance: string +): ProviderRateLimits { return { provider: 'zcode', session: null, @@ -64,7 +71,7 @@ function failed(error: string, failureKind: 'network' | 'server' | 'parse'): Pro updatedAt: Date.now(), error, status: 'error', - usageMetadata: { source: 'web', failureKind } + usageMetadata: { source: 'web', failureKind, authProvenance } } } @@ -77,44 +84,48 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | null { } const mainModel = typeof config.model === 'string' ? config.model : config.model?.main - const mainProvider = typeof mainModel === 'string' ? mainModel.split('/', 1)[0] : null + const delimiter = typeof mainModel === 'string' ? mainModel.indexOf('/') : -1 + const mainProvider = + typeof mainModel === 'string' && delimiter > 0 && delimiter < mainModel.length - 1 + ? mainModel.slice(0, delimiter) + : null // A quota from another configured account must never appear as the selected model's quota. - const candidates = mainProvider - ? Object.entries(config.provider ?? {}).filter(([id]) => id === mainProvider) - : Object.entries(config.provider ?? {}) - if (!mainProvider && candidates.length !== 1) { + if (!mainProvider) { return null } - - for (const [, provider] of candidates) { - const apiKey = provider.options?.apiKey - const baseURL = provider.options?.baseURL + const provider = config.provider?.[mainProvider] + if (!provider) { + return null + } + const apiKey = provider.options?.apiKey + const baseURL = provider.options?.baseURL + if ( + typeof apiKey !== 'string' || + !apiKey.trim() || + /[\r\n]/.test(apiKey) || + typeof baseURL !== 'string' + ) { + return null + } + try { + const parsed = new URL(baseURL) if ( - typeof apiKey !== 'string' || - !apiKey.trim() || - /[\r\n]/.test(apiKey) || - typeof baseURL !== 'string' + parsed.protocol !== 'https:' || + !SUPPORTED_HOSTS.has(parsed.hostname) || + (parsed.port !== '' && parsed.port !== '443') ) { - continue + return null } - try { - const parsed = new URL(baseURL) - if ( - parsed.protocol !== 'https:' || - !SUPPORTED_HOSTS.has(parsed.hostname) || - (parsed.port !== '' && parsed.port !== '443') - ) { - continue - } - return { - apiKey: apiKey.trim(), - quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit` - } - } catch { - continue + return { + apiKey: apiKey.trim(), + quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`, + authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY) + .update(JSON.stringify([mainProvider, parsed.origin, apiKey.trim()])) + .digest('hex') } + } catch { + return null } - return null } function asNumber(value: unknown): number | null { @@ -163,10 +174,12 @@ function asWindow(limit: QuotaLimit | undefined): RateLimitWindow | null { if (usedPercent === null || windowMinutes === null) { return null } + const reset = asResetTime(limit.nextResetTime) return { usedPercent, windowMinutes, - resetsAt: asResetTime(limit.nextResetTime), + resetsAt: + windowMinutes === 300 && reset !== null && reset > Date.now() + 301 * 60_000 ? null : reset, resetDescription: null } } @@ -199,18 +212,26 @@ export async function fetchZcodeRateLimits( signal }) } catch (error) { - return failed(error instanceof Error ? error.message : 'ZCode quota request failed', 'network') + return failed( + error instanceof Error ? error.message : 'ZCode quota request failed', + 'network', + credentials.authProvenance + ) } if (!response.ok) { - return failed(`ZCode quota request failed (${response.status})`, 'server') + return failed( + `ZCode quota request failed (${response.status})`, + 'server', + credentials.authProvenance + ) } let payload: QuotaResponse try { payload = (await response.json()) as QuotaResponse } catch { - return failed('Could not parse ZCode quota response', 'parse') + return failed('Could not parse ZCode quota response', 'parse', credentials.authProvenance) } if ( payload.success !== true || @@ -218,7 +239,7 @@ export async function fetchZcodeRateLimits( !Array.isArray(payload.data?.limits) ) { const message = typeof payload.msg === 'string' ? payload.msg : 'Invalid ZCode quota response' - return failed(message, 'parse') + return failed(message, 'parse', credentials.authProvenance) } const limits = payload.data.limits.filter( @@ -233,7 +254,11 @@ export async function fetchZcodeRateLimits( const weekly = planLimits.find((limit) => limit.windowMinutes === 10080) ?? null const monthly = asWindow(limits.find((limit) => limit.type === 'TIME_LIMIT')) if (!session && !weekly && !monthly) { - return failed('ZCode quota response contained no usable limits', 'parse') + return failed( + 'ZCode quota response contained no usable limits', + 'parse', + credentials.authProvenance + ) } return { @@ -245,6 +270,10 @@ export async function fetchZcodeRateLimits( updatedAt: Date.now(), error: null, status: 'ok', - usageMetadata: { source: 'web', credentialSource: configPath } + usageMetadata: { + source: 'web', + credentialSource: configPath, + authProvenance: credentials.authProvenance + } } } diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index d702a59d5a05..987bb91c040e 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -503,23 +503,21 @@ describe('isUsageEmptyState', () => { }) it('shows the setup CTA for a loaded profile with no configured usage provider', () => { - expect( - isUsageEmptyState( - { - claude: provider('unavailable', { provider: 'claude' }), - codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), - opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }), - antigravity: null, - minimax: provider('unavailable', { provider: 'minimax' }), - grok: provider('unavailable', { provider: 'grok' }), - cursor: provider('unavailable', { provider: 'cursor' }), - zcode: provider('unavailable', { provider: 'zcode' }) - }, - usageSettings() - ) - ).toBe(true) + const settledProviders = { + claude: provider('unavailable', { provider: 'claude' }), + codex: provider('unavailable', { provider: 'codex' }), + gemini: provider('unavailable'), + opencodeGo: provider('unavailable', { provider: 'opencode-go' }), + kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: null, + minimax: provider('unavailable', { provider: 'minimax' }), + grok: provider('unavailable', { provider: 'grok' }), + cursor: provider('unavailable', { provider: 'cursor' }), + zcode: provider('unavailable', { provider: 'zcode' }) + } + expect(isUsageEmptyState(settledProviders, usageSettings())).toBe(true) + expect(isUsageEmptyState({ ...settledProviders, zcode: undefined }, usageSettings())).toBe(true) + expect(isUsageEmptyState({ ...settledProviders, zcode: null }, usageSettings())).toBe(false) }) it('does not show the setup CTA while checked Antigravity usage is awaiting a snapshot', () => { diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 3622abac43c8..24392aa48ab3 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -183,7 +183,7 @@ export function isUsageEmptyState( isProviderSnapshotPending(providers.minimax) || isProviderSnapshotPending(providers.grok) || isProviderSnapshotPending(providers.cursor) || - isProviderSnapshotPending(providers.zcode) + (providers.zcode !== undefined && isProviderSnapshotPending(providers.zcode)) ) { return false } From 72a3fe1571f7b8129a295213d67f95ae3f3a30e6 Mon Sep 17 00:00:00 2001 From: Neil Date: Sun, 27 Sep 2026 22:44:10 -0700 Subject: [PATCH 4/4] fix(usage): read the ZCode quota JSON instead of asserting it, and cancel unread bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears both CI failures on this branch. Static analysis flagged two type assertions. Both sat on JSON this process does not control — a user-edited `~/.zcode/config.json` and a response from a remote quota endpoint — so the declared shapes were a guess rather than a fact. The fields were already read defensively with `typeof` checks; only the outer cast claimed the value was an object at all. `isRecord`/`readRecord` make that claim a check, which also lets the two now-unused config and response types go. The global-fetch audit failed because the request was a new unaudited call site. Node's bundled undici can crash the whole process when a response body is left unread and the peer closes the socket (orca#8695), and the `!response.ok` path returned without touching the body. It now cancels the body first, matching the sibling Codex clients, and the file is registered with its expected call count. Co-authored-by: guanbear --- src/main/global-fetch-call-site-audit.test.ts | 1 + src/main/rate-limits/zcode-usage-fetcher.ts | 86 +++++++++---------- 2 files changed, 43 insertions(+), 44 deletions(-) diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 0c2133f320eb..907092515f54 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -23,6 +23,7 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], ['main/rate-limits/codex-fetcher.ts', 3], + ['main/rate-limits/zcode-usage-fetcher.ts', 1], ['main/runtime/push/push-gateway-client.ts', 1], ['main/runtime/relay/relay-http-client.ts', 2], ['main/runtime/relay/relay-region-catalog-fetch.ts', 1], diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts index 19cc73771a80..852def603e32 100644 --- a/src/main/rate-limits/zcode-usage-fetcher.ts +++ b/src/main/rate-limits/zcode-usage-fetcher.ts @@ -2,22 +2,13 @@ import { createHmac, randomBytes } from 'node:crypto' import { readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' const API_TIMEOUT_MS = 15_000 const SUPPORTED_HOSTS = new Set(['api.z.ai', 'open.bigmodel.cn', 'dev.bigmodel.cn']) const CREDENTIAL_IDENTITY_KEY = randomBytes(32) -type ZcodeProviderOptions = { - apiKey?: unknown - baseURL?: unknown -} - -type ZcodeConfig = { - model?: string | { main?: unknown } - provider?: Record -} - type QuotaLimit = { type?: unknown unit?: unknown @@ -29,22 +20,31 @@ type QuotaLimit = { nextResetTime?: unknown } -type QuotaResponse = { - success?: unknown - code?: unknown - msg?: unknown - data?: { - level?: unknown - limits?: unknown - } -} - type ZcodeUsageCredentials = { apiKey: string quotaUrl: string authProvenance: string } +// Why readers and not casts: both JSON sources are outside our control — a user-edited +// config file and a remote response — so their shape is a guess until something checks it. +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readRecord(value: unknown): Record | null { + return isRecord(value) ? value : null +} + +function readMainProvider(model: unknown): string | null { + const name = typeof model === 'string' ? model : readRecord(model)?.main + if (typeof name !== 'string') { + return null + } + const delimiter = name.indexOf('/') + return delimiter > 0 && delimiter < name.length - 1 ? name.slice(0, delimiter) : null +} + function unavailable(error: string): ProviderRateLimits { return { provider: 'zcode', @@ -76,29 +76,24 @@ function failed( } function readCredentials(configPath: string): ZcodeUsageCredentials | null { - let config: ZcodeConfig + let config: Record | null try { - config = JSON.parse(readFileSync(configPath, 'utf8')) as ZcodeConfig + config = readRecord(JSON.parse(readFileSync(configPath, 'utf8'))) } catch { return null } - const mainModel = typeof config.model === 'string' ? config.model : config.model?.main - const delimiter = typeof mainModel === 'string' ? mainModel.indexOf('/') : -1 - const mainProvider = - typeof mainModel === 'string' && delimiter > 0 && delimiter < mainModel.length - 1 - ? mainModel.slice(0, delimiter) - : null - // A quota from another configured account must never appear as the selected model's quota. - if (!mainProvider) { + if (!config) { return null } - const provider = config.provider?.[mainProvider] - if (!provider) { + // A quota from another configured account must never appear as the selected model's quota. + const mainProvider = readMainProvider(config.model) + if (!mainProvider) { return null } - const apiKey = provider.options?.apiKey - const baseURL = provider.options?.baseURL + const options = readRecord(readRecord(readRecord(config.provider)?.[mainProvider])?.options) + const apiKey = options?.apiKey + const baseURL = options?.baseURL if ( typeof apiKey !== 'string' || !apiKey.trim() || @@ -220,6 +215,7 @@ export async function fetchZcodeRateLimits( } if (!response.ok) { + await cancelUnreadResponseBody(response) return failed( `ZCode quota request failed (${response.status})`, 'server', @@ -227,24 +223,26 @@ export async function fetchZcodeRateLimits( ) } - let payload: QuotaResponse + let payload: Record | null try { - payload = (await response.json()) as QuotaResponse + payload = readRecord(await response.json()) } catch { return failed('Could not parse ZCode quota response', 'parse', credentials.authProvenance) } + const data = readRecord(payload?.data) + const code = payload?.code + const reported = data?.limits if ( - payload.success !== true || - (payload.code !== undefined && payload.code !== 0 && payload.code !== 200) || - !Array.isArray(payload.data?.limits) + payload?.success !== true || + (code !== undefined && code !== 0 && code !== 200) || + !Array.isArray(reported) ) { - const message = typeof payload.msg === 'string' ? payload.msg : 'Invalid ZCode quota response' + const msg = payload?.msg + const message = typeof msg === 'string' ? msg : 'Invalid ZCode quota response' return failed(message, 'parse', credentials.authProvenance) } - const limits = payload.data.limits.filter( - (value): value is QuotaLimit => typeof value === 'object' && value !== null - ) + const limits = reported.filter((value): value is QuotaLimit => isRecord(value)) const planLimits = limits .filter((limit) => limit.type === 'TOKENS_LIMIT' || limit.type === 'CREDIT_LIMIT') .map(asWindow) @@ -266,7 +264,7 @@ export async function fetchZcodeRateLimits( session, weekly, monthly, - planType: typeof payload.data.level === 'string' ? payload.data.level : null, + planType: typeof data?.level === 'string' ? data.level : null, updatedAt: Date.now(), error: null, status: 'ok',