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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const {
registerAgentHookHandlersMock,
registerClaudeAccountHandlersMock,
registerMiniMaxCredentialsHandlersMock,
registerZcodePlanCredentialsHandlersMock,
registerGrokAccountHandlersMock,
registerCursorAccountHandlersMock,
registerClipboardHandlersMock,
Expand Down Expand Up @@ -103,6 +104,7 @@ const {
registerAgentHookHandlersMock: vi.fn(),
registerClaudeAccountHandlersMock: vi.fn(),
registerMiniMaxCredentialsHandlersMock: vi.fn(),
registerZcodePlanCredentialsHandlersMock: vi.fn(),
registerGrokAccountHandlersMock: vi.fn(),
registerCursorAccountHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
Expand Down Expand Up @@ -336,6 +338,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', () => {
registerAgentHookHandlersMock.mockReset()
registerClaudeAccountHandlersMock.mockReset()
registerMiniMaxCredentialsHandlersMock.mockReset()
registerZcodePlanCredentialsHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
setTrustedClipboardRendererWebContentsIdMock.mockReset()
registerUpdaterHandlersMock.mockReset()
Expand Down Expand Up @@ -540,6 +547,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 @@ -62,6 +62,7 @@ import { registerCodexConfigSyncHandlers } from '../codex-config-sync'
import { getPtyIdForPaneKey } from '../pty'
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 @@ -151,6 +152,7 @@ export function registerCoreHandlers(
registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService)
registerClaudeAccountHandlers(claudeAccounts)
registerMiniMaxCredentialsHandlers(rateLimits)
registerZcodePlanCredentialsHandlers(rateLimits)
registerGrokAccountHandlers()
registerCursorAccountHandlers()
registerRateLimitHandlers(rateLimits, codexAccounts)
Expand Down
68 changes: 68 additions & 0 deletions src/main/ipc/zcode-plan-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { registerZcodePlanCredentialsHandlers } from './zcode-plan-credentials'

const mocks = vi.hoisted(() => ({
handle: vi.fn(),
hasKey: vi.fn(() => false),
protection: vi.fn<() => 'sealed' | 'plaintext' | null>(() => null),
hasCli: vi.fn(() => false),
save: vi.fn(),
clear: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
vi.mock('../zcode/zcode-plan-api-key-store', () => ({
hasZcodePlanApiKey: mocks.hasKey,
getZcodePlanApiKeyProtection: mocks.protection,
saveZcodePlanApiKey: mocks.save,
clearZcodePlanApiKey: mocks.clear
}))
vi.mock('../rate-limits/zcode-usage-fetcher', () => ({ hasZcodeCliPlanCredentials: mocks.hasCli }))

function handler(channel: string) {
const registration = mocks.handle.mock.calls.find(([name]) => name === channel)
if (!registration) {
throw new Error('Handler missing')
}
return registration[1]
}

describe('GLM credential IPC', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.hasKey.mockReturnValue(false)
mocks.hasCli.mockReturnValue(false)
mocks.protection.mockReturnValue(null)
registerZcodePlanCredentialsHandlers(null)
})

it('returns only presence and protection without exposing the key', () => {
mocks.hasKey.mockReturnValue(true)
mocks.hasCli.mockReturnValue(true)
mocks.protection.mockReturnValue('sealed')
expect(handler('zcodePlanCredentials:getStatus')()).toEqual({
apiKeyConfigured: true,
zcodeCliConfigured: true,
apiKeyProtection: 'sealed'
})
})

it('validates an untyped IPC key before persistence', () => {
expect(() => handler('zcodePlanCredentials:saveApiKey')(null, 42)).toThrow('must be a string')
expect(mocks.save).not.toHaveBeenCalled()
})

it('saves and removes keys while returning status only', () => {
mocks.save.mockImplementationOnce(() => mocks.hasKey.mockReturnValue(true))
mocks.clear.mockImplementationOnce(() => mocks.hasKey.mockReturnValue(false))
expect(handler('zcodePlanCredentials:saveApiKey')(null, 'synthetic-key')).toEqual({
apiKeyConfigured: true,
zcodeCliConfigured: false,
apiKeyProtection: null
})
expect(handler('zcodePlanCredentials:clearApiKey')()).toEqual({
apiKeyConfigured: false,
zcodeCliConfigured: false,
apiKeyProtection: null
})
})
})
49 changes: 49 additions & 0 deletions src/main/ipc/zcode-plan-credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ipcMain } from 'electron'
import {
clearZcodePlanApiKey,
getZcodePlanApiKeyProtection,
hasZcodePlanApiKey,
saveZcodePlanApiKey
} from '../zcode/zcode-plan-api-key-store'
import { hasZcodeCliPlanCredentials } from '../rate-limits/zcode-usage-fetcher'
import type { RateLimitService } from '../rate-limits/service'
import type { ZcodePlanCredentialsStatus } from '../../shared/zcode-plan-sites'

function getZcodePlanCredentialsStatus(): ZcodePlanCredentialsStatus {
return {
apiKeyConfigured: hasZcodePlanApiKey(),
zcodeCliConfigured: hasZcodeCliPlanCredentials(),
apiKeyProtection: getZcodePlanApiKeyProtection()
}
}

// 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()
})
}
189 changes: 189 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,189 @@
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('./antigravity-usage-fetcher', () => ({
fetchAntigravityRateLimits: 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 vi.waitFor(() => expect(service.getState().claude?.status).toBe('ok'))

service.invalidateZcodeCredentialState()
const queuedRefresh = service.refresh()
await Promise.resolve()

firstZcode.resolve(okProvider('zcode', 50, Date.now()))
await vi.waitFor(() => expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(2))

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
Loading
Loading