diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 22fe84caba63..d440dbeded0d 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -37,6 +37,7 @@ const { registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, registerMiniMaxCredentialsHandlersMock, + registerZcodePlanCredentialsHandlersMock, registerGrokAccountHandlersMock, registerCursorAccountHandlersMock, registerClipboardHandlersMock, @@ -105,6 +106,7 @@ const { registerAgentTrustHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), + registerZcodePlanCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), registerCursorAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), @@ -342,6 +344,10 @@ vi.mock('../minimax-credentials', () => ({ registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock })) +vi.mock('../zcode-plan-credentials', () => ({ + registerZcodePlanCredentialsHandlers: registerZcodePlanCredentialsHandlersMock +})) + vi.mock('../grok-accounts', () => ({ registerGrokAccountHandlers: registerGrokAccountHandlersMock })) @@ -441,6 +447,7 @@ describe('registerCoreHandlers', () => { registerAgentTrustHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerMiniMaxCredentialsHandlersMock.mockReset() + registerZcodePlanCredentialsHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() setTrustedClipboardRendererWebContentsIdMock.mockReset() registerUpdaterHandlersMock.mockReset() @@ -533,6 +540,7 @@ describe('registerCoreHandlers', () => { expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) + expect(registerZcodePlanCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGrokAccountHandlersMock).toHaveBeenCalled() expect(registerCursorAccountHandlersMock).toHaveBeenCalled() expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index e692cc9c5e89..47e7a006581e 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -63,6 +63,7 @@ import { getPtyIdForPaneKey } from '../pty' import { registerAgentTrustHandlers } from '../agent-trust' import { registerClaudeAccountHandlers } from '../claude-accounts' import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials' +import { registerZcodePlanCredentialsHandlers } from '../zcode-plan-credentials' import { registerGrokAccountHandlers } from '../grok-accounts' import { registerCursorAccountHandlers } from '../cursor-accounts' import { registerUpdaterHandlers } from '../../window/attach-main-window-services' @@ -152,6 +153,7 @@ export function registerCoreHandlers( registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) + registerZcodePlanCredentialsHandlers(rateLimits) registerGrokAccountHandlers() registerCursorAccountHandlers() registerRateLimitHandlers(rateLimits, codexAccounts) diff --git a/src/main/ipc/zcode-plan-credentials.ts b/src/main/ipc/zcode-plan-credentials.ts new file mode 100644 index 000000000000..63dd709609c4 --- /dev/null +++ b/src/main/ipc/zcode-plan-credentials.ts @@ -0,0 +1,51 @@ +import { ipcMain } from 'electron' +import { + clearZcodePlanApiKey, + hasZcodePlanApiKey, + saveZcodePlanApiKey +} from '../zcode/zcode-plan-api-key-store' +import { hasZcodeCliPlanCredentials } from '../rate-limits/zcode-usage-fetcher' +import type { RateLimitService } from '../rate-limits/service' + +export type ZcodePlanCredentialsStatus = { + apiKeyConfigured: boolean + zcodeCliConfigured: boolean +} + +function getZcodePlanCredentialsStatus(): ZcodePlanCredentialsStatus { + return { + apiKeyConfigured: hasZcodePlanApiKey(), + zcodeCliConfigured: hasZcodeCliPlanCredentials() + } +} + +// Why: fire-and-forget — callers get the persisted credential status immediately; +// the rate-limit refresh runs in the background and only logs on failure. +function refreshAfterZcodePlanCredentialChange( + rateLimits: RateLimitService | null, + action: 'save' | 'clear' +): void { + rateLimits?.invalidateZcodeCredentialState() + void rateLimits?.refresh().catch((error: unknown) => { + console.error(`[zcode] failed to trigger rate-limit refresh after ${action}:`, error) + }) +} + +export function registerZcodePlanCredentialsHandlers(rateLimits: RateLimitService | null): void { + ipcMain.handle('zcodePlanCredentials:getStatus', () => getZcodePlanCredentialsStatus()) + ipcMain.handle('zcodePlanCredentials:saveApiKey', (_event, key: string) => { + // Validate the IPC argument in the main process; the renderer-declared type + // is compile-time only and the value arrives as unknown over IPC. + if (typeof key !== 'string') { + throw new Error('GLM Coding Plan API key must be a string') + } + saveZcodePlanApiKey(key) + refreshAfterZcodePlanCredentialChange(rateLimits, 'save') + return getZcodePlanCredentialsStatus() + }) + ipcMain.handle('zcodePlanCredentials:clearApiKey', () => { + clearZcodePlanApiKey() + refreshAfterZcodePlanCredentialChange(rateLimits, 'clear') + return getZcodePlanCredentialsStatus() + }) +} diff --git a/src/main/rate-limits/service-zcode-usage.test.ts b/src/main/rate-limits/service-zcode-usage.test.ts new file mode 100644 index 000000000000..6169b0815259 --- /dev/null +++ b/src/main/rate-limits/service-zcode-usage.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { RateLimitService } from './service' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { fetchCodexRateLimits } from './codex-fetcher' +import { fetchZcodeRateLimits } from './zcode-usage-fetcher' +import { hasZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store' +import { + deferred, + okProvider, + resetRateLimitProviderMocks +} from './rate-limit-service-test-harness' + +vi.mock('./claude-fetcher', () => ({ + fetchClaudeRateLimits: vi.fn(), + fetchManagedAccountUsage: vi.fn() +})) + +vi.mock('./codex-fetcher', () => ({ + consumeCodexRateLimitResetCredit: vi.fn(), + fetchCodexRateLimits: vi.fn() +})) + +vi.mock('./gemini-usage-fetcher', () => ({ + fetchGeminiRateLimits: vi.fn() +})) + +vi.mock('./kimi-fetcher', () => ({ + fetchKimiRateLimits: vi.fn() +})) + +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: vi.fn() +})) + +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn(), + hasZcodeCliPlanCredentials: vi.fn(() => false) +})) + +vi.mock('./minimax/minimax-fetcher', () => ({ + fetchMiniMaxRateLimits: vi.fn() +})) + +vi.mock('./grok-fetcher', () => ({ + fetchGrokRateLimits: vi.fn() +})) + +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + +vi.mock('./grok-auth', () => ({ + readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + +vi.mock('../minimax/minimax-cookie-store', () => ({ + hasMiniMaxSessionCookie: vi.fn(() => false) +})) + +vi.mock('../minimax/minimax-api-key-store', () => ({ + hasMiniMaxApiKey: vi.fn(() => false) +})) + +vi.mock('../zcode/zcode-plan-api-key-store', () => ({ + hasZcodePlanApiKey: vi.fn(() => false), + readZcodePlanApiKey: vi.fn(() => null), + saveZcodePlanApiKey: vi.fn(), + clearZcodePlanApiKey: vi.fn() +})) + +describe('RateLimitService zcode plan credentials', () => { + beforeEach(() => { + resetRateLimitProviderMocks() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) + }) + + it('fetches zcode with the site-resolved plan credential when a resolver is set', async () => { + const service = new RateLimitService() + service.setZcodePlanConfigResolver(() => ({ site: 'bigmodel', apiKey: 'glm-key' })) + vi.mocked(hasZcodePlanApiKey).mockReturnValue(true) + vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce(okProvider('zcode', 33, Date.now())) + + await service.refresh() + + expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(1) + expect(fetchZcodeRateLimits).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal), + planCredential: { apiKey: 'glm-key', baseUrl: 'https://open.bigmodel.cn' } + }) + const state = service.getState() + expect(state.zcode?.status).toBe('ok') + expect(state.zcode?.session?.usedPercent).toBe(33) + expect(state.zcodePlanApiKeyConfigured).toBe(true) + }) + + it('passes no plan credential while no key is saved and still fetches via the CLI config', async () => { + const service = new RateLimitService() + service.setZcodePlanConfigResolver(() => ({ site: 'zai', apiKey: '' })) + vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce(okProvider('zcode', 12, Date.now())) + + await service.refresh() + + expect(fetchZcodeRateLimits).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal), + planCredential: null + }) + expect(service.getState().zcode?.session?.usedPercent).toBe(12) + }) + + it('surfaces a resolver failure as a zcode-only error without fetching', async () => { + const service = new RateLimitService() + service.setZcodePlanConfigResolver(() => { + throw new Error('GLM Coding Plan API key could not be decrypted') + }) + + await service.refresh() + + expect(fetchZcodeRateLimits).not.toHaveBeenCalled() + const zcode = service.getState().zcode + expect(zcode?.status).toBe('error') + expect(zcode?.error).toContain('could not be decrypted') + expect(zcode?.usageMetadata?.failureKind).toBe('keychain-unavailable') + expect(service.getState().claude?.status).toBe('ok') + }) + + it('discards the previous zcode snapshot when the saved site changes', async () => { + const service = new RateLimitService() + let site: 'zai' | 'bigmodel' = 'zai' + service.setZcodePlanConfigResolver(() => ({ site, apiKey: 'glm-key' })) + vi.mocked(fetchZcodeRateLimits) + .mockResolvedValueOnce(okProvider('zcode', 40, Date.now())) + .mockRejectedValueOnce(new Error('Zcode quota request failed (401)')) + + await service.refresh() + expect(service.getState().zcode?.session?.usedPercent).toBe(40) + + site = 'bigmodel' + await service.refresh() + + const state = service.getState() + expect(fetchZcodeRateLimits).toHaveBeenLastCalledWith({ + signal: expect.any(AbortSignal), + planCredential: { apiKey: 'glm-key', baseUrl: 'https://open.bigmodel.cn' } + }) + expect(state.zcode?.status).toBe('error') + expect(state.zcode?.session).toBeNull() + }) + + it('does not apply an in-flight zcode result after credential invalidation', async () => { + const service = new RateLimitService() + const firstZcode = deferred() + const secondZcode = deferred() + service.setZcodePlanConfigResolver(() => ({ site: 'zai', apiKey: 'glm-key' })) + vi.mocked(fetchZcodeRateLimits) + .mockImplementationOnce(() => firstZcode.promise) + .mockImplementationOnce(() => secondZcode.promise) + + const firstRefresh = service.refresh() + await Promise.resolve() + + service.invalidateZcodeCredentialState() + const queuedRefresh = service.refresh() + await Promise.resolve() + + firstZcode.resolve(okProvider('zcode', 50, Date.now())) + await Promise.resolve() + await Promise.resolve() + + expect(service.getState().zcode?.status).toBe('fetching') + expect(service.getState().zcode?.session).toBeNull() + + secondZcode.resolve(okProvider('zcode', 10, Date.now())) + await firstRefresh + await queuedRefresh + + const state = service.getState() + expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(2) + expect(state.zcode?.session?.usedPercent).toBe(10) + }) +}) diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts index ed8578e36940..e6090e28228c 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -38,6 +38,15 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna }) } + invalidateZcodeCredentialState(): void { + this.zcodeFetchGeneration += 1 + // Why: saving/forgetting the plan key can race an in-flight fetch; clear the visible snapshot before any old-key result returns. + this.updateState({ + ...this.state, + zcode: this.withFetchingStatus(null, 'zcode') + }) + } + async refreshForCodexAccountChange( outgoingAccountId?: string | null, target?: CodexAccountSelectionTarget diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index 2a788b1cf935..09dbde8a8600 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from 'electron' import { hasMiniMaxSessionCookie } from '../../minimax/minimax-cookie-store' import { hasMiniMaxApiKey } from '../../minimax/minimax-api-key-store' +import { hasZcodePlanApiKey } from '../../zcode/zcode-plan-api-key-store' import { RateLimitServiceAccountRefresh } from './service-account-refresh' import { type CodexAccountSelectionTarget, @@ -10,6 +11,7 @@ import { type ClaudeAuthPreparationResolver, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, + type ZcodePlanRateLimitConfig, type GeminiCliOAuthEnabledResolver, type InactiveCodexAccountInfo, type InactiveClaudeAccountInfo, @@ -48,6 +50,10 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco this.miniMaxConfigResolver = resolver } + setZcodePlanConfigResolver(resolver: () => ZcodePlanRateLimitConfig): void { + this.zcodePlanConfigResolver = resolver + } + setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { this.geminiCliOAuthEnabledResolver = resolver } @@ -125,6 +131,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco // Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads. minimaxCookieConfigured: hasMiniMaxSessionCookie(), minimaxApiKeyConfigured: hasMiniMaxApiKey(), + zcodePlanApiKeyConfigured: hasZcodePlanApiKey(), opencodeGoApiKeyConfigured: this.openCodeGoApiKeyConfigured, grokAuthConfigured: this.grokAuthConfigured, cursorAuthConfigured: this.cursorAuthConfigured, diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts index 12542f311db9..cfd4a6e9d58e 100644 --- a/src/main/rate-limits/service/service-fetch-policy.ts +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -24,6 +24,19 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT } } + protected getZcodePlanCredentialError(message: string): ProviderRateLimits { + return { + provider: 'zcode', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: message, + status: 'error', + usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } + } + } + // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. // A live post flips the snapshot back to ok, but the endpoint's Retry-After is still binding. protected isRetryAfterActive(limits: ProviderRateLimits | null): boolean { diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts index 51ce2db3d0b1..da15da71a29a 100644 --- a/src/main/rate-limits/service/service-fetch-targets.ts +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -6,6 +6,7 @@ import { type ClaudeRuntimeAuthPreparation, type CodexAccountSelectionTarget, type MiniMaxResolvedConfig, + type ZcodePlanResolvedConfig, type NormalizedCodexAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget, type ProviderRateLimits, @@ -219,4 +220,16 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul } } } + + protected resolveZcodePlanConfig(): ZcodePlanResolvedConfig { + try { + return { + config: this.zcodePlanConfigResolver?.() ?? { site: 'zai', apiKey: '' }, + error: null + } + } catch (error) { + // Why: an undecryptable saved key must not abort every provider's refresh; surface it as ZCode-only state instead. + return { config: { site: 'zai', apiKey: '' }, error: toErrorMessage(error) } + } + } } 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 70b58933c163..72e767ab3b38 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -26,6 +26,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ opencodeGeneration, miniMaxConfigChanged, miniMaxGeneration, + zcodeConfigChanged, + zcodeGeneration, claudeFetchGated, results: [ claudeResult, @@ -151,6 +153,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration + const shouldApplyZcode = zcodeGeneration === this.zcodeFetchGeneration if (shouldApplyClaude) { this.trackActiveFailureStreak('claude', claude) @@ -224,15 +227,20 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ previousZcodeAccount === zcodeAccount this.trackActiveFailureStreak('grok', grok) this.trackActiveFailureStreak('cursor', cursor) - this.trackActiveFailureStreak('zcode', zcode) + if (shouldApplyZcode) { + this.trackActiveFailureStreak('zcode', zcode) + } this.updateState({ ...this.state, grok: this.applyStalePolicy(grok, previousState.grok), cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor), - zcode: - zcode.status === 'error' && !sameZcodeAccount + zcode: !shouldApplyZcode + ? this.state.zcode + : zcodeConfigChanged ? zcode - : this.applyStalePolicy(zcode, previousState.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 55a2cc2a0622..4ebe8fed54c3 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -6,6 +6,7 @@ import { readGrokAuthSession } from '../grok-auth' import { fetchCursorRateLimits } from '../cursor-fetcher' import { readCursorAuthSession } from '../cursor-auth' import { fetchZcodeRateLimits } from '../zcode-usage-fetcher' +import { ZCODE_PLAN_SITE_BASE_URLS } from '../../../shared/zcode-plan-sites' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' import { createHash } from 'node:crypto' import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection' @@ -34,6 +35,8 @@ export type FetchAllCyclePrepared = { opencodeGeneration: number miniMaxConfigChanged: boolean miniMaxGeneration: number + zcodeConfigChanged: boolean + zcodeGeneration: number claudeFetchGated: boolean results: [ PromiseSettledResult, @@ -114,6 +117,25 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ } const miniMaxGeneration = this.minimaxFetchGeneration + const zcodePlanConfigResult = this.resolveZcodePlanConfig() + const zcodePlanApiKey = zcodePlanConfigResult.config.apiKey + // Why digest, not the key: this string only has to change when the credential does. + const currentZcodeConfigHash = zcodePlanApiKey + ? `${zcodePlanConfigResult.config.site}|${createHash('sha256').update(zcodePlanApiKey).digest('hex')}` + : (zcodePlanConfigResult.error ?? '') + const zcodeConfigChanged = currentZcodeConfigHash !== this.lastZcodeConfigHash + if (zcodeConfigChanged) { + this.lastZcodeConfigHash = currentZcodeConfigHash + this.zcodeFetchGeneration += 1 + } + const zcodeGeneration = this.zcodeFetchGeneration + const zcodePlanCredential = zcodePlanApiKey + ? { + apiKey: zcodePlanApiKey, + baseUrl: ZCODE_PLAN_SITE_BASE_URLS[zcodePlanConfigResult.config.site] + } + : null + // Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change). this.updateState({ ...previousState, @@ -133,7 +155,9 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ : this.withFetchingStatus(previousState.minimax, 'minimax'), grok: this.withFetchingStatus(previousState.grok, 'grok'), cursor: this.withFetchingStatus(previousState.cursor, 'cursor'), - zcode: this.withFetchingStatus(previousState.zcode, 'zcode') + zcode: zcodeConfigChanged + ? this.withFetchingStatus(null, 'zcode') + : this.withFetchingStatus(previousState.zcode, 'zcode') }) // Why: the Cursor probe reads the macOS Keychain, so it is awaited inside the @@ -148,7 +172,11 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (reason) => ({ status: 'rejected', reason }) as const ) - const zcodeResultPromise = fetchZcodeRateLimits({ signal }).then( + const zcodeResultPromise = ( + zcodePlanConfigResult.error + ? Promise.resolve(this.getZcodePlanCredentialError(zcodePlanConfigResult.error)) + : fetchZcodeRateLimits({ signal, planCredential: zcodePlanCredential }) + ).then( (value) => ({ status: 'fulfilled', value }) as const, (reason) => ({ status: 'rejected', reason }) as const ) @@ -229,6 +257,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ opencodeGeneration, miniMaxConfigChanged, miniMaxGeneration, + zcodeConfigChanged, + zcodeGeneration, claudeFetchGated, results: [ claudeResult, diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 7af82c6c3135..01c4456cf489 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -13,6 +13,7 @@ import { type ClaudeAuthPreparationResolver, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, + type ZcodePlanRateLimitConfig, type GeminiCliOAuthEnabledResolver, type NormalizedCodexAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget, @@ -84,8 +85,10 @@ export abstract class RateLimitServiceState { protected lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null protected opencodeFetchGeneration = 0 protected minimaxFetchGeneration = 0 + protected zcodeFetchGeneration = 0 protected lastOpencodeConfigHash = '' protected lastMiniMaxConfigHash = '' + protected lastZcodeConfigHash = '' protected codexHomePathResolver: CodexHomePathResolver | null = null protected codexFetchTarget: NormalizedCodexAccountSelectionTarget = { runtime: 'host', @@ -100,6 +103,7 @@ export abstract class RateLimitServiceState { } protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null + protected zcodePlanConfigResolver: (() => ZcodePlanRateLimitConfig) | null = null protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null protected inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index a3998c13abe4..bc6902e2fa56 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -1,4 +1,5 @@ import type { ProviderRateLimits } from '../../../shared/rate-limit-types' +import type { ZcodePlanSite } from '../../../shared/zcode-plan-sites' import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' import type { KimiHomeResolution } from '../../kimi/kimi-runtime-home' @@ -61,6 +62,16 @@ export type MiniMaxResolvedConfig = { error: string | null } +export type ZcodePlanRateLimitConfig = { + site: ZcodePlanSite + apiKey: string +} + +export type ZcodePlanResolvedConfig = { + config: ZcodePlanRateLimitConfig + error: string | null +} + export type GeminiCliOAuthEnabledResolver = () => boolean export type ActiveRateLimitProvider = ProviderRateLimits['provider'] export type ActiveProviderState = { diff --git a/src/main/rate-limits/zcode-usage-fetcher.test.ts b/src/main/rate-limits/zcode-usage-fetcher.test.ts index b587d196be0f..bb1a80221624 100644 --- a/src/main/rate-limits/zcode-usage-fetcher.test.ts +++ b/src/main/rate-limits/zcode-usage-fetcher.test.ts @@ -2,7 +2,7 @@ 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' +import { fetchZcodeRateLimits, hasZcodeCliPlanCredentials } from './zcode-usage-fetcher' let dir: string let configPath: string @@ -289,4 +289,96 @@ describe('fetchZcodeRateLimits', () => { expect(result.status).toBe('error') expect(result.usageMetadata?.failureKind).toBe('parse') }) + + it('prefers the Orca-saved plan credential over the ZCode CLI config', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + level: 'pro', + limits: [ + { type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 30 }, + { type: 'CREDIT_LIMIT', unit: 6, number: 1, percentage: 60 } + ] + } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ + configPath, + planCredential: { apiKey: 'orca-saved-key', baseUrl: 'https://api.z.ai' } + }) + + expect(result.status).toBe('ok') + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(String(url)).toBe('https://api.z.ai/api/monitor/usage/quota/limit') + expect(new Headers(init?.headers).get('Authorization')).toBe('orca-saved-key') + expect(result.usageMetadata?.credentialSource).toBe('orca-plan') + expect(JSON.stringify(result)).not.toContain('orca-saved-key') + }) + + it('uses the BigModel host for the mainland plan site', async () => { + writeFileSync(configPath, JSON.stringify({ provider: {} })) + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { limits: [{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 10 }] } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ + configPath, + planCredential: { apiKey: 'bigmodel-key', baseUrl: 'https://open.bigmodel.cn' } + }) + + expect(result.status).toBe('ok') + expect(String(vi.mocked(fetch).mock.calls[0][0])).toBe( + 'https://open.bigmodel.cn/api/monitor/usage/quota/limit' + ) + }) + + it('rejects a plan credential whose base URL is not a supported site', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('{}')) + + const result = await fetchZcodeRateLimits({ + configPath, + planCredential: { apiKey: 'key', baseUrl: 'https://evil.example.com' } + }) + + expect(result.status).toBe('error') + expect(result.error).toBe('The saved GLM Coding Plan API key is unusable') + expect(fetch).not.toHaveBeenCalled() + }) + + it('reports an unusable plan credential instead of switching to the CLI config', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue(new Response('{}')) + + const result = await fetchZcodeRateLimits({ + configPath, + planCredential: { apiKey: 'bad\r\nkey', baseUrl: 'https://api.z.ai' } + }) + + expect(result.status).toBe('error') + expect(result.error).toBe('The saved GLM Coding Plan API key is unusable') + expect(fetch).not.toHaveBeenCalled() + expect(result.usageMetadata?.credentialSource).toBeUndefined() + }) +}) + +describe('hasZcodeCliPlanCredentials', () => { + it('detects a usable CLI config', async () => { + writeConfig() + + expect(hasZcodeCliPlanCredentials(configPath)).toBe(true) + }) + + it('reports false without a config file', async () => { + expect(hasZcodeCliPlanCredentials(configPath)).toBe(false) + }) }) diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts index 852def603e32..dcc7671ba5db 100644 --- a/src/main/rate-limits/zcode-usage-fetcher.ts +++ b/src/main/rate-limits/zcode-usage-fetcher.ts @@ -26,6 +26,14 @@ type ZcodeUsageCredentials = { authProvenance: string } +/** A GLM Coding Plan key saved through Orca's AI Provider Accounts; takes priority over the ZCode CLI config. */ +export type ZcodePlanCredential = { + apiKey: string + baseUrl: string +} + +export const ZCODE_PLAN_CREDENTIAL_SOURCE = 'orca-plan' + // 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 { @@ -123,6 +131,38 @@ function readCredentials(configPath: string): ZcodeUsageCredentials | null { } } +function readPlanCredentials(plan: ZcodePlanCredential): ZcodeUsageCredentials | null { + const apiKey = plan.apiKey.trim() + if (!apiKey || /[\r\n]/.test(apiKey)) { + return null + } + try { + const parsed = new URL(plan.baseUrl) + if ( + parsed.protocol !== 'https:' || + !SUPPORTED_HOSTS.has(parsed.hostname) || + (parsed.port !== '' && parsed.port !== '443') + ) { + return null + } + return { + apiKey, + quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`, + authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY) + .update(JSON.stringify([ZCODE_PLAN_CREDENTIAL_SOURCE, parsed.origin, apiKey])) + .digest('hex') + } + } catch { + return null + } +} + +export function hasZcodeCliPlanCredentials( + configPath = join(homedir(), '.zcode', 'cli', 'config.json') +): boolean { + return readCredentials(configPath) !== null +} + function asNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } @@ -182,14 +222,24 @@ function asWindow(limit: QuotaLimit | undefined): RateLimitWindow | null { export async function fetchZcodeRateLimits( options: { configPath?: string + planCredential?: ZcodePlanCredential | null signal?: AbortSignal } = {} ): Promise { const configPath = options.configPath ?? join(homedir(), '.zcode', 'cli', 'config.json') - const credentials = readCredentials(configPath) + const planCredentials = options.planCredential + ? readPlanCredentials(options.planCredential) + : null + if (!planCredentials && options.planCredential) { + // Why: a saved-but-unusable key must surface as its own error; silently + // falling back to the CLI config would show a different account's quota. + return failed('The saved GLM Coding Plan API key is unusable', 'parse', '') + } + const credentials = planCredentials ?? readCredentials(configPath) if (!credentials) { return unavailable('ZCode Coding Plan credentials are not configured') } + const credentialSource = planCredentials ? ZCODE_PLAN_CREDENTIAL_SOURCE : configPath let response: Response try { @@ -270,7 +320,7 @@ export async function fetchZcodeRateLimits( status: 'ok', usageMetadata: { source: 'web', - credentialSource: configPath, + credentialSource, authProvenance: credentials.authProvenance } } diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index c932e59a9043..051b1079a231 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -47,6 +47,7 @@ export type RuntimeClientSettings = Pick< | 'minimaxGroupId' | 'minimaxUsageModels' | 'minimaxEndpoint' + | 'zcodePlanSite' | 'prBotAuthorOverrides' | 'artifactSharingEnabled' | 'worktreeVisibilityDefaults' @@ -83,6 +84,7 @@ export type RuntimeClientSettingsUpdate = Pick< | 'minimaxGroupId' | 'minimaxUsageModels' | 'minimaxEndpoint' + | 'zcodePlanSite' | 'prBotAuthorOverrides' | 'worktreeVisibilityDefaults' | 'machineName' @@ -131,6 +133,7 @@ export class RuntimeClientSettingsController { minimaxGroupId: settings.minimaxGroupId ?? '', minimaxUsageModels: settings.minimaxUsageModels ?? 'general', minimaxEndpoint: settings.minimaxEndpoint ?? 'overseas', + zcodePlanSite: settings.zcodePlanSite ?? 'zai', prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [], artifactSharingEnabled: isArtifactSharingEnabled(settings), worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' }, diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 43fe59b34e1a..a7f9af2a9069 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -103,6 +103,7 @@ export type RuntimeStore = { minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] minimaxEndpoint?: GlobalSettings['minimaxEndpoint'] + zcodePlanSite?: GlobalSettings['zcodePlanSite'] prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides'] artifactSharingEnabled?: GlobalSettings['artifactSharingEnabled'] terminalQuickCommands?: GlobalSettings['terminalQuickCommands'] diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 7a677ed760ef..a740a0b2efc4 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -15,6 +15,7 @@ import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limi import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home' import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' import { readMiniMaxApiKey } from '../minimax/minimax-api-key-store' +import { readZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store' import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync' import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' @@ -107,6 +108,17 @@ export function initializeMainProcessAccountServices(): void { ) }) } + // Why: the site picks the GLM Coding Plan quota host, so a stale snapshot from + // the previous site would otherwise sit in the status bar until the next poll. + if ('zcodePlanSite' in updates) { + state.rateLimits?.invalidateZcodeCredentialState() + void state.rateLimits?.refresh().catch((error: unknown) => { + console.warn( + '[rate-limits] Failed to refresh GLM Coding Plan usage after a settings change:', + error + ) + }) + } }) state.rateLimits.setClaudeAuthPreparationResolver((target) => state.claudeRuntimeAuth!.prepareForRateLimitFetch(target) @@ -134,6 +146,10 @@ export function initializeMainProcessAccountServices(): void { apiKey } }) + state.rateLimits.setZcodePlanConfigResolver(() => ({ + site: store.getSettings().zcodePlanSite, + apiKey: readZcodePlanApiKey() ?? '' + })) state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled) state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings()) state.keybindings = new KeybindingService({ diff --git a/src/main/zcode/zcode-plan-api-key-store.test.ts b/src/main/zcode/zcode-plan-api-key-store.test.ts new file mode 100644 index 000000000000..94b23c4cb8c9 --- /dev/null +++ b/src/main/zcode/zcode-plan-api-key-store.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ZcodePlanApiKeyStore from './zcode-plan-api-key-store' + +const safeStorageMock = vi.hoisted(() => ({ + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((value: string) => Buffer.from(value)), + decryptString: vi.fn((value: Buffer) => value.toString('utf8')) +})) + +const electronMock = vi.hoisted(() => ({ + safeStorage: safeStorageMock +})) + +vi.mock('electron', () => electronMock) + +const existsSyncMock = vi.fn() +const readFileSyncMock = vi.fn() +const rmSyncMock = vi.fn() +const hardenExistingSecureFileMock = vi.fn() +const writeSecureFileMock = vi.fn() +const homedirMock = vi.fn(() => '/home/test') + +vi.mock('node:fs', () => ({ + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + rmSync: rmSyncMock +})) + +vi.mock('node:os', () => ({ + homedir: homedirMock +})) + +vi.mock('node:path', () => ({ + join: (...parts: string[]) => parts.join('/') +})) + +vi.mock('../../shared/secure-file', () => ({ + hardenExistingSecureFile: hardenExistingSecureFileMock, + writeSecureFile: writeSecureFileMock +})) + +const storePath = '/home/test/.orca/zcode-plan-api-key.enc' +const envelope = (kind: 'encrypted' | 'plaintext', value: string): string => + `orca-zcode-plan-api-key:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}` + +async function loadStore(): Promise { + return await import('./zcode-plan-api-key-store') +} + +describe('zcode-plan-api-key-store', () => { + beforeEach(() => { + existsSyncMock.mockReset() + readFileSyncMock.mockReset() + rmSyncMock.mockReset() + hardenExistingSecureFileMock.mockReset() + writeSecureFileMock.mockReset() + writeSecureFileMock.mockReturnValue(true) + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value)) + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8')) + homedirMock.mockReturnValue('/home/test') + vi.resetModules() + }) + + it('reports unconfigured while no key file exists', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + + expect(store.hasZcodePlanApiKey()).toBe(false) + expect(store.readZcodePlanApiKey()).toBeNull() + }) + + it('saves an encrypted envelope and reads it back through the cache', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + + store.saveZcodePlanApiKey(' glm-secret ') + + expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('encrypted', 'glm-secret')) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'glm-secret'))) + expect(store.hasZcodePlanApiKey()).toBe(true) + expect(store.readZcodePlanApiKey()).toBe('glm-secret') + }) + + it('warns and writes plaintext when safeStorage is unavailable', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + + store.saveZcodePlanApiKey('glm-secret') + + expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('plaintext', 'glm-secret')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable')) + warn.mockRestore() + }) + + it('reads a plaintext envelope back without requiring safeStorage', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', 'glm-secret'))) + const store = await loadStore() + + expect(store.readZcodePlanApiKey()).toBe('glm-secret') + expect(safeStorageMock.decryptString).not.toHaveBeenCalled() + }) + + it('rejects saving an empty key', async () => { + const store = await loadStore() + + expect(() => store.saveZcodePlanApiKey(' ')).toThrow('GLM Coding Plan API key is required') + expect(writeSecureFileMock).not.toHaveBeenCalled() + }) + + it('rejects a key with an interior newline instead of saving it', async () => { + const store = await loadStore() + + expect(() => store.saveZcodePlanApiKey('glm\r\nsecret')).toThrow('must be a single line') + expect(writeSecureFileMock).not.toHaveBeenCalled() + }) + + it('refuses to keep an unrestricted plaintext key when hardening fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + writeSecureFileMock.mockReturnValue(false) + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + + expect(() => store.saveZcodePlanApiKey('glm-secret')).toThrow( + 'could not be stored securely on this device' + ) + expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true }) + warn.mockRestore() + }) + + it('restores the previous envelope when a plaintext replacement cannot be restricted', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + const previous = Buffer.from(envelope('encrypted', 'old-key')) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(previous) + writeSecureFileMock.mockReturnValueOnce(false).mockReturnValueOnce(true) + const store = await loadStore() + + expect(() => store.saveZcodePlanApiKey('new-key')).toThrow( + 'could not be stored securely on this device' + ) + // First write publishes the unrestricted replacement; the second restores + // the previous envelope so the user's old key survives the failed replace. + expect(writeSecureFileMock).toHaveBeenCalledTimes(2) + expect(writeSecureFileMock).toHaveBeenLastCalledWith(storePath, previous.toString('utf8')) + expect(rmSyncMock).not.toHaveBeenCalledWith(storePath, expect.anything()) + warn.mockRestore() + }) + + it('refuses to decrypt an encrypted envelope once safeStorage becomes unavailable', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'glm-secret'))) + const store = await loadStore() + + expect(() => store.readZcodePlanApiKey()).toThrow('could not be decrypted') + }) + + it('throws on an unreadable envelope instead of returning a partial key', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('not-an-envelope')) + const store = await loadStore() + + expect(() => store.readZcodePlanApiKey()).toThrow('could not be decrypted') + }) + + it('clearing removes the file and resets the cached value', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + store.saveZcodePlanApiKey('glm-secret') + + existsSyncMock.mockReturnValue(true) + store.clearZcodePlanApiKey() + + expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true }) + existsSyncMock.mockReturnValue(false) + expect(store.readZcodePlanApiKey()).toBeNull() + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts new file mode 100644 index 000000000000..5652d8907e52 --- /dev/null +++ b/src/main/zcode/zcode-plan-api-key-store.ts @@ -0,0 +1,156 @@ +import { safeStorage } from 'electron' +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file' + +const ZCODE_PLAN_API_KEY_FILE = 'zcode-plan-api-key.enc' +const API_KEY_ENVELOPE_PREFIX = 'orca-zcode-plan-api-key:v1:' +let cachedZcodePlanApiKey: string | null = null +let warnedZcodePlanApiKeyStatusHardenFailure = false + +type ZcodePlanApiKeyEnvelope = { + kind: 'encrypted' | 'plaintext' + payload: Buffer +} + +function getZcodePlanApiKeyPath(): string { + return join(homedir(), '.orca', ZCODE_PLAN_API_KEY_FILE) +} + +function encodeApiKeyEnvelope(kind: ZcodePlanApiKeyEnvelope['kind'], payload: Buffer): string { + return `${API_KEY_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}` +} + +function decodeApiKeyEnvelope(raw: Buffer): ZcodePlanApiKeyEnvelope { + const text = raw.toString('utf8') + if (!text.startsWith(API_KEY_ENVELOPE_PREFIX)) { + throw new Error('GLM Coding Plan API key could not be decrypted') + } + const rest = text.slice(API_KEY_ENVELOPE_PREFIX.length) + const separator = rest.indexOf(':') + if (separator === -1) { + throw new Error('GLM Coding Plan API key could not be decrypted') + } + const kind = rest.slice(0, separator) + if (kind !== 'encrypted' && kind !== 'plaintext') { + throw new Error('GLM Coding Plan API key could not be decrypted') + } + return { + kind, + payload: Buffer.from(rest.slice(separator + 1), 'base64') + } +} + +function readEnvelope(envelope: ZcodePlanApiKeyEnvelope): string { + if (envelope.kind === 'plaintext') { + return envelope.payload.toString('utf8') + } + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('GLM Coding Plan API key could not be decrypted') + } + return safeStorage.decryptString(envelope.payload) +} + +export function hasZcodePlanApiKey(): boolean { + const keyPath = getZcodePlanApiKeyPath() + if (!existsSync(keyPath)) { + return false + } + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + if (!warnedZcodePlanApiKeyStatusHardenFailure) { + warnedZcodePlanApiKeyStatusHardenFailure = true + console.warn( + '[zcode] Failed to harden GLM Coding Plan API key file while checking status', + error + ) + } + } + return true +} + +export function saveZcodePlanApiKey(key: string): void { + const trimmed = key.trim() + if (!trimmed) { + throw new Error('GLM Coding Plan API key is required') + } + if (/[\r\n]/.test(trimmed)) { + throw new Error('GLM Coding Plan API key must be a single line') + } + if (safeStorage.isEncryptionAvailable()) { + writeSecureFile( + getZcodePlanApiKeyPath(), + encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed)) + ) + cachedZcodePlanApiKey = trimmed + return + } + console.warn( + '[zcode] safeStorage encryption unavailable — storing GLM Coding Plan API key in plaintext' + ) + const keyPath = getZcodePlanApiKeyPath() + // Why: capture the previous envelope — writeSecureFile has already replaced + // the file by the time it reports that restriction failed, and deleting the + // result must not take the user's previous working key with it. + let previousEnvelope: Buffer | null = null + if (existsSync(keyPath)) { + try { + previousEnvelope = readFileSync(keyPath) + } catch { + previousEnvelope = null + } + } + const wroteRestricted = writeSecureFile( + keyPath, + encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) + ) + // Why: an unrestricted plaintext credential must never be reported as saved. + if (!wroteRestricted) { + if (!previousEnvelope) { + rmSync(keyPath, { force: true }) + } else { + try { + writeSecureFile(keyPath, previousEnvelope.toString('utf8')) + } catch { + // Why: restriction is failing device-wide; the restored bytes keep the + // previous credential available instead of deleting it, and the thrown + // save error still tells the user the store is not secure. + } + } + throw new Error('GLM Coding Plan API key could not be stored securely on this device') + } + cachedZcodePlanApiKey = trimmed +} + +export function readZcodePlanApiKey(): string | null { + if (cachedZcodePlanApiKey !== null) { + return cachedZcodePlanApiKey + } + const keyPath = getZcodePlanApiKeyPath() + if (!existsSync(keyPath)) { + return null + } + // Why: keep hardening out of the decode/decrypt try below so a chmod/ACL + // failure isn't misreported as a decrypt failure (matches hasZcodePlanApiKey). + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + console.warn('[zcode] Failed to harden GLM Coding Plan API key file while reading', error) + } + try { + const raw = readFileSync(keyPath) + const envelope = decodeApiKeyEnvelope(raw) + cachedZcodePlanApiKey = readEnvelope(envelope) + return cachedZcodePlanApiKey + } catch (error) { + console.error('[zcode] failed to decode/decrypt GLM Coding Plan API key', error) + throw new Error('GLM Coding Plan API key could not be decrypted') + } +} + +export function clearZcodePlanApiKey(): void { + cachedZcodePlanApiKey = null + rmSync(getZcodePlanApiKeyPath(), { force: true }) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7e4b2def4349..e7b44159db34 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -4,7 +4,8 @@ import type { CodexConfigSyncApi, CursorAccountsApi, GrokAccountsApi, - MinimaxCredentialsApi + MinimaxCredentialsApi, + ZcodePlanCredentialsApi } from './api/agent-account-api' import type { HooksApi } from './api/agent-hook-api' import type { SkillsApi } from './api/agent-skill-api' @@ -143,6 +144,7 @@ export type PreloadApi = { runtimeEnvironments: RuntimeApi['runtimeEnvironments'] rateLimits: RateLimitsApi minimaxCredentials: MinimaxCredentialsApi + zcodePlanCredentials: ZcodePlanCredentialsApi grokAccounts: GrokAccountsApi cursorAccounts: CursorAccountsApi ssh: SshApi diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index e8d574c285bf..46331570b340 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -81,6 +81,18 @@ export type MinimaxCredentialsApi = { clearApiKey: () => Promise<{ apiKeyConfigured: boolean }> } +export type ZcodePlanCredentialsApi = { + // Why: the GLM Coding Plan key lives in its own safeStorage file and the + // ZCode CLI's config is read-only, so the status reports both sources' + // presence; neither credential value ever crosses the IPC boundary. + getStatus: () => Promise<{ + apiKeyConfigured: boolean + zcodeCliConfigured: boolean + }> + saveApiKey: (key: string) => Promise<{ apiKeyConfigured: boolean }> + clearApiKey: () => Promise<{ apiKeyConfigured: boolean }> +} + export type CodexConfigSyncApi = { status: () => Promise } diff --git a/src/preload/api/zcode-plan-credentials-bridge.ts b/src/preload/api/zcode-plan-credentials-bridge.ts new file mode 100644 index 000000000000..e1ae42377c4f --- /dev/null +++ b/src/preload/api/zcode-plan-credentials-bridge.ts @@ -0,0 +1,13 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const zcodePlanCredentialsApi = { + getStatus: (): Promise<{ + apiKeyConfigured: boolean + zcodeCliConfigured: boolean + }> => ipcRenderer.invoke('zcodePlanCredentials:getStatus'), + saveApiKey: (key: string): Promise<{ apiKeyConfigured: boolean }> => + ipcRenderer.invoke('zcodePlanCredentials:saveApiKey', key), + clearApiKey: (): Promise<{ apiKeyConfigured: boolean }> => + ipcRenderer.invoke('zcodePlanCredentials:clearApiKey') +} satisfies PreloadApi['zcodePlanCredentials'] diff --git a/src/preload/index.ts b/src/preload/index.ts index 742a81681961..7848954a53ec 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -77,6 +77,7 @@ import { runtimeApi } from './api/runtime-bridge' import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' import { rateLimitsApi } from './api/rate-limits-bridge' import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' +import { zcodePlanCredentialsApi } from './api/zcode-plan-credentials-bridge' import { grokAccountsApi } from './api/grok-accounts-bridge' import { cursorAccountsApi } from './api/cursor-accounts-bridge' import { sshApi } from './api/ssh-bridge' @@ -177,6 +178,7 @@ const api = { runtimeEnvironments: runtimeEnvironmentsApi, rateLimits: rateLimitsApi, minimaxCredentials: minimaxCredentialsApi, + zcodePlanCredentials: zcodePlanCredentialsApi, grokAccounts: grokAccountsApi, cursorAccounts: cursorAccountsApi, ssh: sshApi, diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index a418aa9916ba..d1ac2b7de57b 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -23,7 +23,8 @@ import { getAccountsLocationSearchEntries, getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, - getAccountsPaneSearchEntries + getAccountsPaneSearchEntries, + getAccountsZcodePlanSearchEntries } from './accounts-search' import { getRemoteAccountsPaneScope } from './provider-account-scope' import { ProviderHostScopeControl } from './ProviderHostScopeControl' @@ -38,6 +39,7 @@ import { } from './provider-account-visibility' import { GrokAccountsSection } from './GrokAccountsSection' import { CursorAccountsSection } from './CursorAccountsSection' +import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection' import type { AccountsPaneProps, AccountsPaneSectionModel, @@ -384,6 +386,9 @@ export function AccountsPane({ ) : null, matchesSettingsSearch(searchQuery, getAccountsCursorSearchEntries()) ? ( + ) : null, + matchesSettingsSearch(searchQuery, getAccountsZcodePlanSearchEntries()) ? ( + ) : null ] diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx new file mode 100644 index 000000000000..1f856d297ffa --- /dev/null +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + saveApiKey: vi.fn(), + clearApiKey: vi.fn(), + refreshRateLimits: vi.fn(), + updateSettings: vi.fn(), + recordFeatureInteraction: vi.fn(), + zcodeUsage: vi.fn<() => unknown>(() => null), + settings: { + zcodePlanSite: 'zai' as 'zai' | 'bigmodel' + } +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => React.createElement('span', { 'data-testid': 'zcode-icon' }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + refreshRateLimits: mocks.refreshRateLimits, + updateSettings: mocks.updateSettings, + recordFeatureInteraction: mocks.recordFeatureInteraction, + settingsSearchQuery: '', + settings: mocks.settings, + rateLimits: { zcode: mocks.zcodeUsage() } + }) +})) + +import { ZcodePlanAccountsSection } from './ZcodePlanAccountsSection' + +describe('ZcodePlanAccountsSection', () => { + beforeEach(() => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: false }) + mocks.saveApiKey.mockResolvedValue({ apiKeyConfigured: true }) + mocks.clearApiKey.mockResolvedValue({ apiKeyConfigured: false }) + mocks.refreshRateLimits.mockResolvedValue(undefined) + mocks.updateSettings.mockResolvedValue(undefined) + mocks.recordFeatureInteraction.mockReset() + mocks.zcodeUsage.mockReturnValue(null) + mocks.settings.zcodePlanSite = 'zai' + Object.defineProperty(window, 'api', { + configurable: true, + value: { + zcodePlanCredentials: { + getStatus: mocks.getStatus, + saveApiKey: mocks.saveApiKey, + clearApiKey: mocks.clearApiKey + } + } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('shows the unlinked state when neither an API key nor a CLI config exists', async () => { + render() + + expect(await screen.findByText('No GLM Coding Plan linked')).toBeInTheDocument() + expect(screen.queryByText('Using the ZCode CLI sign-in')).not.toBeInTheDocument() + }) + + it('explains the CLI fallback when only the ZCode CLI config exists', async () => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: false, zcodeCliConfigured: true }) + + render() + + expect(await screen.findByText('Using the ZCode CLI sign-in')).toBeInTheDocument() + expect(screen.getByText(/~\/\.zcode\/cli\/config\.json/)).toBeInTheDocument() + }) + + it('saves a trimmed API key through the credential IPC', async () => { + render() + + const input = await screen.findByPlaceholderText('Paste your GLM Coding Plan API key') + fireEvent.change(input, { target: { value: ' glm-secret ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mocks.saveApiKey).toHaveBeenCalledWith('glm-secret') + }) + expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking') + await screen.findByText('Replace') + }) + + it('requires a non-empty key before saving', async () => { + render() + + const save = await screen.findByRole('button', { name: 'Save' }) + expect(save).toBeDisabled() + }) + + it('forgets a saved key through the credential IPC', async () => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: true }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Forget key' })) + + await waitFor(() => { + expect(mocks.clearApiKey).toHaveBeenCalledTimes(1) + }) + expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking') + }) + + it('labels the saved state with the selected site', async () => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false }) + mocks.settings.zcodePlanSite = 'bigmodel' + + render() + + expect( + await screen.findByText('API key saved · Zhipu · BigModel (open.bigmodel.cn)') + ).toBeInTheDocument() + }) + + it('renders the live quota windows for a linked plan', async () => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false }) + mocks.zcodeUsage.mockReturnValue({ + provider: 'zcode', + status: 'ok', + error: null, + planType: 'max', + session: { usedPercent: 42, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 73, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + monthly: null, + updatedAt: Date.now() + }) + + render() + + expect(await screen.findByText('42%')).toBeInTheDocument() + expect(screen.getByText('73%')).toBeInTheDocument() + expect(screen.getByText('Plan: max')).toBeInTheDocument() + }) + + it('renders the reset countdown once, not doubled', async () => { + mocks.getStatus.mockResolvedValue({ apiKeyConfigured: true, zcodeCliConfigured: false }) + mocks.zcodeUsage.mockReturnValue({ + provider: 'zcode', + status: 'ok', + error: null, + planType: null, + // Why 47.5 minutes: the floor survives a minute-boundary crossing between + // mock setup and render, so the assertion stays deterministic. + session: { + usedPercent: 42, + windowMinutes: 300, + resetsAt: Date.now() + 47 * 60_000 + 30_000, + resetDescription: null + }, + weekly: null, + monthly: null, + updatedAt: Date.now() + }) + + render() + + expect(await screen.findByText('42%')).toBeInTheDocument() + expect(screen.getByText(/5 hours — resets in 47m/)).toBeInTheDocument() + expect(screen.queryByText(/Resets in Resets/)).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx new file mode 100644 index 000000000000..cf4ef049cc9b --- /dev/null +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx @@ -0,0 +1,410 @@ +import { useCallback, useEffect, useState } from 'react' +import { ExternalLink, Loader2, Lock, LockOpen, RefreshCw, ShieldCheck } from 'lucide-react' +import { toast } from 'sonner' +import { AgentIcon } from '@/lib/agent-catalog' +import { translate } from '@/i18n/i18n' +import { + ZCODE_PLAN_SITE_CONSOLE_URLS, + type ZcodePlanSite +} from '../../../../shared/zcode-plan-sites' +import { cn } from '@/lib/utils' +import { useAppStore } from '../../store' +import { useNow } from '../../hooks/use-now' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SearchableSetting } from './SearchableSetting' +import { collectZcodeUsageWindows, ZcodeUsageWindowView } from './zcode-plan-usage-windows' + +const SEARCH_KEYWORDS = [ + 'glm', + 'zai', + 'z.ai', + 'zhipu', + 'bigmodel', + 'coding plan', + 'usage', + 'rate limit', + 'zcode' +] + +type ZcodePlanStatus = { + apiKeyConfigured: boolean + zcodeCliConfigured: boolean +} + +function siteLabel(site: ZcodePlanSite): string { + if (site === 'bigmodel') { + return translate( + 'auto.components.settings.ZcodePlanAccountsSection.site.bigmodel', + 'Zhipu · BigModel (open.bigmodel.cn)' + ) + } + return translate('auto.components.settings.ZcodePlanAccountsSection.site.zai', 'Z.AI (z.ai)') +} + +export function ZcodePlanAccountsSection(): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const updateSettings = useAppStore((s) => s.updateSettings) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) + const zcodeUsage = useAppStore((s) => s.rateLimits.zcode) + const [status, setStatus] = useState(null) + const [apiKeyDraft, setApiKeyDraft] = useState('') + const [credentialBusy, setCredentialBusy] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const now = useNow(60_000) + + const loadStatus = useCallback(async (): Promise => { + try { + setStatus(await window.api.zcodePlanCredentials.getStatus()) + } catch (error) { + console.error('Failed to load GLM Coding Plan credential status:', error) + setStatus({ apiKeyConfigured: false, zcodeCliConfigured: false }) + } + }, []) + + // Why: a usage refresh can change what the credential-status box should say + // (the CLI config appears or disappears), so reload after each snapshot. + useEffect(() => { + void loadStatus() + }, [loadStatus, zcodeUsage?.updatedAt]) + + const site = settings?.zcodePlanSite ?? 'zai' + const consoleUrl = ZCODE_PLAN_SITE_CONSOLE_URLS[site] + const apiKeyConfigured = status?.apiKeyConfigured === true + + const handleSiteChange = (value: string): void => { + if ((value !== 'zai' && value !== 'bigmodel') || value === site) { + return + } + recordFeatureInteraction('usage-tracking') + // Why: main invalidates and refreshes on this settings change, so no local refresh is needed. + void updateSettings({ zcodePlanSite: value }) + } + + const saveApiKey = async (): Promise => { + if (!apiKeyDraft.trim()) { + toast.error( + translate( + 'auto.components.settings.ZcodePlanAccountsSection.keyRequired', + 'GLM Coding Plan API key is required.' + ) + ) + return + } + setCredentialBusy(true) + try { + const next = await window.api.zcodePlanCredentials.saveApiKey(apiKeyDraft.trim()) + setStatus((previous) => ({ + apiKeyConfigured: next.apiKeyConfigured, + zcodeCliConfigured: previous?.zcodeCliConfigured ?? false + })) + setApiKeyDraft('') + recordFeatureInteraction('usage-tracking') + toast.success( + translate( + 'auto.components.settings.ZcodePlanAccountsSection.keySaved', + 'GLM Coding Plan API key saved.' + ) + ) + } catch (error) { + toast.error( + translate( + 'auto.components.settings.ZcodePlanAccountsSection.keySaveFailed', + 'GLM Coding Plan credential update failed.' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + } finally { + setCredentialBusy(false) + } + } + + const clearApiKey = async (): Promise => { + setCredentialBusy(true) + try { + const next = await window.api.zcodePlanCredentials.clearApiKey() + setStatus((previous) => ({ + apiKeyConfigured: next.apiKeyConfigured, + zcodeCliConfigured: previous?.zcodeCliConfigured ?? false + })) + setApiKeyDraft('') + recordFeatureInteraction('usage-tracking') + } catch (error) { + toast.error( + translate( + 'auto.components.settings.ZcodePlanAccountsSection.keySaveFailed', + 'GLM Coding Plan credential update failed.' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + } finally { + setCredentialBusy(false) + } + } + + const handleRefreshUsage = async (): Promise => { + setRefreshing(true) + try { + await refreshRateLimits() + } finally { + setRefreshing(false) + } + } + + const usage = zcodeUsage ?? null + const usageWindows = collectZcodeUsageWindows(usage) + const staleUsageError = usage?.status === 'error' ? (usage.error ?? null) : null + + return ( +
+
+
+

+ + {translate( + 'auto.components.settings.ZcodePlanAccountsSection.title', + 'GLM Coding Plan' + )} +

+

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.subtitle', + 'Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage in the status bar. Save the plan API key here — no ZCode CLI setup needed.' + )} +

+
+ + {translate( + 'auto.components.settings.ZcodePlanAccountsSection.consoleLink', + 'Get API key' + )} + + +
+ +
+ +
+ {apiKeyConfigured ? ( + <> +

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.keyStored', + 'API key saved · {{value0}}', + { value0: siteLabel(site) } + )} +

+

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.keyStoredHelp', + 'Stored locally and sent only to the selected site for usage refreshes. It takes priority over the ZCode CLI sign-in.' + )} +

+ + ) : status?.zcodeCliConfigured ? ( + <> +

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.usingCli', + 'Using the ZCode CLI sign-in' + )} +

+

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.usingCliHelp', + 'Orca reads the Coding Plan key from ~/.zcode/cli/config.json. Save an API key below to link the plan here instead.' + )} +

+ + ) : ( + <> +

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.notConfigured', + 'No GLM Coding Plan linked' + )} +

+

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.notConfiguredHelp', + 'Save the plan API key below, or sign in with the ZCode CLI on this computer.' + )} +

+ + )} + {staleUsageError ?

{staleUsageError}

: null} +
+ +
+ + + + + + + +
+
+ + + {apiKeyConfigured ? : } + {apiKeyConfigured + ? translate('auto.components.settings.ZcodePlanAccountsSection.saved', 'Saved') + : translate( + 'auto.components.settings.ZcodePlanAccountsSection.notSaved', + 'Not saved' + )} + +
+
+
+ setApiKeyDraft(e.target.value)} + placeholder={translate( + 'auto.components.settings.ZcodePlanAccountsSection.keyPlaceholder', + 'Paste your GLM Coding Plan API key' + )} + spellCheck={false} + className="flex-1 text-xs" + /> + + {apiKeyConfigured ? ( + + ) : null} +
+

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.keyHelp', + 'The same key your coding tools use for the plan (for example Claude Code with ANTHROPIC_BASE_URL pointed at the site). Switching the site above changes which host receives it.' + )} +

+
+ + {usageWindows.length > 0 ? ( + +
+ {usageWindows.map((row) => ( + + ))} + {usage?.planType ? ( +

+ {translate( + 'auto.components.settings.ZcodePlanAccountsSection.planLevel', + 'Plan: {{value0}}', + { value0: usage.planType } + )} +

+ ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 60fa795caa99..405f51196135 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -249,6 +249,36 @@ export const getAccountsCursorSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getAccountsZcodePlanSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.accounts.search.zcodePlan.title', 'GLM Coding Plan'), + description: translate( + 'auto.components.settings.accounts.search.zcodePlan.description', + 'Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage. Pick the site and save the plan API key.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.zcodePlan.kw.glm', 'glm'), + ...translateSearchKeyword('auto.components.settings.accounts.search.zcodePlan.kw.zai', 'zai'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.zcodePlan.kw.zhipu', + 'zhipu' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.zcodePlan.kw.bigmodel', + 'bigmodel' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.zcodePlan.kw.codingPlan', + 'coding plan' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.zcodePlan.kw.rateLimit', + 'rate limit' + ) + ] + } +]) + export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ ...getAccountsLocationSearchEntries(), ...getAccountsClaudeSearchEntries(), @@ -257,5 +287,6 @@ export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsS ...getAccountsOpencodeSearchEntries(), ...getAccountsMiniMaxSearchEntries(), ...getAccountsGrokSearchEntries(), - ...getAccountsCursorSearchEntries() + ...getAccountsCursorSearchEntries(), + ...getAccountsZcodePlanSearchEntries() ]) diff --git a/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx b/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx new file mode 100644 index 000000000000..6701ee44adaa --- /dev/null +++ b/src/renderer/src/components/settings/zcode-plan-usage-windows.tsx @@ -0,0 +1,72 @@ +import { translate } from '@/i18n/i18n' +import { formatResetDuration } from '../../../../shared/rate-limit-reset-format' +import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' +import { Badge } from '../ui/badge' + +export type ZcodeUsageWindowKind = 'session' | 'weekly' | 'monthly' + +export type ZcodeUsageWindowRow = { + kind: ZcodeUsageWindowKind + window: RateLimitWindow +} + +function windowLabel(kind: ZcodeUsageWindowKind): string { + if (kind === 'session') { + return translate('auto.components.settings.ZcodePlanAccountsSection.window.session', '5 hours') + } + if (kind === 'weekly') { + return translate('auto.components.settings.ZcodePlanAccountsSection.window.weekly', 'Weekly') + } + return translate('auto.components.settings.ZcodePlanAccountsSection.window.mcp', 'MCP monthly') +} + +function formatWindowReset(window: RateLimitWindow, now: number): string | null { + if (!window.resetsAt) { + return null + } + const remaining = window.resetsAt - now + return remaining > 0 ? formatResetDuration(remaining) : null +} + +// Why: a window only renders when its data survived the fetcher's mapping, so +// error snapshots and MCP-less plans show exactly the windows they reported. +export function collectZcodeUsageWindows(usage: ProviderRateLimits | null): ZcodeUsageWindowRow[] { + const rows: ZcodeUsageWindowRow[] = [] + if (usage?.session) { + rows.push({ kind: 'session', window: usage.session }) + } + if (usage?.weekly) { + rows.push({ kind: 'weekly', window: usage.weekly }) + } + if (usage?.monthly) { + rows.push({ kind: 'monthly', window: usage.monthly }) + } + return rows +} + +export function ZcodeUsageWindowView({ + row, + now +}: { + row: ZcodeUsageWindowRow + now: number +}): React.JSX.Element { + const resetLabel = formatWindowReset(row.window, now) + return ( +
+ + {Math.round(row.window.usedPercent)}% + + + {windowLabel(row.kind)} + {resetLabel + ? translate( + 'auto.components.settings.ZcodePlanAccountsSection.resetIn', + ' — resets in {{value0}}', + { value0: resetLabel } + ) + : ''} + +
+ ) +} 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 987bb91c040e..7d56063d9007 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 @@ -78,6 +78,7 @@ function usageSettings(overrides: Partial = {}): UsagePro opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, cursorAuthConfigured: false, + zcodePlanApiKeyConfigured: false, ...overrides } } 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 24392aa48ab3..72e2b500096d 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 @@ -17,6 +17,9 @@ export type UsageProviderSettings = Pick< // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean minimaxApiKeyConfigured: boolean + // Why: the GLM Coding Plan key lives in its own safeStorage file; main + // reports presence so the ZCode bar survives reloads before the first poll. + zcodePlanApiKeyConfigured: boolean // Why: the OpenCode Go key can live in OPENCODE_API_KEY or in OpenCode's own // store, neither of which the renderer can see; main reports presence. opencodeGoApiKeyConfigured: boolean @@ -87,7 +90,8 @@ export function hasUsageProviderSettings( settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || settings?.grokAuthConfigured === true || - settings?.cursorAuthConfigured === true + settings?.cursorAuthConfigured === true || + settings?.zcodePlanApiKeyConfigured === true ) } @@ -128,6 +132,9 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'cursor') { return settings.cursorAuthConfigured === true } + if (providerId === 'zcode') { + return settings.zcodePlanApiKeyConfigured === true + } return false } 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 b531bffd161e..6da92332d443 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,9 +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 + case 'zcode': + return 'accounts-zcode' } } 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 8ec0bcfce437..bd7b9aa4258a 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 @@ -108,7 +108,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { minimaxApiKeyConfigured: rateLimits.minimaxApiKeyConfigured, opencodeGoApiKeyConfigured: rateLimits.opencodeGoApiKeyConfigured, grokAuthConfigured: rateLimits.grokAuthConfigured, - cursorAuthConfigured: rateLimits.cursorAuthConfigured + cursorAuthConfigured: rateLimits.cursorAuthConfigured, + zcodePlanApiKeyConfigured: rateLimits.zcodePlanApiKeyConfigured } const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) @@ -148,10 +149,12 @@ 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') + // Why: a saved Coding Plan key is site-auth, not a CLI on PATH — a subscriber + // without the ZCode CLI must still earn the meter (same exemption as MiniMax/Cursor). const showZcode = visibleZcode !== null && statusBarItems.includes('zcode') && - isStatusBarItemAvailable('zcode', detectedAgentIds) + (rateLimits.zcodePlanApiKeyConfigured || 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') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cbbba795d593..bc4af5edc5df 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3278,7 +3278,7 @@ "missing": { "tui": { "title": "This ZCode build has no terminal UI", - "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + "description": "Orca's hooks installed correctly — the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." } } }, @@ -3977,7 +3977,9 @@ } }, "tooltip": { - "zcode": { "mcp": "MCP" }, + "zcode": { + "mcp": "MCP" + }, "cedb7b99e3": "% used", "6d6df77f41": "No data available", "7f7f208060": "Monthly", @@ -9119,6 +9121,18 @@ "rateLimit": "rate limit", "statusBar": "status bar" } + }, + "zcodePlan": { + "title": "GLM Coding Plan", + "description": "Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage. Pick the site and save the plan API key.", + "kw": { + "glm": "glm", + "zai": "zai", + "zhipu": "zhipu", + "bigmodel": "bigmodel", + "codingPlan": "coding plan", + "rateLimit": "rate limit" + } } } }, @@ -12223,6 +12237,45 @@ "usageLabel": "Usage", "noAllowance": "Cursor reported no usage allowance for this account.", "staleUsage": "Last known usage — the latest refresh failed: {{reason}}" + }, + "ZcodePlanAccountsSection": { + "site": { + "bigmodel": "Zhipu · BigModel (open.bigmodel.cn)", + "zai": "Z.AI (z.ai)" + }, + "window": { + "session": "5 hours", + "weekly": "Weekly", + "mcp": "MCP monthly" + }, + "resetIn": " — resets in {{value0}}", + "keyRequired": "GLM Coding Plan API key is required.", + "keySaved": "GLM Coding Plan API key saved.", + "keySaveFailed": "GLM Coding Plan credential update failed.", + "title": "GLM Coding Plan", + "subtitle": "Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage in the status bar. Save the plan API key here — no ZCode CLI setup needed.", + "consoleLink": "Get API key", + "keyStored": "API key saved · {{value0}}", + "keyStoredHelp": "Stored locally and sent only to the selected site for usage refreshes. It takes priority over the ZCode CLI sign-in.", + "usingCli": "Using the ZCode CLI sign-in", + "usingCliHelp": "Orca reads the Coding Plan key from ~/.zcode/cli/config.json. Save an API key below to link the plan here instead.", + "notConfigured": "No GLM Coding Plan linked", + "notConfiguredHelp": "Save the plan API key below, or sign in with the ZCode CLI on this computer.", + "refreshUsage": "Refresh usage", + "siteTitle": "Plan site", + "siteDescription": "Pick the console your Coding Plan belongs to: Z.AI for the international site, Zhipu BigModel for the mainland site.", + "keyTitle": "API key", + "keyDescription": "Paste the API key from the selected console’s API Keys page. Stored locally, encrypted when the OS supports it, and sent only to that site for usage refreshes.", + "saved": "Saved", + "notSaved": "Not saved", + "keyPlaceholder": "Paste your GLM Coding Plan API key", + "replace": "Replace", + "save": "Save", + "forgetKey": "Forget key", + "keyHelp": "The same key your coding tools use for the plan (for example Claude Code with ANTHROPIC_BASE_URL pointed at the site). Switching the site above changes which host receives it.", + "usageTitle": "Plan usage", + "usageDescription": "Live quota windows for the linked Coding Plan, refreshed with the status bar usage cycle.", + "planLevel": "Plan: {{value0}}" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 47fefd2f0e36..2e51c484393e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8895,6 +8895,18 @@ "usage": "用量" }, "title": "Cursor 用量" + }, + "zcodePlan": { + "title": "GLM Coding Plan 用量", + "description": "追踪 Z.AI 或智谱(BigModel)GLM Coding Plan 的用量。选择站点并保存套餐 API key。", + "kw": { + "glm": "glm", + "zai": "zai", + "zhipu": "zhipu", + "bigmodel": "bigmodel", + "codingPlan": "coding plan", + "rateLimit": "rate limit" + } } } }, @@ -12056,6 +12068,45 @@ "usageDescription": "Cursor 按两个随账单周期重置的额度池计费,用完后按需计费。", "usageLabel": "用量", "usageTitle": "每月套餐用量" + }, + "ZcodePlanAccountsSection": { + "site": { + "bigmodel": "智谱 · BigModel(open.bigmodel.cn)", + "zai": "Z.AI(z.ai)" + }, + "window": { + "session": "5 小时", + "weekly": "每周", + "mcp": "MCP 月度" + }, + "resetIn": " — {{value0}}后重置", + "keyRequired": "请填写 GLM Coding Plan API key。", + "keySaved": "GLM Coding Plan API key 已保存。", + "keySaveFailed": "GLM Coding Plan 凭据更新失败。", + "title": "GLM Coding Plan", + "subtitle": "在状态栏追踪 Z.AI 或智谱(BigModel)GLM Coding Plan 用量。在此保存套餐 API key,无需安装 ZCode CLI。", + "consoleLink": "获取 API key", + "keyStored": "API key 已保存 · {{value0}}", + "keyStoredHelp": "仅保存在本地,并只发送到所选站点用于用量刷新。优先级高于 ZCode CLI 登录。", + "usingCli": "正在使用 ZCode CLI 登录", + "usingCliHelp": "Orca 从 ~/.zcode/cli/config.json 读取 Coding Plan key。在下方保存 API key 即可改为在此关联套餐。", + "notConfigured": "尚未关联 GLM Coding Plan", + "notConfiguredHelp": "在下方保存套餐 API key,或在这台电脑上通过 ZCode CLI 登录。", + "refreshUsage": "刷新用量", + "siteTitle": "套餐站点", + "siteDescription": "选择你的 Coding Plan 所属控制台:国际站选 Z.AI,国内站选智谱 BigModel。", + "keyTitle": "API key", + "keyDescription": "粘贴所选控制台 API Keys 页面中的 API key。保存在本地,系统支持时加密存储,并只发送到该站点用于用量刷新。", + "saved": "已保存", + "notSaved": "未保存", + "keyPlaceholder": "粘贴你的 GLM Coding Plan API key", + "replace": "替换", + "save": "保存", + "forgetKey": "清除 key", + "keyHelp": "与编码工具使用同一把套餐 key(例如 Claude Code 将 ANTHROPIC_BASE_URL 指向对应站点)。切换上方站点会变更接收该 key 的主机。", + "usageTitle": "套餐用量", + "usageDescription": "已关联 Coding Plan 的实时配额窗口,随状态栏用量周期刷新。", + "planLevel": "套餐:{{value0}}" } }, "right": { diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 2fd16df1c4f0..e5ec7bed28ea 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -214,6 +214,7 @@ export function buildDefaultSettings(args: { minimaxGroupId: '', minimaxUsageModels: 'general', minimaxEndpoint: 'overseas', + zcodePlanSite: 'zai', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 1e0410f4c227..b1f2af1d1372 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -26,6 +26,7 @@ import type { CtrlTabOrderMode } from './tab-types' import type { TerminalColorOverrides } from './terminal-color-overrides' import type { TerminalQuickCommand } from './terminal-quick-command-types' import type { TuiAgent } from './tui-agent' +import type { ZcodePlanSite } from './zcode-plan-sites' import type { AgentDashboardMode, BranchPrefixStrategy, @@ -397,6 +398,8 @@ export type GlobalSettings = { minimaxUsageModels: string /** MiniMax account region; defaults to overseas for existing users. */ minimaxEndpoint: MiniMaxEndpoint + /** GLM Coding Plan site whose API key is saved in AI Provider Accounts; defaults to the international Z.AI console. */ + zcodePlanSite: ZcodePlanSite /** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */ geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index 494f56e4dff1..b9af7fe1b68c 100644 --- a/src/shared/rate-limit-state-factory.ts +++ b/src/shared/rate-limit-state-factory.ts @@ -18,6 +18,7 @@ export function createEmptyRateLimitState(overrides: Partial = { opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, cursorAuthConfigured: false, + zcodePlanApiKeyConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index 556af5785659..3e10a7c9e79b 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -23,6 +23,7 @@ describe('RateLimitState', () => { opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, cursorAuthConfigured: false, + zcodePlanApiKeyConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 6b06f09d10e9..e18ca57288fd 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -170,6 +170,12 @@ export type RateLimitState = { * stored login. The token itself never leaves main. */ cursorAuthConfigured: boolean + /** + * True when a GLM Coding Plan API key is saved in Orca's AI Provider + * Accounts. The key itself never leaves main; the status bar uses this to + * keep the ZCode bar visible across reloads between snapshot refreshes. + */ + zcodePlanApiKeyConfigured: boolean claudeTarget: RateLimitRuntimeTarget codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] diff --git a/src/shared/rpc-contract/client-settings-params.ts b/src/shared/rpc-contract/client-settings-params.ts index fd0c2f986a93..6574c89a8bb7 100644 --- a/src/shared/rpc-contract/client-settings-params.ts +++ b/src/shared/rpc-contract/client-settings-params.ts @@ -115,6 +115,7 @@ export const SettingsUpdate = z minimaxGroupId: z.string().optional(), minimaxUsageModels: z.string().optional(), minimaxEndpoint: z.enum(['overseas', 'cn']).optional(), + zcodePlanSite: z.enum(['zai', 'bigmodel']).optional(), githubProjects: GitHubProjectSettings.optional(), prBotAuthorOverrides: z .unknown() diff --git a/src/shared/zcode-plan-sites.ts b/src/shared/zcode-plan-sites.ts new file mode 100644 index 000000000000..0860d1de4cf7 --- /dev/null +++ b/src/shared/zcode-plan-sites.ts @@ -0,0 +1,23 @@ +/** + * GLM Coding Plan site table shared by the zcode credential store, the usage + * fetcher, and the Accounts settings section. The provider id stays `zcode`; + * the two sites are the international Z.AI console and Zhipu's mainland + * BigModel platform. + */ +export type ZcodePlanSite = 'zai' | 'bigmodel' + +export const ZCODE_PLAN_SITES: readonly ZcodePlanSite[] = ['zai', 'bigmodel'] + +export function isZcodePlanSite(value: unknown): value is ZcodePlanSite { + return value === 'zai' || value === 'bigmodel' +} + +export const ZCODE_PLAN_SITE_BASE_URLS: Record = { + zai: 'https://api.z.ai', + bigmodel: 'https://open.bigmodel.cn' +} + +export const ZCODE_PLAN_SITE_CONSOLE_URLS: Record = { + zai: 'https://z.ai/manage-apikey', + bigmodel: 'https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys' +}