fix(rate-limits): read Kimi usage credentials from the configured WSL runtime (#12475)
* fix(rate-limits): read Kimi usage credentials from the configured WSL runtime Kimi's usage fetch always read the Windows host's ~/.kimi-code, so a Kimi CLI running inside WSL rotated only the WSL-side token and the status bar was stuck on 'Run Kimi to refresh'. Resolve the Kimi home from the local-account runtime target (mirroring Codex's getDefaultWslDistro()/getWslHome() UNC pattern), pinned to host off Windows, and keep KIMI_CODE_HOME host-only. Fixes #12370 Co-authored-by: Orca <help@stably.ai> * fix(rate-limits): bound and offload the Kimi credentials read for WSL homes Adopted from @cengiz-io's #12372: read credentials through createAuthFilesystemOperation (async, per-path dedup, AbortSignal bound) so a stopped distro degrades to an error instead of parking Electron main on a UNC read. ENOTDIR joins ENOENT as "not signed in" to keep existsSync parity, and the WSL runtime target is now probed with the async wsl.exe helpers. Co-authored-by: Orca <help@stably.ai> * test(rate-limits): build Kimi credential-path expectations with path.join The host-home assertions hardcoded POSIX separators, so they only passed on a POSIX runner — on a Windows dev machine `join` emits backslashes and all four cases failed (three assertion mismatches plus a WSL-suite fixture whose map key never matched the path the fetcher read). --------- Co-authored-by: Orca <help@stably.ai> Co-authored-by: OrcaWin <alpha-eng@stably.ai>
This commit is contained in:
parent
2c865cda66
commit
597c84f36c
|
|
@ -183,6 +183,7 @@ import { RateLimitService } from './rate-limits/service'
|
|||
import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store'
|
||||
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
|
||||
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
|
||||
import { getKimiRuntimeTarget, resolveKimiHome } from './kimi/kimi-runtime-home'
|
||||
import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync'
|
||||
import {
|
||||
attachMainWindowServices,
|
||||
|
|
@ -2306,6 +2307,9 @@ void app.whenReady().then(async () => {
|
|||
codexRuntimeHome!.prepareForRateLimitFetch(target)
|
||||
)
|
||||
rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings()))
|
||||
// Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the
|
||||
// usage fetch must read the WSL-side credentials when that's the configured runtime (#12370).
|
||||
rateLimits.setKimiHomeResolver(() => resolveKimiHome(getKimiRuntimeTarget(store!.getSettings())))
|
||||
rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings()))
|
||||
const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync(
|
||||
rateLimits,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const wslMocks = vi.hoisted(() => ({
|
||||
listWslDistrosAsync: vi.fn<() => Promise<string[]>>(),
|
||||
getWslHomeAsync: vi.fn<(distro: string) => Promise<string | null>>()
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', () => wslMocks)
|
||||
vi.mock('node:os', () => ({ homedir: () => 'C:\\Users\\neil' }))
|
||||
|
||||
import { getHostKimiHome, getKimiRuntimeTarget, resolveKimiHome } from './kimi-runtime-home'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
|
||||
function settings(overrides: Partial<GlobalSettings>): GlobalSettings {
|
||||
return overrides as GlobalSettings
|
||||
}
|
||||
|
||||
describe('getKimiRuntimeTarget', () => {
|
||||
it('follows the configured WSL runtime on Windows', () => {
|
||||
expect(
|
||||
getKimiRuntimeTarget(
|
||||
settings({ localAccountRuntime: 'wsl', localAccountWslDistro: ' Ubuntu ' }),
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('pins to host off Windows even when the setting says wsl', () => {
|
||||
expect(
|
||||
getKimiRuntimeTarget(
|
||||
settings({ localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' }),
|
||||
'darwin'
|
||||
)
|
||||
).toEqual({ runtime: 'host', wslDistro: null })
|
||||
})
|
||||
|
||||
it('follows the Windows runtime default when the policy is auto', () => {
|
||||
expect(
|
||||
getKimiRuntimeTarget(
|
||||
settings({
|
||||
localAccountRuntime: 'auto',
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' }
|
||||
}),
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Debian' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveKimiHome', () => {
|
||||
const originalKimiCodeHome = process.env.KIMI_CODE_HOME
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
wslMocks.listWslDistrosAsync.mockReset().mockResolvedValue(['Ubuntu'])
|
||||
wslMocks.getWslHomeAsync.mockReset().mockResolvedValue('\\\\wsl.localhost\\Ubuntu\\home\\neil')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalKimiCodeHome === undefined) {
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
} else {
|
||||
process.env.KIMI_CODE_HOME = originalKimiCodeHome
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the host home for a host target', async () => {
|
||||
expect(await resolveKimiHome({ runtime: 'host', wslDistro: null }, 'win32')).toEqual({
|
||||
runtime: 'host',
|
||||
wslDistro: null,
|
||||
path: getHostKimiHome()
|
||||
})
|
||||
expect(wslMocks.getWslHomeAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves the WSL distro home for a WSL target', async () => {
|
||||
expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).toEqual({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\neil\\.kimi-code'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores the host KIMI_CODE_HOME when reading a WSL home', async () => {
|
||||
process.env.KIMI_CODE_HOME = 'D:\\kimi-home'
|
||||
expect((await resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).path).toBe(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\neil\\.kimi-code'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the default distro when none is configured', async () => {
|
||||
expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: null }, 'win32')).toMatchObject({
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(wslMocks.getWslHomeAsync).toHaveBeenCalledWith('Ubuntu')
|
||||
})
|
||||
|
||||
it('reports no path when the distro home cannot be probed', async () => {
|
||||
wslMocks.getWslHomeAsync.mockResolvedValue(null)
|
||||
expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).toEqual({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
path: null
|
||||
})
|
||||
})
|
||||
|
||||
it('reports no path when no distro exists at all', async () => {
|
||||
wslMocks.listWslDistrosAsync.mockResolvedValue([])
|
||||
expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: null }, 'win32')).toEqual({
|
||||
runtime: 'wsl',
|
||||
wslDistro: null,
|
||||
path: null
|
||||
})
|
||||
})
|
||||
|
||||
it('never probes WSL off Windows', async () => {
|
||||
expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'darwin')).toEqual({
|
||||
runtime: 'host',
|
||||
wslDistro: null,
|
||||
path: getHostKimiHome()
|
||||
})
|
||||
expect(wslMocks.listWslDistrosAsync).not.toHaveBeenCalled()
|
||||
expect(wslMocks.getWslHomeAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { homedir } from 'node:os'
|
||||
import { join, win32 as pathWin32 } from 'node:path'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
resolveLocalAccountRuntimeTarget,
|
||||
type LocalAccountRuntimeTarget
|
||||
} from '../../shared/local-account-runtime'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
|
||||
export type KimiHomeResolution = LocalAccountRuntimeTarget & {
|
||||
/** null when the WSL distro's home could not be probed (distro missing/stopped). */
|
||||
path: string | null
|
||||
}
|
||||
|
||||
// Why: match the CLI's `KIMI_CODE_HOME ?? ~/.kimi-code` resolution so we read the
|
||||
// same files the running Kimi CLI writes.
|
||||
export function getHostKimiHome(): string {
|
||||
return process.env.KIMI_CODE_HOME?.trim() || join(homedir(), '.kimi-code')
|
||||
}
|
||||
|
||||
/**
|
||||
* Kimi has no per-account runtime switcher, so it follows the local-account
|
||||
* runtime policy. WSL is Windows-only — pin everywhere else so no `wsl.exe`
|
||||
* probe is attempted on a stale `wsl` setting synced from a Windows machine.
|
||||
*/
|
||||
export function getKimiRuntimeTarget(
|
||||
settings: GlobalSettings,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): LocalAccountRuntimeTarget {
|
||||
if (platform !== 'win32') {
|
||||
return { runtime: 'host', wslDistro: null }
|
||||
}
|
||||
return resolveLocalAccountRuntimeTarget(settings, platform)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a runtime target to the Kimi home Orca should read (UNC path for WSL).
|
||||
* Async because it runs on every quota poll: the sync `wsl.exe` probes park
|
||||
* Electron's main process for up to 5s each cycle while a distro is stopped.
|
||||
*/
|
||||
export async function resolveKimiHome(
|
||||
target: LocalAccountRuntimeTarget,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<KimiHomeResolution> {
|
||||
if (target.runtime !== 'wsl' || platform !== 'win32') {
|
||||
return { runtime: 'host', wslDistro: null, path: getHostKimiHome() }
|
||||
}
|
||||
const distro = target.wslDistro?.trim() || (await defaultWslDistro())
|
||||
if (!distro) {
|
||||
return { runtime: 'wsl', wslDistro: null, path: null }
|
||||
}
|
||||
const home = await getWslHomeAsync(distro)
|
||||
// KIMI_CODE_HOME describes the Windows host process, never the distro's home.
|
||||
return { runtime: 'wsl', wslDistro: distro, path: home ? joinKimiHome(home) : null }
|
||||
}
|
||||
|
||||
/** Async twin of `getDefaultWslDistro`, which shells out to wsl.exe synchronously. */
|
||||
async function defaultWslDistro(): Promise<string | null> {
|
||||
return (await listWslDistrosAsync())[0] ?? null
|
||||
}
|
||||
|
||||
function joinKimiHome(home: string): string {
|
||||
return parseWslUncPath(home) ? pathWin32.join(home, '.kimi-code') : join(home, '.kimi-code')
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const netFetchMock = vi.hoisted(() => vi.fn())
|
||||
const files = vi.hoisted(() => new Map<string, string>())
|
||||
const STALLED = vi.hoisted(() => '__stalled_unc_read__')
|
||||
|
||||
vi.mock('electron', () => ({ net: { fetch: netFetchMock } }))
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: async (path: string) => {
|
||||
const contents = files.get(path)
|
||||
if (contents === undefined) {
|
||||
const error = new Error(`ENOENT: ${path}`) as NodeJS.ErrnoException
|
||||
error.code = 'ENOENT'
|
||||
throw error
|
||||
}
|
||||
if (contents === STALLED) {
|
||||
// A distro that is down parks the UNC read instead of failing.
|
||||
return await new Promise<string>(() => {})
|
||||
}
|
||||
return contents
|
||||
}
|
||||
}))
|
||||
vi.mock('node:os', () => ({ homedir: () => '/home/neil' }))
|
||||
|
||||
import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
|
||||
// Built with `join` so the key matches the separator the fetcher emits on this runner's platform.
|
||||
const HOST_CREDENTIALS = join('/home/neil', '.kimi-code', 'credentials', 'kimi-code.json')
|
||||
const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\neil\\.kimi-code'
|
||||
const WSL_CREDENTIALS = `${WSL_HOME}\\credentials\\kimi-code.json`
|
||||
|
||||
function credentials(token: string, expiresInSeconds: number): string {
|
||||
return JSON.stringify({
|
||||
access_token: token,
|
||||
expires_at: Math.floor(Date.now() / 1000) + expiresInSeconds
|
||||
})
|
||||
}
|
||||
|
||||
function usageResponse(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
usage: { limit: '1000', remaining: '900' },
|
||||
limits: [
|
||||
{
|
||||
window: { duration: 5, timeUnit: 'TIME_UNIT_HOUR' },
|
||||
detail: { limit: '100', remaining: '40' }
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('fetchKimiRateLimits with a WSL credentials home', () => {
|
||||
beforeEach(() => {
|
||||
files.clear()
|
||||
netFetchMock.mockReset()
|
||||
netFetchMock.mockResolvedValue(usageResponse())
|
||||
// The Windows-side copy stopped rotating when the CLI moved into WSL.
|
||||
files.set(HOST_CREDENTIALS, credentials('host-stale', -3 * 24 * 3600))
|
||||
files.set(WSL_CREDENTIALS, credentials('wsl-fresh', 13 * 60))
|
||||
})
|
||||
|
||||
it('reads the WSL token instead of the stale host one', async () => {
|
||||
const result = await fetchKimiRateLimits({
|
||||
home: { runtime: 'wsl', wslDistro: 'Ubuntu', path: WSL_HOME }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(60)
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(netFetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer wsl-fresh')
|
||||
})
|
||||
|
||||
it('still reads the host home by default', async () => {
|
||||
const result = await fetchKimiRateLimits()
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('delegated-refresh-required')
|
||||
expect(result.error).toContain('on the computer running Orca')
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('points an expired WSL session at the distro to rerun kimi in', async () => {
|
||||
files.set(WSL_CREDENTIALS, credentials('wsl-stale', -60))
|
||||
|
||||
const result = await fetchKimiRateLimits({
|
||||
home: { runtime: 'wsl', wslDistro: 'Ubuntu', path: WSL_HOME }
|
||||
})
|
||||
|
||||
expect(result.error).toBe(
|
||||
'Kimi session expired — run kimi inside WSL (Ubuntu), then retry usage.'
|
||||
)
|
||||
expect(result.usageMetadata?.failureKind).toBe('delegated-refresh-required')
|
||||
})
|
||||
|
||||
it('reports an unresolvable WSL home instead of falling back to host credentials', async () => {
|
||||
const result = await fetchKimiRateLimits({
|
||||
home: { runtime: 'wsl', wslDistro: 'Ubuntu', path: null }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toBe('WSL Kimi home unavailable for Ubuntu')
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports not signed in when the WSL home has no credentials file', async () => {
|
||||
files.delete(WSL_CREDENTIALS)
|
||||
|
||||
const result = await fetchKimiRateLimits({
|
||||
home: { runtime: 'wsl', wslDistro: 'Ubuntu', path: WSL_HOME }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
})
|
||||
|
||||
// Last: an unsettled UNC read stays shared for its path by design, so the stalled
|
||||
// distro gets its own home to avoid poisoning the other cases.
|
||||
it('bounds a stalled UNC read instead of parking the poll cycle', async () => {
|
||||
const stalledHome = '\\\\wsl.localhost\\Stopped\\home\\neil\\.kimi-code'
|
||||
files.set(`${stalledHome}\\credentials\\kimi-code.json`, STALLED)
|
||||
const timeoutController = new AbortController()
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutController.signal)
|
||||
|
||||
const pending = fetchKimiRateLimits({
|
||||
home: { runtime: 'wsl', wslDistro: 'Stopped', path: stalledHome }
|
||||
})
|
||||
expect(timeout).toHaveBeenCalledWith(5_000)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
timeoutController.abort()
|
||||
|
||||
const result = await pending
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toContain('(WSL Stopped)')
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
timeout.mockRestore()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,28 +1,34 @@
|
|||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const netFetchMock = vi.hoisted(() => vi.fn())
|
||||
const fsState = vi.hoisted<{ credentials: string | null; readError: Error | null }>(() => ({
|
||||
const fsState = vi.hoisted<{
|
||||
credentials: string | null
|
||||
readError: Error | null
|
||||
readPaths: string[]
|
||||
}>(() => ({
|
||||
credentials: null,
|
||||
readError: null
|
||||
readError: null,
|
||||
readPaths: []
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
net: { fetch: netFetchMock }
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: () => fsState.credentials !== null,
|
||||
readFileSync: () => {
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: async (path: string) => {
|
||||
fsState.readPaths.push(String(path))
|
||||
if (fsState.readError) {
|
||||
throw fsState.readError
|
||||
}
|
||||
if (fsState.credentials === null) {
|
||||
throw new Error('ENOENT')
|
||||
const error = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException
|
||||
error.code = 'ENOENT'
|
||||
throw error
|
||||
}
|
||||
return fsState.credentials
|
||||
},
|
||||
writeFileSync: () => {},
|
||||
renameSync: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:os', () => ({ homedir: () => '/home/test' }))
|
||||
|
|
@ -50,6 +56,11 @@ const USAGE_RESPONSE = {
|
|||
subType: 'TYPE_PURCHASE'
|
||||
}
|
||||
|
||||
// Built with `join` so the expectation matches the separator the fetcher emits on this platform.
|
||||
function hostCredentialsPath(kimiHome: string): string {
|
||||
return join(kimiHome, 'credentials', 'kimi-code.json')
|
||||
}
|
||||
|
||||
function freshCredentials(): string {
|
||||
// expires_at far in the future (seconds).
|
||||
return JSON.stringify({ access_token: 'tok-abc', expires_at: 99_999_999_999 })
|
||||
|
|
@ -60,6 +71,7 @@ describe('fetchKimiRateLimits', () => {
|
|||
netFetchMock.mockReset()
|
||||
fsState.credentials = null
|
||||
fsState.readError = null
|
||||
fsState.readPaths = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -147,4 +159,35 @@ describe('fetchKimiRateLimits', () => {
|
|||
})
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the host ~/.kimi-code credentials by default', async () => {
|
||||
fsState.credentials = freshCredentials()
|
||||
netFetchMock.mockResolvedValueOnce(jsonResponse(USAGE_RESPONSE))
|
||||
|
||||
const result = await fetchKimiRateLimits()
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(fsState.readPaths).toEqual([hostCredentialsPath('/home/test/.kimi-code')])
|
||||
})
|
||||
|
||||
it('honors KIMI_CODE_HOME for the host home', async () => {
|
||||
vi.stubEnv('KIMI_CODE_HOME', '/custom/kimi-home')
|
||||
fsState.credentials = freshCredentials()
|
||||
netFetchMock.mockResolvedValueOnce(jsonResponse(USAGE_RESPONSE))
|
||||
|
||||
const result = await fetchKimiRateLimits()
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(fsState.readPaths).toEqual([hostCredentialsPath('/custom/kimi-home')])
|
||||
})
|
||||
|
||||
it('ignores a blank KIMI_CODE_HOME instead of reading from the process cwd', async () => {
|
||||
vi.stubEnv('KIMI_CODE_HOME', ' ')
|
||||
fsState.credentials = freshCredentials()
|
||||
netFetchMock.mockResolvedValueOnce(jsonResponse(USAGE_RESPONSE))
|
||||
|
||||
await fetchKimiRateLimits()
|
||||
|
||||
expect(fsState.readPaths).toEqual([hostCredentialsPath('/home/test/.kimi-code')])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, win32 as pathWin32 } from 'node:path'
|
||||
import { net } from 'electron'
|
||||
import type {
|
||||
ProviderRateLimits,
|
||||
RateLimitWindow,
|
||||
UsageRateLimitMetadata
|
||||
} from '../../shared/rate-limit-types'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { getHostKimiHome, type KimiHomeResolution } from '../kimi/kimi-runtime-home'
|
||||
import {
|
||||
createAuthFilesystemOperation,
|
||||
type SharedAuthFilesystemOperation
|
||||
} from './auth-filesystem-operation'
|
||||
|
||||
// Why: Kimi Code's managed coding plan exposes subscription usage at
|
||||
// `${base}/usages` (see packages/oauth/src/managed-usage.ts in the CLI bundle).
|
||||
|
|
@ -14,18 +19,16 @@ import type {
|
|||
// stays aligned with a user's self-hosted/staging config.
|
||||
const KIMI_BASE_URL = process.env.KIMI_CODE_BASE_URL ?? 'https://api.kimi.com/coding/v1'
|
||||
const API_TIMEOUT_MS = 10_000
|
||||
const CREDENTIALS_READ_TIMEOUT_MS = 5_000
|
||||
|
||||
const SESSION_WINDOW_MINUTES = 300 // 5h
|
||||
const WEEKLY_WINDOW_MINUTES = 10080 // 7d
|
||||
|
||||
function getKimiHome(): string {
|
||||
// Why: match the CLI's `KIMI_CODE_HOME ?? ~/.kimi-code` resolution so we read
|
||||
// the same OAuth credentials the running Kimi CLI writes.
|
||||
return process.env.KIMI_CODE_HOME ?? join(homedir(), '.kimi-code')
|
||||
}
|
||||
|
||||
function getCredentialsPath(): string {
|
||||
return join(getKimiHome(), 'credentials', 'kimi-code.json')
|
||||
function getCredentialsPath(kimiHome: string): string {
|
||||
// WSL homes arrive as `\\wsl.localhost\<distro>\...`, which only win32 join keeps intact.
|
||||
return parseWslUncPath(kimiHome)
|
||||
? pathWin32.join(kimiHome, 'credentials', 'kimi-code.json')
|
||||
: join(kimiHome, 'credentials', 'kimi-code.json')
|
||||
}
|
||||
|
||||
type KimiCredentials = {
|
||||
|
|
@ -52,22 +55,65 @@ function parseCredentials(value: unknown): KimiCredentials | null {
|
|||
return credentials
|
||||
}
|
||||
|
||||
function readCredentials(): CredentialsReadResult {
|
||||
const path = getCredentialsPath()
|
||||
if (!existsSync(path)) {
|
||||
return { status: 'missing' }
|
||||
const credentialsReadByPath = new Map<
|
||||
string,
|
||||
SharedAuthFilesystemOperation<CredentialsReadResult>
|
||||
>()
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
function readErrorMessage(err: unknown): string {
|
||||
// Why: AbortSignal timeouts reject with a DOMException, not an Error.
|
||||
const message = (err as { message?: unknown } | null)?.message
|
||||
return typeof message === 'string' ? message : 'Unable to read Kimi credentials'
|
||||
}
|
||||
|
||||
function getCredentialsRead(path: string): SharedAuthFilesystemOperation<CredentialsReadResult> {
|
||||
const existing = credentialsReadByPath.get(path)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
|
||||
const credentials = parseCredentials(parsed)
|
||||
return credentials
|
||||
? { status: 'ok', credentials }
|
||||
: { status: 'error', error: 'Kimi credentials file is invalid' }
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Unable to read Kimi credentials'
|
||||
// Why: aborting an fs promise does not cancel an already issued UNC request, so
|
||||
// share one raw read per path until it settles (mirrors codex-fetcher's auth read).
|
||||
const read = createAuthFilesystemOperation(path, async (): Promise<CredentialsReadResult> => {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(path, 'utf-8')
|
||||
} catch (err) {
|
||||
return isMissingPathError(err)
|
||||
? { status: 'missing' }
|
||||
: { status: 'error', error: readErrorMessage(err) }
|
||||
}
|
||||
try {
|
||||
const credentials = parseCredentials(JSON.parse(raw))
|
||||
return credentials
|
||||
? { status: 'ok', credentials }
|
||||
: { status: 'error', error: 'Kimi credentials file is invalid' }
|
||||
} catch (err) {
|
||||
return { status: 'error', error: readErrorMessage(err) }
|
||||
}
|
||||
})
|
||||
credentialsReadByPath.set(path, read)
|
||||
const clearRead = (): void => {
|
||||
if (credentialsReadByPath.get(path) === read) {
|
||||
credentialsReadByPath.delete(path)
|
||||
}
|
||||
}
|
||||
void read.result.then(clearRead, clearRead)
|
||||
return read
|
||||
}
|
||||
|
||||
async function readCredentials(kimiHome: string): Promise<CredentialsReadResult> {
|
||||
const path = getCredentialsPath(kimiHome)
|
||||
try {
|
||||
// Why: a stopped distro parks a UNC read for minutes; bound it so a WSL home
|
||||
// degrades to an error instead of stalling the poll cycle.
|
||||
return await getCredentialsRead(path).wait(AbortSignal.timeout(CREDENTIALS_READ_TIMEOUT_MS))
|
||||
} catch (err) {
|
||||
return { status: 'error', error: readErrorMessage(err) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,6 +258,19 @@ function mapUsageResponse(data: KimiUsageResponse): ProviderRateLimits {
|
|||
}
|
||||
}
|
||||
|
||||
// A bare "operation was aborted due to timeout" hides which machine stalled.
|
||||
function readErrorContext(home: KimiHomeResolution, error: string): string {
|
||||
return home.runtime === 'wsl' ? `${error} (WSL ${home.wslDistro ?? 'default distro'})` : error
|
||||
}
|
||||
|
||||
function expiredSessionMessage(home: KimiHomeResolution): string {
|
||||
const where =
|
||||
home.runtime === 'wsl'
|
||||
? `inside WSL (${home.wslDistro ?? 'default distro'})`
|
||||
: 'on the computer running Orca'
|
||||
return `Kimi session expired — run kimi ${where}, then retry usage.`
|
||||
}
|
||||
|
||||
function result(
|
||||
status: ProviderRateLimits['status'],
|
||||
error: string | null,
|
||||
|
|
@ -231,7 +290,11 @@ function result(
|
|||
/**
|
||||
* Read-only subscription usage for Kimi Code.
|
||||
*
|
||||
* Why read-only: the access token lives in `~/.kimi-code/credentials/kimi-code.json`
|
||||
* `home` selects which machine's credentials to read: on Windows the Kimi CLI
|
||||
* often runs inside WSL, and only that copy is refreshed (#12370). Defaults to
|
||||
* the host home.
|
||||
*
|
||||
* Why read-only: the access token lives in `<kimi home>/credentials/kimi-code.json`
|
||||
* and is refreshed by the Kimi CLI itself (15-min TTL, refresh-token rotation).
|
||||
* Orca must NEVER refresh or rewrite that file — a rotated refresh token would
|
||||
* log out a live `kimi` session. We only read the current token and call the
|
||||
|
|
@ -239,13 +302,23 @@ function result(
|
|||
* `/usage` command uses. The completion endpoint (the one Moonshot gates to
|
||||
* approved coding agents) is never touched here.
|
||||
*/
|
||||
export async function fetchKimiRateLimits(): Promise<ProviderRateLimits> {
|
||||
const readResult = readCredentials()
|
||||
export async function fetchKimiRateLimits(options?: {
|
||||
home?: KimiHomeResolution
|
||||
}): Promise<ProviderRateLimits> {
|
||||
const home: KimiHomeResolution = options?.home ?? {
|
||||
runtime: 'host',
|
||||
wslDistro: null,
|
||||
path: getHostKimiHome()
|
||||
}
|
||||
if (home.path === null) {
|
||||
return result('error', `WSL Kimi home unavailable for ${home.wslDistro ?? 'default distro'}`)
|
||||
}
|
||||
const readResult = await readCredentials(home.path)
|
||||
if (readResult.status === 'missing') {
|
||||
return result('unavailable', 'Not signed in to Kimi Code')
|
||||
}
|
||||
if (readResult.status === 'error') {
|
||||
return result('error', readResult.error)
|
||||
return result('error', readErrorContext(home, readResult.error))
|
||||
}
|
||||
const creds = readResult.credentials
|
||||
if (typeof creds.access_token !== 'string' || creds.access_token.length === 0) {
|
||||
|
|
@ -255,11 +328,10 @@ export async function fetchKimiRateLimits(): Promise<ProviderRateLimits> {
|
|||
// Why: don't refresh — the CLI owns the token lifecycle. Report a transient
|
||||
// error so the rate-limit service keeps the last good snapshot (stale
|
||||
// policy) until the user next runs Kimi and the CLI refreshes the file.
|
||||
return result(
|
||||
'error',
|
||||
'Kimi session expired — run kimi on the computer running Orca, then retry usage.',
|
||||
{ failureKind: 'delegated-refresh-required', source: 'oauth' }
|
||||
)
|
||||
return result('error', expiredSessionMessage(home), {
|
||||
failureKind: 'delegated-refresh-required',
|
||||
source: 'oauth'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1432,6 +1432,36 @@ describe('RateLimitService', () => {
|
|||
expect(state.opencodeGo?.session?.usedPercent).toBe(40)
|
||||
})
|
||||
|
||||
it('passes the resolved Kimi home into each fetch cycle', async () => {
|
||||
const service = new RateLimitService()
|
||||
const home = {
|
||||
runtime: 'wsl' as const,
|
||||
wslDistro: 'Ubuntu',
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\neil\\.kimi-code'
|
||||
}
|
||||
const resolver = vi.fn(async () => home)
|
||||
service.setKimiHomeResolver(resolver)
|
||||
mockFreshBackgroundProviderFetches()
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10))
|
||||
|
||||
await service.refresh()
|
||||
await service.refresh()
|
||||
|
||||
// Resolved per cycle so a runtime-policy change takes effect without a restart.
|
||||
expect(resolver).toHaveBeenCalledTimes(2)
|
||||
expect(fetchKimiRateLimits).toHaveBeenCalledWith({ home })
|
||||
})
|
||||
|
||||
it('reads the host Kimi home when no resolver is wired', async () => {
|
||||
const service = new RateLimitService()
|
||||
mockFreshBackgroundProviderFetches()
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchKimiRateLimits).toHaveBeenCalledWith({ home: undefined })
|
||||
})
|
||||
|
||||
it('passes the selected WSL Codex home into active account rate-limit fetches', async () => {
|
||||
const service = new RateLimitService()
|
||||
const wslCodexHome =
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from '../claude-accounts/runtime-selection'
|
||||
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
import type { KimiHomeResolution } from '../kimi/kimi-runtime-home'
|
||||
import { fetchGrokRateLimits } from './grok-fetcher'
|
||||
import { readGrokAuthSession } from './grok-auth'
|
||||
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
|
|
@ -38,6 +39,7 @@ export type InactiveCodexAccountInfo = {
|
|||
}
|
||||
|
||||
type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => string | null
|
||||
type KimiHomeResolver = () => Promise<KimiHomeResolution>
|
||||
type ClaudeAuthPreparationResolver = (
|
||||
target?: ClaudeAccountSelectionTarget
|
||||
) => Promise<ClaudeRuntimeAuthPreparation>
|
||||
|
|
@ -222,6 +224,8 @@ export class RateLimitService {
|
|||
runtime: 'host',
|
||||
wslDistro: null
|
||||
}
|
||||
// Why: resolved per cycle — the local-account runtime policy can flip between fetches.
|
||||
private kimiHomeResolver: KimiHomeResolver | null = null
|
||||
private claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null
|
||||
private claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = {
|
||||
runtime: 'host',
|
||||
|
|
@ -260,6 +264,19 @@ export class RateLimitService {
|
|||
this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target)
|
||||
}
|
||||
|
||||
setKimiHomeResolver(resolver: KimiHomeResolver): void {
|
||||
this.kimiHomeResolver = resolver
|
||||
}
|
||||
|
||||
// Why: resolving a WSL home probes wsl.exe, so it must not run before the other
|
||||
// providers' fetches are started; chaining keeps the no-resolver path immediate.
|
||||
private fetchKimiWithResolvedHome(): Promise<ProviderRateLimits> {
|
||||
const pendingHome = this.kimiHomeResolver?.()
|
||||
return pendingHome
|
||||
? pendingHome.then((home) => fetchKimiRateLimits({ home }))
|
||||
: fetchKimiRateLimits({ home: undefined })
|
||||
}
|
||||
|
||||
setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void {
|
||||
this.claudeAuthPreparationResolver = resolver
|
||||
}
|
||||
|
|
@ -1661,7 +1678,7 @@ export class RateLimitService {
|
|||
workspaceIdOverride || undefined,
|
||||
this.networkProxySettingsResolver?.()
|
||||
),
|
||||
fetchKimiRateLimits(),
|
||||
this.fetchKimiWithResolvedHome(),
|
||||
miniMaxConfigResult.error
|
||||
? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error))
|
||||
: fetchMiniMaxRateLimits({
|
||||
|
|
|
|||
Loading…
Reference in New Issue