Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const {
registerAgentTrustHandlersMock,
registerClaudeAccountHandlersMock,
registerMiniMaxCredentialsHandlersMock,
registerZcodePlanCredentialsHandlersMock,
registerGrokAccountHandlersMock,
registerCursorAccountHandlersMock,
registerClipboardHandlersMock,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -342,6 +344,10 @@ vi.mock('../minimax-credentials', () => ({
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
}))

vi.mock('../zcode-plan-credentials', () => ({
registerZcodePlanCredentialsHandlers: registerZcodePlanCredentialsHandlersMock
}))

vi.mock('../grok-accounts', () => ({
registerGrokAccountHandlers: registerGrokAccountHandlersMock
}))
Expand Down Expand Up @@ -441,6 +447,7 @@ describe('registerCoreHandlers', () => {
registerAgentTrustHandlersMock.mockReset()
registerClaudeAccountHandlersMock.mockReset()
registerMiniMaxCredentialsHandlersMock.mockReset()
registerZcodePlanCredentialsHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
setTrustedClipboardRendererWebContentsIdMock.mockReset()
registerUpdaterHandlersMock.mockReset()
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/main/ipc/register-core-handlers/register-core-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -152,6 +153,7 @@ export function registerCoreHandlers(
registerAgentTrustHandlers()
registerClaudeAccountHandlers(claudeAccounts)
registerMiniMaxCredentialsHandlers(rateLimits)
registerZcodePlanCredentialsHandlers(rateLimits)
registerGrokAccountHandlers()
registerCursorAccountHandlers()
registerRateLimitHandlers(rateLimits, codexAccounts)
Expand Down
51 changes: 51 additions & 0 deletions src/main/ipc/zcode-plan-credentials.ts
Original file line number Diff line number Diff line change
@@ -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()
})
}
186 changes: 186 additions & 0 deletions src/main/rate-limits/service-zcode-usage.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderRateLimits>()
const secondZcode = deferred<ProviderRateLimits>()
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)
})
})
9 changes: 9 additions & 0 deletions src/main/rate-limits/service/service-account-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/main/rate-limits/service/service-configuration.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +11,7 @@ import {
type ClaudeAuthPreparationResolver,
type OpenCodeGoRateLimitConfig,
type MiniMaxRateLimitConfig,
type ZcodePlanRateLimitConfig,
type GeminiCliOAuthEnabledResolver,
type InactiveCodexAccountInfo,
type InactiveClaudeAccountInfo,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src/main/rate-limits/service/service-fetch-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions src/main/rate-limits/service/service-fetch-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ClaudeRuntimeAuthPreparation,
type CodexAccountSelectionTarget,
type MiniMaxResolvedConfig,
type ZcodePlanResolvedConfig,
type NormalizedCodexAccountSelectionTarget,
type NormalizedClaudeAccountSelectionTarget,
type ProviderRateLimits,
Expand Down Expand Up @@ -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) }
}
}
}
Loading
Loading