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/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..9577a87d6a3f 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,57 @@ 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 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-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..70b58933c163 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 @@ -210,12 +216,23 @@ 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) 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: + zcode.status === 'error' && !sameZcodeAccount + ? 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..b587d196be0f --- /dev/null +++ b/src/main/rate-limits/zcode-usage-fetcher.test.ts @@ -0,0 +1,292 @@ +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('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( + 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('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', + 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('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: {} }))) + + 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..852def603e32 --- /dev/null +++ b/src/main/rate-limits/zcode-usage-fetcher.ts @@ -0,0 +1,277 @@ +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 QuotaLimit = { + type?: unknown + unit?: unknown + number?: unknown + usage?: unknown + currentValue?: unknown + remaining?: unknown + percentage?: unknown + nextResetTime?: 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', + 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', + authProvenance: string +): ProviderRateLimits { + return { + provider: 'zcode', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error, + status: 'error', + usageMetadata: { source: 'web', failureKind, authProvenance } + } +} + +function readCredentials(configPath: string): ZcodeUsageCredentials | null { + let config: Record | null + try { + config = readRecord(JSON.parse(readFileSync(configPath, 'utf8'))) + } catch { + return null + } + + if (!config) { + return null + } + // 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 options = readRecord(readRecord(readRecord(config.provider)?.[mainProvider])?.options) + const apiKey = options?.apiKey + const baseURL = options?.baseURL + if ( + typeof apiKey !== 'string' || + !apiKey.trim() || + /[\r\n]/.test(apiKey) || + typeof baseURL !== 'string' + ) { + return null + } + try { + const parsed = new URL(baseURL) + if ( + parsed.protocol !== 'https:' || + !SUPPORTED_HOSTS.has(parsed.hostname) || + (parsed.port !== '' && parsed.port !== '443') + ) { + return null + } + 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 + } +} + +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 + } + const reset = asResetTime(limit.nextResetTime) + return { + usedPercent, + windowMinutes, + resetsAt: + windowMinutes === 300 && reset !== null && reset > Date.now() + 301 * 60_000 ? null : reset, + 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', + credentials.authProvenance + ) + } + + if (!response.ok) { + await cancelUnreadResponseBody(response) + return failed( + `ZCode quota request failed (${response.status})`, + 'server', + credentials.authProvenance + ) + } + + let payload: Record | null + try { + 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 || + (code !== undefined && code !== 0 && code !== 200) || + !Array.isArray(reported) + ) { + const msg = payload?.msg + const message = typeof msg === 'string' ? msg : 'Invalid ZCode quota response' + return failed(message, 'parse', credentials.authProvenance) + } + + 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) + .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', + credentials.authProvenance + ) + } + + return { + provider: 'zcode', + session, + weekly, + monthly, + planType: typeof data?.level === 'string' ? data.level : null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'web', + credentialSource: configPath, + authProvenance: credentials.authProvenance + } + } +} 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/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) 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..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 @@ -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: [ @@ -501,22 +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' }) - }, - 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', () => { @@ -531,7 +532,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 +554,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..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 @@ -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) || + (providers.zcode !== undefined && 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'