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
40 changes: 40 additions & 0 deletions src/main/rate-limits/codex-fetcher-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,46 @@ describe('Codex backend rate-limit requests', () => {
)
})

it('classifies a sole seven-day backend primary window as weekly', async () => {
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'access-token', account_id: 'account-id' }
})
)
vi.mocked(fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
plan_type: 'plus',
rate_limit: {
primary_window: {
used_percent: 37,
limit_window_seconds: 7 * 24 * 60 * 60,
reset_at: 1_800_000_000
},
secondary_window: null
}
})
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ available_count: 0, credits: [] })
} as Response)

await expect(
fetchCodexRateLimits({
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
})
).resolves.toMatchObject({
session: null,
weekly: { usedPercent: 37, windowMinutes: 10_080, resetsAt: 1_800_000_000_000 },
status: 'ok'
})

expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})

it('aborts callers while sharing one stalled backend auth read', async () => {
let resolveRead!: (content: string) => void
readFileMock.mockImplementation(
Expand Down
174 changes: 174 additions & 0 deletions src/main/rate-limits/codex-fetcher-session-supplement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { EventEmitter } from 'node:events'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'

const { childSpawnMock, readFileMock } = vi.hoisted(() => ({
childSpawnMock: vi.fn(),
readFileMock: vi.fn()
}))

vi.mock('node:child_process', () => ({ spawn: childSpawnMock }))
vi.mock('node:fs/promises', () => ({ readFile: readFileMock }))
vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
vi.mock('node-pty', () => ({ spawn: vi.fn() }))
vi.mock('./codex-auth-presence', () => ({
probeCodexAuthPresence: vi.fn(async () => 'present')
}))

import { fetchCodexRateLimits } from './codex-fetcher'

function makeRpcChild(rateLimitResetCredits?: unknown) {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
stdin: { write: ReturnType<typeof vi.fn> }
kill: ReturnType<typeof vi.fn>
}
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = vi.fn()
child.stdin = {
write: vi.fn((line: string) => {
const message = JSON.parse(line) as { id?: number; method?: string }
if (message.method === 'initialize') {
setTimeout(() => {
child.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`)
)
}, 0)
}
if (message.method === 'account/rateLimits/read') {
setTimeout(() => {
child.stdout.emit(
'data',
Buffer.from(
`${JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: {
rateLimits: {
primary: { usedPercent: 22, windowDurationMins: 10_080 }
},
...(rateLimitResetCredits !== undefined ? { rateLimitResetCredits } : {})
}
})}\n`
)
)
}, 0)
}
})
}
return child
}

function usageResponse(rateLimitResetCredits?: unknown): Response {
return {
ok: true,
json: async () => ({
plan_type: 'pro',
rate_limit: {
primary_window: {
used_percent: 23,
limit_window_seconds: 7 * 24 * 60 * 60
}
},
...(rateLimitResetCredits !== undefined
? { rate_limit_reset_credits: rateLimitResetCredits }
: {})
})
} as Response
}

function dedicatedCreditsResponse(): Response {
return {
ok: true,
json: async () => ({
available_count: 2,
credits: [
{
status: 'available',
expires_at: '2027-01-15T12:00:00Z',
granted_at: '2027-01-08T12:00:00Z'
}
]
})
} as Response
}

async function fetchWeeklyOnly(rateLimitResetCredits?: unknown) {
childSpawnMock.mockReturnValue(makeRpcChild(rateLimitResetCredits))
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
return resultPromise
}

describe('Codex backend session supplement credits', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'access-token', account_id: 'account-id' }
})
)
vi.stubGlobal('fetch', vi.fn())
})

afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})

it('reuses complete zero-credit metadata from a weekly-only usage response', async () => {
vi.mocked(fetch).mockResolvedValue(usageResponse({ available_count: 0 }))

await expect(fetchWeeklyOnly()).resolves.toMatchObject({
session: null,
weekly: { usedPercent: 22, windowMinutes: 10_080 },
rateLimitResetCredits: { availableCount: 0, nextExpiresAt: null }
})
expect(fetch).toHaveBeenCalledTimes(1)
})

it.each([
{ name: 'null', credits: null },
{ name: 'invalid', credits: {} }
])('preserves complete RPC credits when usage metadata is $name', async ({ credits }) => {
vi.mocked(fetch).mockResolvedValue(usageResponse(credits))

await expect(
fetchWeeklyOnly({
availableCount: 1,
nextExpiresAt: 1_800_000_000
})
).resolves.toMatchObject({
rateLimitResetCredits: {
availableCount: 1,
nextExpiresAt: 1_800_000_000_000
}
})
expect(fetch).toHaveBeenCalledTimes(1)
})

it.each([
{ name: 'absent', credits: undefined },
{ name: 'incomplete', credits: { available_count: 2 } }
])('uses the dedicated credits endpoint when usage metadata is $name', async ({ credits }) => {
vi.mocked(fetch)
.mockResolvedValueOnce(usageResponse(credits))
.mockResolvedValueOnce(dedicatedCreditsResponse())

await expect(fetchWeeklyOnly()).resolves.toMatchObject({
rateLimitResetCredits: {
availableCount: 2,
nextExpiresAt: Date.parse('2027-01-15T12:00:00Z')
}
})
expect(fetch).toHaveBeenCalledTimes(2)
expect(vi.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
'https://chatgpt.com/backend-api/wham/usage',
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits'
])
})
})
47 changes: 47 additions & 0 deletions src/main/rate-limits/codex-fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,53 @@ describe('fetchCodexRateLimits', () => {
})
})

it('fills a restored backend 5-hour window when RPC only reports weekly usage', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
respondToRpcRateLimitRead(rpcChild, {
primary: { usedPercent: 22, windowDurationMins: 10080 },
secondary: null
})
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'access-token', account_id: 'account-id' }
})
)
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({
plan_type: 'plus',
rate_limit: {
primary_window: {
used_percent: 7,
limit_window_seconds: 5 * 60 * 60,
reset_at: 1_800_000_000
},
secondary_window: {
used_percent: 23,
limit_window_seconds: 7 * 24 * 60 * 60,
reset_at: 1_800_100_000
}
},
rate_limit_reset_credits: {
available_count: 1,
credits: [{ status: 'available', expires_at: '2027-01-15T12:00:00Z' }]
}
})
} as Response)

const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)

await expect(resultPromise).resolves.toMatchObject({
session: { usedPercent: 7, windowMinutes: 300, resetsAt: 1_800_000_000_000 },
weekly: { usedPercent: 23, windowMinutes: 10080, resetsAt: 1_800_100_000_000 },
status: 'ok'
})
expect(fetch).toHaveBeenCalledTimes(1)
})

it('does not map a duplicate session-duration window into the weekly slot', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
Expand Down
Loading