feat(rate-limits): add MiniMax token control (#7387)
This commit is contained in:
parent
136cb50bcb
commit
f11a2109c8
|
|
@ -0,0 +1,9 @@
|
|||
<svg width="36" height="32" viewBox="0 0 36.9143 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="0" y1="15.5" x2="36.9143" y2="15.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E21680"/>
|
||||
<stop offset="1" stop-color="#FF633A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M17.5802 3.08155C17.5802 2.35036 16.987 1.75702 16.2589 1.75702C15.5309 1.75702 14.9377 2.35171 14.9377 3.08155V25.0348C14.9377 26.7337 13.5586 28.1164 11.8639 28.1164C10.1693 28.1164 8.79008 26.7337 8.79008 25.0348V10.9503C8.79008 10.2191 8.19688 9.62575 7.46886 9.62575C6.74085 9.62575 6.14765 10.2204 6.14765 10.9503V16.9201C6.14765 18.619 4.76846 20.0016 3.07381 20.0016C1.37915 20.0016 0 18.619 0 16.9201V14.7563C0 14.2711 0.392318 13.8778 0.876312 13.8778C1.36031 13.8778 1.75262 14.2711 1.75262 14.7563V16.9201C1.75262 17.6513 2.34579 18.2446 3.07381 18.2446C3.80182 18.2446 4.39502 17.6499 4.39502 16.9201V10.9503C4.39502 9.25138 5.77421 7.86874 7.46886 7.86874C9.16352 7.86874 10.5427 9.25138 10.5427 10.9503V25.0348C10.5427 25.766 11.1359 26.3593 11.8639 26.3593C12.5919 26.3593 13.1851 25.7646 13.1851 25.0348V16.4227V3.08155C13.1851 1.38265 14.5643 0 16.2589 0C17.9536 0 19.3328 1.38265 19.3328 3.08155V21.2599C19.3328 21.7451 18.9405 22.1384 18.4565 22.1384C17.9725 22.1384 17.5802 21.7451 17.5802 21.2599V3.08155ZM33.8405 7.86874C32.1458 7.86874 30.7666 9.25138 30.7666 10.9503V22.7155C30.7666 23.4467 30.1734 24.0401 29.4454 24.0401C28.7174 24.0401 28.1242 23.4454 28.1242 22.7155V3.08155C28.1242 1.38265 26.745 0 25.0504 0C23.3557 0 21.9766 1.38265 21.9766 3.08155V28.0042C21.9766 28.7354 21.3834 29.3287 20.6553 29.3287C19.9273 29.3287 19.3341 28.734 19.3341 28.0042V24.9186C19.3341 24.4334 18.9418 24.0401 18.4578 24.0401C17.9738 24.0401 17.5815 24.4334 17.5815 24.9186V28.0042C17.5815 29.7031 18.9607 31.0857 20.6553 31.0857C22.35 31.0857 23.7292 29.7031 23.7292 28.0042V3.08155C23.7292 2.35036 24.3224 1.75702 25.0504 1.75702C25.7784 1.75702 26.3716 2.35171 26.3716 3.08155V22.7155C26.3716 24.4144 27.7508 25.7971 29.4454 25.7971C31.1401 25.7971 32.5193 24.4144 32.5193 22.7155V10.9503C32.5193 10.2191 33.1125 9.62575 33.8405 9.62575C34.5685 9.62575 35.1617 10.2204 35.1617 10.9503V21.2599C35.1617 21.7451 35.554 22.1384 36.038 22.1384C36.522 22.1384 36.9143 21.7451 36.9143 21.2599V10.9503C36.9143 9.25138 35.5351 7.86874 33.8405 7.86874Z" fill="url(#paint0_linear)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
|
@ -127,6 +127,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
defaultLinearTeamSelection: null,
|
||||
opencodeSessionCookie: '',
|
||||
opencodeWorkspaceId: '',
|
||||
minimaxGroupId: '',
|
||||
minimaxUsageModels: 'general',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
keepComputerAwakeWhileAgentsRun: false,
|
||||
|
|
|
|||
|
|
@ -131,6 +131,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
defaultLinearTeamSelection: null,
|
||||
opencodeSessionCookie: '',
|
||||
opencodeWorkspaceId: '',
|
||||
minimaxGroupId: '',
|
||||
minimaxUsageModels: 'general',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
keepComputerAwakeWhileAgentsRun: false,
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ import {
|
|||
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
|
||||
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
|
||||
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 {
|
||||
|
|
@ -1685,6 +1686,14 @@ app.whenReady().then(async () => {
|
|||
workspaceIdOverride: settings.opencodeWorkspaceId
|
||||
}
|
||||
})
|
||||
rateLimits.setMiniMaxConfigResolver(() => {
|
||||
const settings = store!.getSettings()
|
||||
return {
|
||||
sessionCookie: readMiniMaxSessionCookie() ?? '',
|
||||
groupId: settings.minimaxGroupId,
|
||||
models: settings.minimaxUsageModels
|
||||
}
|
||||
})
|
||||
rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled)
|
||||
rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings())
|
||||
keybindings = new KeybindingService({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const ipcState = vi.hoisted(() => ({
|
||||
handleHandlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
ipcState.handleHandlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
|
||||
const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
|
||||
const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => false))
|
||||
|
||||
vi.mock('../minimax/minimax-cookie-store', () => ({
|
||||
saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock,
|
||||
clearMiniMaxSessionCookie: clearMiniMaxSessionCookieMock,
|
||||
hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock
|
||||
}))
|
||||
|
||||
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
|
||||
function makeRefreshMock(): {
|
||||
refresh: ReturnType<typeof vi.fn>
|
||||
service: Pick<RateLimitService, 'refresh'>
|
||||
} {
|
||||
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
|
||||
return { refresh, service: { refresh } }
|
||||
}
|
||||
|
||||
async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
|
||||
const handler = ipcState.handleHandlers.get(channel)
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for ${channel}`)
|
||||
}
|
||||
return (await handler({}, ...args)) as T
|
||||
}
|
||||
|
||||
describe('registerMiniMaxCredentialsHandlers', () => {
|
||||
beforeEach(() => {
|
||||
ipcState.handleHandlers.clear()
|
||||
saveMiniMaxSessionCookieMock.mockReset()
|
||||
clearMiniMaxSessionCookieMock.mockReset()
|
||||
hasMiniMaxSessionCookieMock.mockReset()
|
||||
hasMiniMaxSessionCookieMock.mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('registers the three MiniMax credential channels', () => {
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
expect(ipcState.handleHandlers.has('minimaxCredentials:getStatus')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('minimaxCredentials:saveCookie')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('minimaxCredentials:clearCookie')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the configured state on getStatus from the cookie store', async () => {
|
||||
hasMiniMaxSessionCookieMock.mockReturnValue(true)
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
const status = await invoke<{ configured: boolean }>('minimaxCredentials:getStatus')
|
||||
expect(status).toEqual({ configured: true })
|
||||
})
|
||||
|
||||
it('persists the cookie and reports configured after saveCookie', async () => {
|
||||
hasMiniMaxSessionCookieMock.mockReturnValueOnce(true)
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
const status = await invoke<{ configured: boolean }>(
|
||||
'minimaxCredentials:saveCookie',
|
||||
'_token=abc; minimax_group_id_v2=42'
|
||||
)
|
||||
expect(saveMiniMaxSessionCookieMock).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
|
||||
expect(status).toEqual({ configured: true })
|
||||
})
|
||||
|
||||
it('triggers a rate-limit refresh after saveCookie when a service is provided', async () => {
|
||||
const { refresh, service } = makeRefreshMock()
|
||||
registerMiniMaxCredentialsHandlers(service as RateLimitService)
|
||||
await invoke('minimaxCredentials:saveCookie', '_token=abc')
|
||||
// Why: the save handler is fire-and-forget — wait a microtask cycle so
|
||||
// the queued `void rateLimits?.refresh()` resolves before we assert.
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not throw when saveCookie runs without a rate-limit service', async () => {
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
await expect(invoke('minimaxCredentials:saveCookie', '_token=abc')).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('clears the cookie and triggers a refresh on clearCookie', async () => {
|
||||
const { refresh, service } = makeRefreshMock()
|
||||
hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
|
||||
registerMiniMaxCredentialsHandlers(service as RateLimitService)
|
||||
const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
|
||||
expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
|
||||
expect(status).toEqual({ configured: false })
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('logs but does not throw when the post-save rate-limit refresh rejects', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const refresh = vi.fn(() => Promise.reject(new Error('refresh boom')))
|
||||
registerMiniMaxCredentialsHandlers({
|
||||
refresh
|
||||
} as Pick<RateLimitService, 'refresh'> as RateLimitService)
|
||||
await invoke('minimaxCredentials:saveCookie', '_token=abc')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed to trigger rate-limit refresh after save'),
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
clearMiniMaxSessionCookie,
|
||||
hasMiniMaxSessionCookie,
|
||||
saveMiniMaxSessionCookie
|
||||
} from '../minimax/minimax-cookie-store'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
|
||||
export type MiniMaxCredentialsStatus = {
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus {
|
||||
return { configured: hasMiniMaxSessionCookie() }
|
||||
}
|
||||
|
||||
export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void {
|
||||
ipcMain.handle('minimaxCredentials:getStatus', () => getMiniMaxCredentialsStatus())
|
||||
ipcMain.handle('minimaxCredentials:saveCookie', (_event, cookie: string) => {
|
||||
saveMiniMaxSessionCookie(cookie)
|
||||
void rateLimits?.refresh().catch((error: unknown) => {
|
||||
console.error('[minimax] failed to trigger rate-limit refresh after save:', error)
|
||||
})
|
||||
return getMiniMaxCredentialsStatus()
|
||||
})
|
||||
ipcMain.handle('minimaxCredentials:clearCookie', () => {
|
||||
clearMiniMaxSessionCookie()
|
||||
void rateLimits?.refresh().catch((error: unknown) => {
|
||||
console.error('[minimax] failed to trigger rate-limit refresh after clear:', error)
|
||||
})
|
||||
return getMiniMaxCredentialsStatus()
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const ipcState = vi.hoisted(() => ({
|
||||
handleHandlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
ipcState.handleHandlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { registerRateLimitHandlers } from './rate-limits'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
|
||||
function makeService(): { service: RateLimitService; refresh: ReturnType<typeof vi.fn> } {
|
||||
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
|
||||
const service = {
|
||||
getState: vi.fn(() => ({}) as RateLimitState),
|
||||
refresh,
|
||||
refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
|
||||
refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
|
||||
consumeCodexRateLimitResetCredit: vi.fn(() =>
|
||||
Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState })
|
||||
),
|
||||
setPollingInterval: vi.fn(() => Promise.resolve()),
|
||||
fetchInactiveClaudeAccountsOnOpen: vi.fn(() => Promise.resolve()),
|
||||
fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve())
|
||||
}
|
||||
return { service: service as unknown as RateLimitService, refresh }
|
||||
}
|
||||
|
||||
describe('registerRateLimitHandlers', () => {
|
||||
beforeEach(() => {
|
||||
ipcState.handleHandlers.clear()
|
||||
})
|
||||
|
||||
it('registers a refreshMiniMax channel that delegates to refresh()', async () => {
|
||||
const { service, refresh } = makeService()
|
||||
registerRateLimitHandlers(service)
|
||||
const handler = ipcState.handleHandlers.get('rateLimits:refreshMiniMax')
|
||||
expect(handler).toBeDefined()
|
||||
await handler!({})
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the existing rate-limit channels registered', () => {
|
||||
const { service } = makeService()
|
||||
registerRateLimitHandlers(service)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -23,4 +23,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void {
|
|||
ipcMain.handle('rateLimits:fetchInactiveCodexAccounts', () =>
|
||||
rateLimits.fetchInactiveCodexAccountsOnOpen()
|
||||
)
|
||||
ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const {
|
|||
registerAgentHookHandlersMock,
|
||||
registerAgentTrustHandlersMock,
|
||||
registerClaudeAccountHandlersMock,
|
||||
registerMiniMaxCredentialsHandlersMock,
|
||||
registerClipboardHandlersMock,
|
||||
setTrustedClipboardRendererWebContentsIdMock,
|
||||
registerUpdaterHandlersMock,
|
||||
|
|
@ -89,6 +90,7 @@ const {
|
|||
registerAgentHookHandlersMock: vi.fn(),
|
||||
registerAgentTrustHandlersMock: vi.fn(),
|
||||
registerClaudeAccountHandlersMock: vi.fn(),
|
||||
registerMiniMaxCredentialsHandlersMock: vi.fn(),
|
||||
registerClipboardHandlersMock: vi.fn(),
|
||||
setTrustedClipboardRendererWebContentsIdMock: vi.fn(),
|
||||
registerUpdaterHandlersMock: vi.fn(),
|
||||
|
|
@ -279,6 +281,10 @@ vi.mock('./claude-accounts', () => ({
|
|||
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./minimax-credentials', () => ({
|
||||
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('../window/attach-main-window-services', () => ({
|
||||
registerUpdaterHandlers: registerUpdaterHandlersMock
|
||||
}))
|
||||
|
|
@ -353,6 +359,7 @@ describe('registerCoreHandlers', () => {
|
|||
registerAgentHookHandlersMock.mockReset()
|
||||
registerAgentTrustHandlersMock.mockReset()
|
||||
registerClaudeAccountHandlersMock.mockReset()
|
||||
registerMiniMaxCredentialsHandlersMock.mockReset()
|
||||
registerClipboardHandlersMock.mockReset()
|
||||
setTrustedClipboardRendererWebContentsIdMock.mockReset()
|
||||
registerUpdaterHandlersMock.mockReset()
|
||||
|
|
@ -418,6 +425,7 @@ describe('registerCoreHandlers', () => {
|
|||
expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerPetHandlersMock).toHaveBeenCalled()
|
||||
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
|
||||
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
|
||||
expect(registerLinearHandlersMock).toHaveBeenCalled()
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import { registerCodexAccountHandlers } from './codex-accounts'
|
|||
import { registerAgentHookHandlers } from './agent-hooks'
|
||||
import { registerAgentTrustHandlers } from './agent-trust'
|
||||
import { registerClaudeAccountHandlers } from './claude-accounts'
|
||||
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
|
||||
import { registerUpdaterHandlers } from '../window/attach-main-window-services'
|
||||
import {
|
||||
registerClipboardHandlers,
|
||||
|
|
@ -117,6 +118,7 @@ export function registerCoreHandlers(
|
|||
registerAgentHookHandlers(runtime)
|
||||
registerAgentTrustHandlers()
|
||||
registerClaudeAccountHandlers(claudeAccounts)
|
||||
registerMiniMaxCredentialsHandlers(rateLimits)
|
||||
registerRateLimitHandlers(rateLimits)
|
||||
registerGitHubHandlers(store, stats)
|
||||
registerGitLabHandlers(store)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as MiniMaxCookieStore from './minimax-cookie-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 mkdirSyncMock = vi.fn()
|
||||
const readFileSyncMock = vi.fn()
|
||||
const rmSyncMock = vi.fn()
|
||||
const writeFileSyncMock = vi.fn()
|
||||
const homedirMock = vi.fn(() => '/home/test')
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
mkdirSync: mkdirSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
rmSync: rmSyncMock,
|
||||
writeFileSync: writeFileSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: homedirMock
|
||||
}))
|
||||
|
||||
vi.mock('node:path', () => ({
|
||||
join: (...parts: string[]) => parts.join('/')
|
||||
}))
|
||||
|
||||
const storePath = '/home/test/.orca/minimax-session-cookie.enc'
|
||||
|
||||
async function loadStore(): Promise<typeof MiniMaxCookieStore> {
|
||||
return await import('./minimax-cookie-store')
|
||||
}
|
||||
|
||||
describe('minimax-cookie-store', () => {
|
||||
beforeEach(() => {
|
||||
existsSyncMock.mockReset()
|
||||
mkdirSyncMock.mockReset()
|
||||
readFileSyncMock.mockReset()
|
||||
rmSyncMock.mockReset()
|
||||
writeFileSyncMock.mockReset()
|
||||
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'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('returns false when no file exists yet', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
expect(store.hasMiniMaxSessionCookie()).toBe(false)
|
||||
})
|
||||
|
||||
it('writes the cookie using safeStorage when encryption is available', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
store.saveMiniMaxSessionCookie('_token=abc; minimax_group_id_v2=42')
|
||||
expect(safeStorageMock.encryptString).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
||||
storePath,
|
||||
Buffer.from('_token=abc; minimax_group_id_v2=42'),
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
expect(mkdirSyncMock).toHaveBeenCalledWith('/home/test/.orca', { recursive: true })
|
||||
})
|
||||
|
||||
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.saveMiniMaxSessionCookie('_token=abc')
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(storePath, '_token=abc', {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('refuses empty cookies', async () => {
|
||||
const store = await loadStore()
|
||||
expect(() => store.saveMiniMaxSessionCookie(' ')).toThrow(/required/)
|
||||
})
|
||||
|
||||
it('reads decrypted cookie from disk and caches it', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload'))
|
||||
safeStorageMock.decryptString.mockReturnValue('_token=cached; minimax_group_id_v2=9')
|
||||
const store = await loadStore()
|
||||
const first = store.readMiniMaxSessionCookie()
|
||||
const second = store.readMiniMaxSessionCookie()
|
||||
expect(first).toBe('_token=cached; minimax_group_id_v2=9')
|
||||
expect(second).toBe(first)
|
||||
expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns null when no file exists', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns plaintext when safeStorage is unavailable and reads succeed', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('_token=plaintext'))
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=plaintext')
|
||||
})
|
||||
|
||||
it('throws when decryption fails', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload'))
|
||||
safeStorageMock.decryptString.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const store = await loadStore()
|
||||
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
|
||||
})
|
||||
|
||||
it('clears the cached cookie and removes the file', async () => {
|
||||
existsSyncMock.mockReturnValueOnce(true)
|
||||
readFileSyncMock.mockReturnValueOnce(Buffer.from('encrypted-payload'))
|
||||
safeStorageMock.decryptString.mockReturnValueOnce('_token=preclear')
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=preclear')
|
||||
store.clearMiniMaxSessionCookie()
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
|
||||
expect(store.readMiniMaxSessionCookie()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { safeStorage } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc'
|
||||
let cachedMiniMaxCookie: string | null = null
|
||||
|
||||
function getOrcaDir(): string {
|
||||
return join(homedir(), '.orca')
|
||||
}
|
||||
|
||||
function getMiniMaxCookiePath(): string {
|
||||
return join(getOrcaDir(), MINIMAX_COOKIE_FILE)
|
||||
}
|
||||
|
||||
function ensureOrcaDir(): void {
|
||||
const dir = getOrcaDir()
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function hasMiniMaxSessionCookie(): boolean {
|
||||
return existsSync(getMiniMaxCookiePath())
|
||||
}
|
||||
|
||||
export function saveMiniMaxSessionCookie(cookie: string): void {
|
||||
const trimmed = cookie.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error('MiniMax session cookie is required')
|
||||
}
|
||||
ensureOrcaDir()
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
writeFileSync(getMiniMaxCookiePath(), safeStorage.encryptString(trimmed), { mode: 0o600 })
|
||||
cachedMiniMaxCookie = trimmed
|
||||
return
|
||||
}
|
||||
console.warn('[minimax] safeStorage encryption unavailable — storing MiniMax cookie in plaintext')
|
||||
writeFileSync(getMiniMaxCookiePath(), trimmed, { encoding: 'utf8', mode: 0o600 })
|
||||
cachedMiniMaxCookie = trimmed
|
||||
}
|
||||
|
||||
export function readMiniMaxSessionCookie(): string | null {
|
||||
if (cachedMiniMaxCookie !== null) {
|
||||
return cachedMiniMaxCookie
|
||||
}
|
||||
const keyPath = getMiniMaxCookiePath()
|
||||
if (!existsSync(keyPath)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(keyPath)
|
||||
cachedMiniMaxCookie = safeStorage.isEncryptionAvailable()
|
||||
? safeStorage.decryptString(raw)
|
||||
: raw.toString('utf8')
|
||||
return cachedMiniMaxCookie
|
||||
} catch {
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMiniMaxSessionCookie(): void {
|
||||
cachedMiniMaxCookie = null
|
||||
rmSync(getMiniMaxCookiePath(), { force: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted(
|
||||
() => {
|
||||
const netFetchMock = vi.fn()
|
||||
const cookiesSetMock = vi.fn(() => Promise.resolve())
|
||||
const clearStorageDataMock = vi.fn(() => Promise.resolve())
|
||||
const sessionFromPartitionMock = vi.fn(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock }
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
net: { fetch: netFetchMock },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
|
||||
import {
|
||||
extractMiniMaxCookieValue,
|
||||
fetchMiniMaxRateLimits,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret
|
||||
} from './minimax-fetcher'
|
||||
|
||||
const MINIMAX_URL = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains'
|
||||
|
||||
function makeResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body
|
||||
} as Response
|
||||
}
|
||||
|
||||
function makeOkPayload(remainingPercent: number): unknown {
|
||||
const now = Date.now()
|
||||
return {
|
||||
base_resp: { status_code: 0, status_msg: 'ok' },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: remainingPercent,
|
||||
start_time: now - 60_000,
|
||||
end_time: now + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const FULL_COOKIE =
|
||||
'_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3'
|
||||
|
||||
function getCookieJarSetNames(): string[] {
|
||||
return cookiesSetMock.mock.calls.map((call) => {
|
||||
const [details] = call as unknown as [{ name: string }]
|
||||
return details.name
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchMiniMaxRateLimits', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-04T12:00:00.000Z'))
|
||||
clearStorageDataMock.mockClear()
|
||||
cookiesSetMock.mockClear()
|
||||
netFetchMock.mockReset()
|
||||
sessionFromPartitionMock.mockClear()
|
||||
sessionFromPartitionMock.mockImplementation(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns unavailable when cookie is empty', async () => {
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: '' })
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.provider).toBe('minimax')
|
||||
expect(result.session).toBeNull()
|
||||
expect(result.weekly).toBeNull()
|
||||
expect(result.error).toMatch(/not configured/i)
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns unavailable when cookie is only whitespace', async () => {
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: ' ' })
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when cookie has no _token', async () => {
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: '_twpid=tw.123; minimax_group_id_v2=12345'
|
||||
})
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toMatch(/MiniMax auth cookie not found/)
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies 401 as stale-token', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse({}, 401))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('stale-token')
|
||||
expect(result.error).toMatch(/session expired/i)
|
||||
})
|
||||
|
||||
it('classifies 403 as stale-token', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse({}, 403))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('stale-token')
|
||||
})
|
||||
|
||||
it('classifies 500 as server', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse({}, 500))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('server')
|
||||
expect(result.error).toMatch(/500/)
|
||||
})
|
||||
|
||||
it('returns ok with session window mapping remaining to usedPercent', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(35)))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.provider).toBe('minimax')
|
||||
expect(result.session?.usedPercent).toBe(65)
|
||||
expect(result.session?.windowMinutes).toBe(300)
|
||||
expect(result.weekly).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a fixed 5-hour session window regardless of API interval drift', async () => {
|
||||
const startTime = 1_700_000_000_000
|
||||
const endTime = startTime + 295 * 60_000
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
base_resp: { status_code: 0, status_msg: 'ok' },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: 60,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
remains_time: endTime - startTime
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.windowMinutes).toBe(300)
|
||||
expect(result.session?.resetsAt).toBe(endTime)
|
||||
})
|
||||
|
||||
it('does not derive the windowMinutes label from the raw start/end interval', async () => {
|
||||
const startTime = 1_700_000_000_000
|
||||
const endTime = startTime + 4 * 60 * 60_000
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
base_resp: { status_code: 0, status_msg: 'ok' },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: 63,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
remains_time: endTime - startTime
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.windowMinutes).toBe(300)
|
||||
expect(result.session?.usedPercent).toBe(37)
|
||||
expect(result.session?.resetsAt).toBe(endTime)
|
||||
})
|
||||
|
||||
it('sets the cookie jar and sends browser-like request headers', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledWith({
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
expect(getCookieJarSetNames()).toEqual([
|
||||
'_token',
|
||||
'_twpid',
|
||||
'minimax_group_id_v2',
|
||||
'platform_cookie_consent'
|
||||
])
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = netFetchMock.mock.calls[0]
|
||||
expect(url).toBe(MINIMAX_URL)
|
||||
expect(init.method).toBe('GET')
|
||||
expect(init.headers.Cookie).toBeUndefined()
|
||||
expect(init.headers['X-Group-Id']).toBe('12345')
|
||||
expect(init.headers.Referer).toBe('https://platform.minimax.io/console/usage')
|
||||
expect(init.headers.Accept).toMatch(/application\/json/)
|
||||
expect(init.headers['Accept-Language']).toBe('en-US,en;q=0.9')
|
||||
expect(init.headers['Sec-Fetch-Dest']).toBeUndefined()
|
||||
expect(init.headers['Sec-Fetch-Mode']).toBeUndefined()
|
||||
expect(init.headers['Sec-Fetch-Site']).toBeUndefined()
|
||||
expect(init.headers['User-Agent']).toMatch(/^Mozilla\/5\.0/)
|
||||
expect(init.headers['User-Agent']).not.toContain('orca-minimax-usage')
|
||||
})
|
||||
|
||||
it('accepts quoted MiniMax cookie storage syntax', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({
|
||||
cookie: '_token:"jwt-token" minimax_group_id_v2:"42"'
|
||||
})
|
||||
const [, init] = netFetchMock.mock.calls[0]
|
||||
expect(getCookieJarSetNames()).toEqual(['_token', 'minimax_group_id_v2'])
|
||||
expect(init.headers.Cookie).toBeUndefined()
|
||||
expect(init.headers['X-Group-Id']).toBe('42')
|
||||
})
|
||||
|
||||
it('preserves the complete browser Cookie header', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
const fullBrowserCookie = [
|
||||
'platform_cookie_consent=3',
|
||||
'_ga=analytics',
|
||||
'_token=tok',
|
||||
'_twpid=tw.1',
|
||||
'ak_bmsc=ak',
|
||||
'bm_sv=sv',
|
||||
'bm_sz=sz',
|
||||
'_abck=ab',
|
||||
'minimax_group_id_v2=42',
|
||||
'sensorsdata2015jssdkcross=analytics'
|
||||
].join('; ')
|
||||
await fetchMiniMaxRateLimits({
|
||||
cookie: fullBrowserCookie
|
||||
})
|
||||
const [, init] = netFetchMock.mock.calls[0]
|
||||
expect(getCookieJarSetNames()).toEqual([
|
||||
'platform_cookie_consent',
|
||||
'_ga',
|
||||
'_token',
|
||||
'_twpid',
|
||||
'ak_bmsc',
|
||||
'bm_sv',
|
||||
'bm_sz',
|
||||
'_abck',
|
||||
'minimax_group_id_v2',
|
||||
'sensorsdata2015jssdkcross'
|
||||
])
|
||||
expect(init.headers.Cookie).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers explicit groupId over cookie value', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: 'override-id' })
|
||||
expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('override-id')
|
||||
})
|
||||
|
||||
it('falls back to cookie minimax_group_id_v2 when groupId is empty', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: '' })
|
||||
expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('12345')
|
||||
})
|
||||
|
||||
it('surfaces base_resp status_code != 0 as usage-unavailable', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 401, status_msg: 'unauth' } })
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toContain('unauth')
|
||||
})
|
||||
|
||||
it('returns error when model_remains is empty', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toMatch(/configured model was not found/i)
|
||||
})
|
||||
|
||||
it('returns error when configured model is not in response', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: FULL_COOKIE,
|
||||
models: 'unrelated-model'
|
||||
})
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toMatch(/configured model was not found/i)
|
||||
})
|
||||
|
||||
it('falls back to the lone snapshot when no configured model matches', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(40)))
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: FULL_COOKIE,
|
||||
models: 'unrelated-model'
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(60)
|
||||
})
|
||||
|
||||
it('selects the first configured model when multiple are listed', async () => {
|
||||
const payload = makeOkPayload(40)
|
||||
;(payload as { model_remains: unknown[] }).model_remains = [
|
||||
{
|
||||
model_name: 'unrelated',
|
||||
current_interval_remaining_percent: 10,
|
||||
start_time: Date.now() - 60_000,
|
||||
end_time: Date.now() + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
},
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: 40,
|
||||
start_time: Date.now() - 60_000,
|
||||
end_time: Date.now() + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
}
|
||||
]
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(payload))
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: FULL_COOKIE,
|
||||
models: 'general'
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(60)
|
||||
})
|
||||
|
||||
it('redacts _token in any error path that includes payload text', async () => {
|
||||
const payload = { base_resp: { status_code: 7, status_msg: FULL_COOKIE } }
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(payload))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.error).not.toContain('eyJh')
|
||||
expect(result.error).not.toContain('minimax_group_id_v2=12345')
|
||||
expect(result.error).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
it('logs MiniMax failures with cookie names but without cookie values', async () => {
|
||||
const warn = vi.mocked(console.warn)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 7, status_msg: FULL_COOKIE } })
|
||||
)
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[minimax] usage fetch failed',
|
||||
expect.objectContaining({
|
||||
baseRespStatusMsg: expect.not.stringContaining('eyJh'),
|
||||
cookieNames: ['_token', '_twpid', 'minimax_group_id_v2', 'platform_cookie_consent']
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeMiniMaxCookieHeader', () => {
|
||||
it('preserves all cookie pairs from a browser Cookie header', () => {
|
||||
const normalized = normalizeMiniMaxCookieHeader(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
expect(normalized).toBe(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes quoted MiniMax cookie storage syntax', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe(
|
||||
'_token=tok; minimax_group_id_v2=42'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a copied Cookie header line', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe(
|
||||
'session=abc; other=xyz'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractMiniMaxCookieValue', () => {
|
||||
it('returns the value for a given cookie name', () => {
|
||||
expect(extractMiniMaxCookieValue(FULL_COOKIE, 'minimax_group_id_v2')).toBe('12345')
|
||||
})
|
||||
it('returns null when the name is absent', () => {
|
||||
expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactMiniMaxSecret', () => {
|
||||
it('redacts _token values', () => {
|
||||
expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).toContain('_token=[REDACTED]')
|
||||
expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).not.toContain('eyJhABCDEF')
|
||||
})
|
||||
it('redacts minimax_group_id_v2 values', () => {
|
||||
expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999')
|
||||
})
|
||||
it('redacts MiniMax anti-bot cookie values', () => {
|
||||
const redacted = redactMiniMaxSecret('ak_bmsc=secret bm_sv:"secret2"')
|
||||
expect(redacted).not.toContain('secret')
|
||||
expect(redacted).toContain('ak_bmsc=[REDACTED]')
|
||||
expect(redacted).toContain('bm_sv:[REDACTED]')
|
||||
})
|
||||
it('redacts Cookie: header lines', () => {
|
||||
expect(redactMiniMaxSecret('X-Cookie: _token=secret')).toContain('[REDACTED]')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
import {
|
||||
extractMiniMaxCookieValue,
|
||||
fetchMiniMaxWithManualCookieHeader,
|
||||
fetchMiniMaxWithSessionCookieJar,
|
||||
getUniqueMiniMaxCookieNames,
|
||||
logMiniMaxFetchFailure,
|
||||
makeMiniMaxRequestHeaders,
|
||||
MINIMAX_USAGE_ENDPOINT,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret,
|
||||
type MiniMaxFetchResponse
|
||||
} from './minimax-request-context'
|
||||
|
||||
export {
|
||||
extractMiniMaxCookieValue,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret
|
||||
} from './minimax-request-context'
|
||||
|
||||
const API_TIMEOUT_MS = 15_000
|
||||
|
||||
type MiniMaxUsageItem = {
|
||||
model_name?: unknown
|
||||
current_interval_remaining_percent?: unknown
|
||||
start_time?: unknown
|
||||
end_time?: unknown
|
||||
remains_time?: unknown
|
||||
}
|
||||
|
||||
type MiniMaxUsageResponse = {
|
||||
base_resp?: {
|
||||
status_code?: unknown
|
||||
status_msg?: unknown
|
||||
}
|
||||
model_remains?: MiniMaxUsageItem[]
|
||||
}
|
||||
|
||||
type MiniMaxUsageSnapshot = {
|
||||
modelName: string
|
||||
window: RateLimitWindow
|
||||
}
|
||||
|
||||
export type FetchMiniMaxRateLimitsOptions = {
|
||||
cookie: string
|
||||
groupId?: string | null
|
||||
models?: string | readonly string[] | null
|
||||
endpoint?: string
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(value)))
|
||||
}
|
||||
|
||||
function makeUnavailable(error: string): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'minimax',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error,
|
||||
status: 'unavailable',
|
||||
usageMetadata: { failureKind: 'missing-credentials', source: 'web' }
|
||||
}
|
||||
}
|
||||
|
||||
function makeError(
|
||||
error: string,
|
||||
failureKind: NonNullable<ProviderRateLimits['usageMetadata']>['failureKind']
|
||||
): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'minimax',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error,
|
||||
status: 'error',
|
||||
usageMetadata: { failureKind, source: 'web' }
|
||||
}
|
||||
}
|
||||
|
||||
function parseModels(models: FetchMiniMaxRateLimitsOptions['models']): string[] {
|
||||
if (Array.isArray(models)) {
|
||||
return models.map((model) => model.trim()).filter(Boolean)
|
||||
}
|
||||
if (typeof models === 'string') {
|
||||
return models
|
||||
.split(',')
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
return ['general']
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: MiniMax's API returns `end_time - start_time` that can drift below the
|
||||
// 5-hour bucket (e.g. 4h or 295 min). The UI labels must reflect the contracted
|
||||
// session — a fixed 5-hour window — so the status bar reads "5h" regardless of
|
||||
// what the API reports. Mirrors how Codex always reports 300/10080 minutes.
|
||||
const MINIMAX_SESSION_WINDOW_MINUTES = 300
|
||||
|
||||
function parseUsageItem(item: MiniMaxUsageItem): MiniMaxUsageSnapshot | null {
|
||||
const modelName = typeof item.model_name === 'string' ? item.model_name : null
|
||||
const remainingPercent = asNumber(item.current_interval_remaining_percent)
|
||||
const startTime = asNumber(item.start_time)
|
||||
const endTime = asNumber(item.end_time)
|
||||
if (!modelName || remainingPercent === null || startTime === null || endTime === null) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
modelName,
|
||||
window: {
|
||||
usedPercent: clampPercent(100 - remainingPercent),
|
||||
windowMinutes: MINIMAX_SESSION_WINDOW_MINUTES,
|
||||
resetsAt: endTime,
|
||||
resetDescription: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectSnapshot(
|
||||
snapshots: MiniMaxUsageSnapshot[],
|
||||
preferredModels: string[]
|
||||
): MiniMaxUsageSnapshot | null {
|
||||
for (const model of preferredModels) {
|
||||
const match = snapshots.find((snapshot) => snapshot.modelName === model)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return snapshots.length === 1 ? snapshots[0] : null
|
||||
}
|
||||
|
||||
async function fetchMiniMaxResponse(args: {
|
||||
cookie: string
|
||||
endpoint: string
|
||||
groupId: string | null
|
||||
signal: AbortSignal
|
||||
}): Promise<MiniMaxFetchResponse> {
|
||||
try {
|
||||
return await fetchMiniMaxWithSessionCookieJar(args)
|
||||
} catch (sessionFetchError) {
|
||||
const message =
|
||||
sessionFetchError instanceof Error ? sessionFetchError.message : String(sessionFetchError)
|
||||
console.warn(
|
||||
'[minimax] session cookie jar fetch failed; falling back to manual Cookie header',
|
||||
{
|
||||
error: redactMiniMaxSecret(message),
|
||||
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
|
||||
requestHeaderNames: Object.keys(makeMiniMaxRequestHeaders(args.groupId))
|
||||
}
|
||||
)
|
||||
return await fetchMiniMaxWithManualCookieHeader(args)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMiniMaxHttpError(fetchResult: MiniMaxFetchResponse): ProviderRateLimits | null {
|
||||
const { response } = fetchResult
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: fetchResult.transport,
|
||||
responseStatus: response.status,
|
||||
cookieNames: fetchResult.cookieNames,
|
||||
requestHeaderNames: fetchResult.requestHeaderNames
|
||||
})
|
||||
return makeError(
|
||||
'MiniMax session expired. Replace the MiniMax cookie in Settings.',
|
||||
'stale-token'
|
||||
)
|
||||
}
|
||||
if (!response.ok) {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: fetchResult.transport,
|
||||
responseStatus: response.status,
|
||||
cookieNames: fetchResult.cookieNames,
|
||||
requestHeaderNames: fetchResult.requestHeaderNames
|
||||
})
|
||||
return makeError(`MiniMax usage fetch failed (${response.status})`, 'server')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function handleMiniMaxPayloadError(
|
||||
fetchResult: MiniMaxFetchResponse,
|
||||
payload: MiniMaxUsageResponse
|
||||
): ProviderRateLimits | null {
|
||||
const statusCode = payload.base_resp?.status_code
|
||||
if (statusCode === undefined || statusCode === 0) {
|
||||
return null
|
||||
}
|
||||
logMiniMaxFetchFailure({
|
||||
transport: fetchResult.transport,
|
||||
responseStatus: fetchResult.response.status,
|
||||
statusCode,
|
||||
statusMsg: payload.base_resp?.status_msg,
|
||||
cookieNames: fetchResult.cookieNames,
|
||||
requestHeaderNames: fetchResult.requestHeaderNames
|
||||
})
|
||||
const message =
|
||||
typeof payload.base_resp?.status_msg === 'string'
|
||||
? payload.base_resp.status_msg
|
||||
: 'MiniMax returned an error'
|
||||
return makeError(redactMiniMaxSecret(message), 'usage-unavailable')
|
||||
}
|
||||
|
||||
export async function fetchMiniMaxRateLimits(
|
||||
options: FetchMiniMaxRateLimitsOptions
|
||||
): Promise<ProviderRateLimits> {
|
||||
const rawCookie = options.cookie.trim()
|
||||
if (!rawCookie) {
|
||||
return makeUnavailable('MiniMax session cookie not configured')
|
||||
}
|
||||
const cookie = normalizeMiniMaxCookieHeader(rawCookie)
|
||||
if (!extractMiniMaxCookieValue(cookie, '_token')) {
|
||||
return makeError(
|
||||
'MiniMax auth cookie not found — paste a Cookie header with _token',
|
||||
'missing-credentials'
|
||||
)
|
||||
}
|
||||
const groupId =
|
||||
options.groupId?.trim() || extractMiniMaxCookieValue(cookie, 'minimax_group_id_v2')
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
|
||||
try {
|
||||
const fetchResult = await fetchMiniMaxResponse({
|
||||
cookie,
|
||||
endpoint: options.endpoint ?? MINIMAX_USAGE_ENDPOINT,
|
||||
groupId,
|
||||
signal: controller.signal
|
||||
})
|
||||
const httpError = handleMiniMaxHttpError(fetchResult)
|
||||
if (httpError) {
|
||||
return httpError
|
||||
}
|
||||
const payload = (await fetchResult.response.json()) as MiniMaxUsageResponse
|
||||
const payloadError = handleMiniMaxPayloadError(fetchResult, payload)
|
||||
if (payloadError) {
|
||||
return payloadError
|
||||
}
|
||||
const snapshots = (payload.model_remains ?? [])
|
||||
.map(parseUsageItem)
|
||||
.filter((snapshot): snapshot is MiniMaxUsageSnapshot => snapshot !== null)
|
||||
const selected = selectSnapshot(snapshots, parseModels(options.models))
|
||||
if (!selected) {
|
||||
return makeError(
|
||||
'MiniMax usage data for the configured model was not found',
|
||||
'usage-unavailable'
|
||||
)
|
||||
}
|
||||
return {
|
||||
provider: 'minimax',
|
||||
session: selected.window,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok',
|
||||
usageMetadata: { source: 'web' }
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown MiniMax usage error'
|
||||
const failureKind = message.toLowerCase().includes('json') ? 'parse' : 'network'
|
||||
return makeError(redactMiniMaxSecret(message), failureKind)
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted(
|
||||
() => {
|
||||
const netFetchMock = vi.fn()
|
||||
const cookiesSetMock = vi.fn(() => Promise.resolve())
|
||||
const clearStorageDataMock = vi.fn(() => Promise.resolve())
|
||||
const sessionFromPartitionMock = vi.fn(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock }
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
net: { fetch: netFetchMock },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
|
||||
import {
|
||||
extractMiniMaxCookieValue,
|
||||
fetchMiniMaxWithManualCookieHeader,
|
||||
fetchMiniMaxWithSessionCookieJar,
|
||||
getUniqueMiniMaxCookieNames,
|
||||
logMiniMaxFetchFailure,
|
||||
makeMiniMaxRequestHeaders,
|
||||
MINIMAX_USAGE_ENDPOINT,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret
|
||||
} from './minimax-request-context'
|
||||
|
||||
const FULL_COOKIE =
|
||||
'_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3'
|
||||
|
||||
describe('normalizeMiniMaxCookieHeader', () => {
|
||||
it('preserves all cookie pairs from a browser Cookie header', () => {
|
||||
const normalized = normalizeMiniMaxCookieHeader(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
expect(normalized).toBe(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes quoted MiniMax cookie storage syntax', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe(
|
||||
'_token=tok; minimax_group_id_v2=42'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a copied Cookie header line with prefix', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe(
|
||||
'session=abc; other=xyz'
|
||||
)
|
||||
})
|
||||
|
||||
it('merges header and quoted forms without dropping any pair', () => {
|
||||
const mixed = '_token=header-quoted _twpid:"q-1"; minimax_group_id_v2=42'
|
||||
const normalized = normalizeMiniMaxCookieHeader(mixed)
|
||||
expect(normalized).toContain('_token=header-quoted')
|
||||
expect(normalized).toContain('_twpid=q-1')
|
||||
expect(normalized).toContain('minimax_group_id_v2=42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractMiniMaxCookieValue', () => {
|
||||
it('returns the value for a known name', () => {
|
||||
expect(extractMiniMaxCookieValue(FULL_COOKIE, '_token')).toBe('eyJh.eyJ.payload')
|
||||
})
|
||||
|
||||
it('returns null when the name is absent', () => {
|
||||
expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles Chromium quoted syntax', () => {
|
||||
expect(
|
||||
extractMiniMaxCookieValue('_token:"jwt" minimax_group_id_v2:"42"', 'minimax_group_id_v2')
|
||||
).toBe('42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUniqueMiniMaxCookieNames', () => {
|
||||
it('deduplicates repeated names', () => {
|
||||
const names = getUniqueMiniMaxCookieNames(
|
||||
'_token=a; _token=b; minimax_group_id_v2=42; minimax_group_id_v2=99'
|
||||
)
|
||||
expect(names).toEqual(['_token', 'minimax_group_id_v2'])
|
||||
})
|
||||
|
||||
it('returns names from a quoted-only Cookie storage export', () => {
|
||||
const names = getUniqueMiniMaxCookieNames(
|
||||
'_token:"jwt" minimax_group_id_v2:"42" platform_cookie_consent:"3"'
|
||||
)
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining(['_token', 'minimax_group_id_v2', 'platform_cookie_consent'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactMiniMaxSecret', () => {
|
||||
it('redacts _token values in header and quoted syntax', () => {
|
||||
const redacted = redactMiniMaxSecret('cookie _token=eyJhABCDEF and _token:"x.y"')
|
||||
expect(redacted).toContain('_token=[REDACTED]')
|
||||
expect(redacted).not.toContain('eyJhABCDEF')
|
||||
expect(redacted).not.toContain('x.y')
|
||||
})
|
||||
|
||||
it('redacts minimax_group_id_v2', () => {
|
||||
expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999')
|
||||
})
|
||||
|
||||
it('redacts MiniMax anti-bot cookie values', () => {
|
||||
const redacted = redactMiniMaxSecret('ak_bmsc=zzzsecret bm_sv:"yyyvalue" _abck="xxxdata"')
|
||||
expect(redacted).not.toContain('zzzsecret')
|
||||
expect(redacted).not.toContain('yyyvalue')
|
||||
expect(redacted).not.toContain('xxxdata')
|
||||
expect(redacted).toContain('ak_bmsc=[REDACTED]')
|
||||
expect(redacted).toContain('bm_sv:[REDACTED]')
|
||||
expect(redacted).toContain('_abck=[REDACTED]')
|
||||
})
|
||||
|
||||
it('redacts full Cookie: header lines', () => {
|
||||
const redacted = redactMiniMaxSecret('Header line\nCookie: _token=secret\nFooter')
|
||||
expect(redacted).toContain('Cookie: [REDACTED]')
|
||||
expect(redacted).not.toContain('Cookie: _token=secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeMiniMaxRequestHeaders', () => {
|
||||
it('always includes browser-like Accept, Accept-Language, Referer, and User-Agent', () => {
|
||||
const headers = makeMiniMaxRequestHeaders(null)
|
||||
expect(headers.Accept).toMatch(/application\/json/)
|
||||
expect(headers['Accept-Language']).toBe('en-US,en;q=0.9')
|
||||
expect(headers.Referer).toBe('https://platform.minimax.io/console/usage')
|
||||
expect(headers['User-Agent']).toMatch(/^Mozilla\/5\.0/)
|
||||
expect(headers['User-Agent']).not.toContain('orca-minimax-usage')
|
||||
})
|
||||
|
||||
it('omits X-Group-Id when groupId is null', () => {
|
||||
const headers = makeMiniMaxRequestHeaders(null)
|
||||
expect(headers['X-Group-Id']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits X-Group-Id when groupId is empty string', () => {
|
||||
const headers = makeMiniMaxRequestHeaders('')
|
||||
expect(headers['X-Group-Id']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes X-Group-Id when groupId is provided', () => {
|
||||
const headers = makeMiniMaxRequestHeaders('2034972027806299092')
|
||||
expect(headers['X-Group-Id']).toBe('2034972027806299092')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMiniMaxWithSessionCookieJar', () => {
|
||||
beforeEach(() => {
|
||||
clearStorageDataMock.mockClear()
|
||||
cookiesSetMock.mockClear()
|
||||
netFetchMock.mockReset()
|
||||
sessionFromPartitionMock.mockClear()
|
||||
sessionFromPartitionMock.mockImplementation(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses a dedicated MiniMax partition and clears cookies before fetching', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await fetchMiniMaxWithSessionCookieJar({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledWith({
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
})
|
||||
|
||||
it('sets every cookie pair onto the session jar with secure + path /', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await fetchMiniMaxWithSessionCookieJar({
|
||||
cookie: '_token=tok; ak_bmsc=ak; minimax_group_id_v2=42',
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: null,
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(cookiesSetMock).toHaveBeenCalledTimes(3)
|
||||
const setDetails = cookiesSetMock.mock.calls.map((call) => {
|
||||
const [details] = call as unknown as [
|
||||
{ name: string; value: string; secure: boolean; path: string }
|
||||
]
|
||||
return details
|
||||
})
|
||||
expect(setDetails).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: '_token', value: 'tok', secure: true, path: '/' }),
|
||||
expect.objectContaining({ name: 'ak_bmsc', value: 'ak', secure: true, path: '/' }),
|
||||
expect.objectContaining({
|
||||
name: 'minimax_group_id_v2',
|
||||
value: '42',
|
||||
secure: true,
|
||||
path: '/'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the transport name as session-cookie-jar on success', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const result = await fetchMiniMaxWithSessionCookieJar({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(result.transport).toBe('session-cookie-jar')
|
||||
expect(result.cookieNames).toEqual([
|
||||
'_token',
|
||||
'_twpid',
|
||||
'minimax_group_id_v2',
|
||||
'platform_cookie_consent'
|
||||
])
|
||||
expect(result.requestHeaderNames).toContain('X-Group-Id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMiniMaxWithManualCookieHeader', () => {
|
||||
beforeEach(() => {
|
||||
netFetchMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('attaches the full Cookie header and X-Group-Id via net.fetch', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const result = await fetchMiniMaxWithManualCookieHeader({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(result.transport).toBe('manual-cookie-header')
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = netFetchMock.mock.calls[0]
|
||||
expect(url).toBe(MINIMAX_USAGE_ENDPOINT)
|
||||
expect(init.method).toBe('GET')
|
||||
expect(init.headers.Cookie).toBe(FULL_COOKIE)
|
||||
expect(init.headers['X-Group-Id']).toBe('12345')
|
||||
})
|
||||
|
||||
it('omits X-Group-Id when groupId is null', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await fetchMiniMaxWithManualCookieHeader({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: null,
|
||||
signal: controller.signal
|
||||
})
|
||||
const [, init] = netFetchMock.mock.calls[0]
|
||||
expect(init.headers['X-Group-Id']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('logMiniMaxFetchFailure', () => {
|
||||
let warn: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('logs structured fields without leaking cookie values', () => {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: 'session-cookie-jar',
|
||||
responseStatus: 200,
|
||||
statusCode: 7,
|
||||
statusMsg: '_token=eyJhABCDEF ak_bmsc=secretvalue',
|
||||
cookieNames: ['_token', 'ak_bmsc'],
|
||||
requestHeaderNames: ['Accept', 'X-Group-Id']
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[minimax] usage fetch failed',
|
||||
expect.objectContaining({
|
||||
transport: 'session-cookie-jar',
|
||||
responseStatus: 200,
|
||||
baseRespStatusCode: 7,
|
||||
baseRespStatusMsg: expect.not.stringContaining('eyJhABCDEF'),
|
||||
cookieNames: ['_token', 'ak_bmsc'],
|
||||
requestHeaderNames: ['Accept', 'X-Group-Id']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('logs without redacting when statusMsg is not a string', () => {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: 'manual-cookie-header',
|
||||
responseStatus: 401,
|
||||
statusCode: undefined,
|
||||
statusMsg: undefined,
|
||||
cookieNames: ['_token'],
|
||||
requestHeaderNames: ['Accept']
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[minimax] usage fetch failed',
|
||||
expect.objectContaining({
|
||||
transport: 'manual-cookie-header',
|
||||
responseStatus: 401,
|
||||
baseRespStatusCode: undefined,
|
||||
baseRespStatusMsg: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import { net, session } from 'electron'
|
||||
|
||||
export const MINIMAX_USAGE_ENDPOINT =
|
||||
'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains'
|
||||
|
||||
const MINIMAX_ORIGIN = 'https://platform.minimax.io'
|
||||
const MINIMAX_REFERER = 'https://platform.minimax.io/console/usage'
|
||||
const MINIMAX_SESSION_PARTITION = 'orca-minimax-rate-limit-fetch'
|
||||
const SENSITIVE_COOKIE_NAMES = new Set([
|
||||
'_token',
|
||||
'_twpid',
|
||||
'_abck',
|
||||
'ak_bmsc',
|
||||
'bm_mi',
|
||||
'bm_sv',
|
||||
'bm_sz',
|
||||
'minimax_group_id_v2'
|
||||
])
|
||||
|
||||
export type MiniMaxFetchTransport = 'session-cookie-jar' | 'manual-cookie-header'
|
||||
|
||||
export type MiniMaxFetchResponse = {
|
||||
response: Response
|
||||
requestHeaderNames: string[]
|
||||
cookieNames: string[]
|
||||
transport: MiniMaxFetchTransport
|
||||
}
|
||||
|
||||
function getMiniMaxBrowserUserAgent(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0'
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0'
|
||||
}
|
||||
return 'Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0'
|
||||
}
|
||||
|
||||
function parseCookiePairs(cookie: string): { name: string; value: string }[] {
|
||||
const headerPairs = cookie
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.map((part) => {
|
||||
const normalizedPart = part.replace(/^Cookie:\s*/i, '')
|
||||
const eq = normalizedPart.indexOf('=')
|
||||
if (eq < 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
name: normalizedPart.slice(0, eq).trim(),
|
||||
value: normalizedPart.slice(eq + 1).trim()
|
||||
}
|
||||
})
|
||||
.filter((pair): pair is { name: string; value: string } => Boolean(pair?.name && pair.value))
|
||||
// Why: Chromium cookie storage exports are often copied as `name:"value"`,
|
||||
// not as an HTTP `Cookie` header. Accept both formats to avoid credential UX traps.
|
||||
const quotedCookiePairPattern = /(?:^|[;\s])([A-Za-z0-9_.-]+)\s*:\s*["']([^"']+)["']/g
|
||||
const quotedPairs = [...cookie.matchAll(quotedCookiePairPattern)]
|
||||
.map((match) => {
|
||||
const [, name = '', value = ''] = match
|
||||
return { name: name.trim(), value: value.trim() }
|
||||
})
|
||||
.filter((pair) => pair.name && pair.value)
|
||||
return [...headerPairs, ...quotedPairs]
|
||||
}
|
||||
|
||||
export function extractMiniMaxCookieValue(cookie: string, name: string): string | null {
|
||||
return parseCookiePairs(cookie).find((pair) => pair.name === name)?.value ?? null
|
||||
}
|
||||
|
||||
export function normalizeMiniMaxCookieHeader(cookie: string): string {
|
||||
return parseCookiePairs(cookie)
|
||||
.map((pair) => `${pair.name}=${pair.value}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
export function getUniqueMiniMaxCookieNames(cookie: string): string[] {
|
||||
return [...new Set(parseCookiePairs(cookie).map((pair) => pair.name))]
|
||||
}
|
||||
|
||||
export function redactMiniMaxSecret(value: string): string {
|
||||
let redacted = value.replace(/Cookie:\s*[^\n\r]+/gi, 'Cookie: [REDACTED]')
|
||||
for (const name of SENSITIVE_COOKIE_NAMES) {
|
||||
redacted = redacted
|
||||
.replace(new RegExp(`${name}=([^;\\s]+)`, 'g'), `${name}=[REDACTED]`)
|
||||
.replace(new RegExp(`${name}:["'][^"']+["']`, 'g'), `${name}:[REDACTED]`)
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
export function makeMiniMaxRequestHeaders(groupId: string | null): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
Referer: MINIMAX_REFERER,
|
||||
'User-Agent': getMiniMaxBrowserUserAgent()
|
||||
}
|
||||
if (groupId) {
|
||||
headers['X-Group-Id'] = groupId
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function fetchMiniMaxWithSessionCookieJar(args: {
|
||||
cookie: string
|
||||
endpoint: string
|
||||
groupId: string | null
|
||||
signal: AbortSignal
|
||||
}): Promise<MiniMaxFetchResponse> {
|
||||
const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
|
||||
const cookiePairs = parseCookiePairs(args.cookie)
|
||||
await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] })
|
||||
await Promise.all(
|
||||
cookiePairs.map((pair) =>
|
||||
miniMaxSession.cookies.set({
|
||||
url: MINIMAX_ORIGIN,
|
||||
name: pair.name,
|
||||
value: pair.value,
|
||||
secure: true,
|
||||
path: '/'
|
||||
})
|
||||
)
|
||||
)
|
||||
const headers = makeMiniMaxRequestHeaders(args.groupId)
|
||||
return {
|
||||
response: await miniMaxSession.fetch(args.endpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: args.signal
|
||||
}),
|
||||
requestHeaderNames: Object.keys(headers),
|
||||
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
|
||||
transport: 'session-cookie-jar'
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMiniMaxWithManualCookieHeader(args: {
|
||||
cookie: string
|
||||
endpoint: string
|
||||
groupId: string | null
|
||||
signal: AbortSignal
|
||||
}): Promise<MiniMaxFetchResponse> {
|
||||
const headers = {
|
||||
...makeMiniMaxRequestHeaders(args.groupId),
|
||||
Cookie: args.cookie
|
||||
}
|
||||
return {
|
||||
response: await net.fetch(args.endpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: args.signal
|
||||
}),
|
||||
requestHeaderNames: Object.keys(headers),
|
||||
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
|
||||
transport: 'manual-cookie-header'
|
||||
}
|
||||
}
|
||||
|
||||
export function logMiniMaxFetchFailure(details: {
|
||||
transport: MiniMaxFetchTransport
|
||||
responseStatus?: number
|
||||
statusCode?: unknown
|
||||
statusMsg?: unknown
|
||||
cookieNames: string[]
|
||||
requestHeaderNames: string[]
|
||||
}): void {
|
||||
console.warn('[minimax] usage fetch failed', {
|
||||
transport: details.transport,
|
||||
responseStatus: details.responseStatus,
|
||||
baseRespStatusCode: details.statusCode,
|
||||
baseRespStatusMsg:
|
||||
typeof details.statusMsg === 'string'
|
||||
? redactMiniMaxSecret(details.statusMsg)
|
||||
: details.statusMsg,
|
||||
cookieNames: details.cookieNames,
|
||||
requestHeaderNames: details.requestHeaderNames
|
||||
})
|
||||
}
|
||||
|
|
@ -9,7 +9,9 @@ import { RateLimitService } from './service'
|
|||
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
|
||||
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
||||
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
|
||||
vi.mock('./claude-fetcher', () => ({
|
||||
fetchClaudeRateLimits: vi.fn(),
|
||||
|
|
@ -28,6 +30,14 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
|
|||
fetchOpenCodeGoRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax-fetcher', () => ({
|
||||
fetchMiniMaxRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../minimax/minimax-cookie-store', () => ({
|
||||
hasMiniMaxSessionCookie: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
|
|
@ -42,7 +52,7 @@ function deferred<T>(): Deferred<T> {
|
|||
}
|
||||
|
||||
function okProvider(
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
|
||||
usedPercent: number,
|
||||
updatedAt = Date.now()
|
||||
): ProviderRateLimits {
|
||||
|
|
@ -62,7 +72,7 @@ function okProvider(
|
|||
}
|
||||
|
||||
function errorProvider(
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
|
||||
message: string
|
||||
): ProviderRateLimits {
|
||||
return {
|
||||
|
|
@ -116,6 +126,8 @@ describe('RateLimitService', () => {
|
|||
vi.clearAllMocks()
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now()))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now()))
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now()))
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => {
|
||||
|
|
@ -1056,4 +1068,77 @@ describe('RateLimitService', () => {
|
|||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('fetches MiniMax alongside other providers when a config resolver is set', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setMiniMaxConfigResolver(() => ({
|
||||
sessionCookie: '_token=abc; minimax_group_id_v2=42',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
}))
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 50, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledWith({
|
||||
cookie: '_token=abc; minimax_group_id_v2=42',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
})
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.minimax?.status).toBe('ok')
|
||||
expect(state.minimax?.session?.usedPercent).toBe(50)
|
||||
expect(state.minimaxCookieConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('reports minimaxCookieConfigured from the cookie store even without a resolver', () => {
|
||||
const service = new RateLimitService()
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
|
||||
expect(service.getState().minimaxCookieConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('discards the previous MiniMax snapshot when its config hash changes', async () => {
|
||||
const service = new RateLimitService()
|
||||
let models = 'general'
|
||||
service.setMiniMaxConfigResolver(() => ({
|
||||
sessionCookie: '_token=abc',
|
||||
groupId: '',
|
||||
models
|
||||
}))
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
|
||||
vi.mocked(fetchMiniMaxRateLimits)
|
||||
.mockResolvedValueOnce(okProvider('minimax', 40, Date.now()))
|
||||
.mockResolvedValueOnce(okProvider('minimax', 10, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
expect(service.getState().minimax?.session?.usedPercent).toBe(40)
|
||||
|
||||
models = 'premium'
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2)
|
||||
expect(state.minimax?.session?.usedPercent).toBe(10)
|
||||
})
|
||||
|
||||
it('isolates MiniMax failures from other providers', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setMiniMaxConfigResolver(() => ({
|
||||
sessionCookie: '_token=abc',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
}))
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockRejectedValueOnce(new Error('minimax down'))
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.minimax?.status).toBe('error')
|
||||
expect(state.minimax?.error).toBe('minimax down')
|
||||
expect(state.claude?.status).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
} from '../claude-accounts/runtime-selection'
|
||||
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
|
||||
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
||||
import {
|
||||
normalizeCodexAccountSelectionTarget,
|
||||
|
|
@ -43,6 +45,12 @@ type OpenCodeGoRateLimitConfig = {
|
|||
workspaceIdOverride: string
|
||||
}
|
||||
|
||||
type MiniMaxRateLimitConfig = {
|
||||
sessionCookie: string
|
||||
groupId: string
|
||||
models: string
|
||||
}
|
||||
|
||||
type GeminiCliOAuthEnabledResolver = () => boolean
|
||||
|
||||
// Why: Claude's subscription usage endpoint has a tight request budget. Quota
|
||||
|
|
@ -64,6 +72,7 @@ type InternalRateLimitState = {
|
|||
gemini: ProviderRateLimits | null
|
||||
opencodeGo: ProviderRateLimits | null
|
||||
kimi: ProviderRateLimits | null
|
||||
minimax: ProviderRateLimits | null
|
||||
}
|
||||
|
||||
function normalizePollingInterval(ms: number): number {
|
||||
|
|
@ -91,7 +100,8 @@ export class RateLimitService {
|
|||
codex: null,
|
||||
gemini: null,
|
||||
opencodeGo: null,
|
||||
kimi: null
|
||||
kimi: null,
|
||||
minimax: null
|
||||
}
|
||||
private pollInterval: number = DEFAULT_POLL_MS
|
||||
private timer: ReturnType<typeof setInterval> | null = null
|
||||
|
|
@ -108,7 +118,9 @@ export class RateLimitService {
|
|||
private codexFetchGeneration = 0
|
||||
private claudeFetchGeneration = 0
|
||||
private opencodeFetchGeneration = 0
|
||||
private minimaxFetchGeneration = 0
|
||||
private lastOpencodeConfigHash = ''
|
||||
private lastMiniMaxConfigHash = ''
|
||||
private codexHomePathResolver: CodexHomePathResolver | null = null
|
||||
private codexFetchTarget: NormalizedCodexAccountSelectionTarget = {
|
||||
runtime: 'host',
|
||||
|
|
@ -120,6 +132,7 @@ export class RateLimitService {
|
|||
wslDistro: null
|
||||
}
|
||||
private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null
|
||||
private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null
|
||||
private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null
|
||||
private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null
|
||||
private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null
|
||||
|
|
@ -163,6 +176,10 @@ export class RateLimitService {
|
|||
this.openCodeGoConfigResolver = resolver
|
||||
}
|
||||
|
||||
setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void {
|
||||
this.miniMaxConfigResolver = resolver
|
||||
}
|
||||
|
||||
setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void {
|
||||
this.geminiCliOAuthEnabledResolver = resolver
|
||||
}
|
||||
|
|
@ -239,6 +256,10 @@ export class RateLimitService {
|
|||
this.pruneInactiveCodexState()
|
||||
return {
|
||||
...this.state,
|
||||
// Why: the cookie lives in the file system, not GlobalSettings. Surface
|
||||
// its presence on the pushed state so the renderer keeps the MiniMax
|
||||
// bar visible across reloads and between snapshot refreshes.
|
||||
minimaxCookieConfigured: hasMiniMaxSessionCookie(),
|
||||
claudeTarget: this.claudeFetchTarget,
|
||||
codexTarget: this.codexFetchTarget,
|
||||
inactiveClaudeAccounts: this.buildInactiveArray(
|
||||
|
|
@ -949,7 +970,7 @@ export class RateLimitService {
|
|||
|
||||
private withFetchingStatus(
|
||||
current: ProviderRateLimits | null,
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax'
|
||||
): ProviderRateLimits {
|
||||
if (!current) {
|
||||
return {
|
||||
|
|
@ -983,6 +1004,10 @@ export class RateLimitService {
|
|||
const openCodeGoConfig = this.openCodeGoConfigResolver?.()
|
||||
const cookie = openCodeGoConfig?.sessionCookie ?? ''
|
||||
const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? ''
|
||||
const miniMaxConfig = this.miniMaxConfigResolver?.()
|
||||
const miniMaxCookie = miniMaxConfig?.sessionCookie ?? ''
|
||||
const miniMaxGroupId = miniMaxConfig?.groupId ?? ''
|
||||
const miniMaxModels = miniMaxConfig?.models ?? 'general'
|
||||
const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false
|
||||
|
||||
// Detect if configuration changed — if it did, we must discard any stale
|
||||
|
|
@ -995,6 +1020,14 @@ export class RateLimitService {
|
|||
}
|
||||
const opencodeGeneration = this.opencodeFetchGeneration
|
||||
|
||||
const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}`
|
||||
const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash
|
||||
if (miniMaxConfigChanged) {
|
||||
this.lastMiniMaxConfigHash = currentMiniMaxConfigHash
|
||||
this.minimaxFetchGeneration += 1
|
||||
}
|
||||
const miniMaxGeneration = this.minimaxFetchGeneration
|
||||
|
||||
// Mark all providers as fetching while keeping previous data visible.
|
||||
// Codex account changes clear Codex separately before this method is
|
||||
// called, so ordinary refreshes still preserve the current values.
|
||||
|
|
@ -1006,13 +1039,16 @@ export class RateLimitService {
|
|||
opencodeGo: opencodeConfigChanged
|
||||
? this.withFetchingStatus(null, 'opencode-go')
|
||||
: this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'),
|
||||
kimi: this.withFetchingStatus(previousState.kimi, 'kimi')
|
||||
kimi: this.withFetchingStatus(previousState.kimi, 'kimi'),
|
||||
minimax: miniMaxConfigChanged
|
||||
? this.withFetchingStatus(null, 'minimax')
|
||||
: this.withFetchingStatus(previousState.minimax, 'minimax')
|
||||
})
|
||||
|
||||
const missingWslCodexHome = codexHomePath
|
||||
? null
|
||||
: this.getMissingWslCodexHomeResult(codexTarget)
|
||||
const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult] =
|
||||
const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] =
|
||||
await Promise.allSettled([
|
||||
fetchClaudeRateLimits({
|
||||
authPreparation: claudeAuthPreparation,
|
||||
|
|
@ -1029,7 +1065,12 @@ export class RateLimitService {
|
|||
}),
|
||||
fetchGeminiRateLimits(geminiCliOAuthEnabled),
|
||||
fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined),
|
||||
fetchKimiRateLimits()
|
||||
fetchKimiRateLimits(),
|
||||
fetchMiniMaxRateLimits({
|
||||
cookie: miniMaxCookie,
|
||||
groupId: miniMaxGroupId,
|
||||
models: miniMaxModels
|
||||
})
|
||||
])
|
||||
|
||||
if (signal.aborted) {
|
||||
|
|
@ -1103,6 +1144,21 @@ export class RateLimitService {
|
|||
status: 'error'
|
||||
} satisfies ProviderRateLimits)
|
||||
|
||||
const miniMax =
|
||||
miniMaxResult.status === 'fulfilled'
|
||||
? miniMaxResult.value
|
||||
: ({
|
||||
provider: 'minimax',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error:
|
||||
miniMaxResult.reason instanceof Error
|
||||
? miniMaxResult.reason.message
|
||||
: 'Unknown error',
|
||||
status: 'error'
|
||||
} satisfies ProviderRateLimits)
|
||||
|
||||
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
|
||||
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
|
||||
if (signal.aborted) {
|
||||
|
|
@ -1117,6 +1173,7 @@ export class RateLimitService {
|
|||
claudeProvenance === latestClaudeProvenance &&
|
||||
this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget)
|
||||
const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration
|
||||
const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration
|
||||
|
||||
// Why: account switches can race in-flight Codex fetches. Only apply a
|
||||
// Codex result if both the selected-account provenance and the request
|
||||
|
|
@ -1136,7 +1193,12 @@ export class RateLimitService {
|
|||
? opencodeGo
|
||||
: this.applyStalePolicy(opencodeGo, previousState.opencodeGo)
|
||||
: this.state.opencodeGo,
|
||||
kimi: this.applyStalePolicy(kimi, previousState.kimi)
|
||||
kimi: this.applyStalePolicy(kimi, previousState.kimi),
|
||||
minimax: shouldApplyMiniMax
|
||||
? miniMaxConfigChanged
|
||||
? miniMax
|
||||
: this.applyStalePolicy(miniMax, previousState.minimax)
|
||||
: this.state.minimax
|
||||
})
|
||||
|
||||
this.lastFetchAt = Date.now()
|
||||
|
|
|
|||
|
|
@ -2796,8 +2796,14 @@ export type PreloadApi = {
|
|||
setPollingInterval: (ms: number) => Promise<void>
|
||||
fetchInactiveClaudeAccounts: () => Promise<void>
|
||||
fetchInactiveCodexAccounts: () => Promise<void>
|
||||
refreshMiniMax: () => Promise<RateLimitState>
|
||||
onUpdate: (callback: (state: RateLimitState) => void) => () => void
|
||||
}
|
||||
minimaxCredentials: {
|
||||
getStatus: () => Promise<{ configured: boolean }>
|
||||
saveCookie: (cookie: string) => Promise<{ configured: boolean }>
|
||||
clearCookie: () => Promise<{ configured: boolean }>
|
||||
}
|
||||
ssh: {
|
||||
listTargets: () => Promise<SshTarget[]>
|
||||
addTarget: (args: { target: Omit<SshTarget, 'id'> }) => Promise<SshTarget>
|
||||
|
|
|
|||
|
|
@ -3799,6 +3799,7 @@ const api = {
|
|||
ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'),
|
||||
fetchInactiveCodexAccounts: (): Promise<void> =>
|
||||
ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'),
|
||||
refreshMiniMax: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshMiniMax'),
|
||||
onUpdate: (callback: (state: RateLimitState) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state)
|
||||
ipcRenderer.on('rateLimits:update', listener)
|
||||
|
|
@ -3806,6 +3807,15 @@ const api = {
|
|||
}
|
||||
},
|
||||
|
||||
minimaxCredentials: {
|
||||
getStatus: (): Promise<{ configured: boolean }> =>
|
||||
ipcRenderer.invoke('minimaxCredentials:getStatus'),
|
||||
saveCookie: (cookie: string): Promise<{ configured: boolean }> =>
|
||||
ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie),
|
||||
clearCookie: (): Promise<{ configured: boolean }> =>
|
||||
ipcRenderer.invoke('minimaxCredentials:clearCookie')
|
||||
},
|
||||
|
||||
ssh: {
|
||||
listTargets: (): Promise<SshTarget[]> => ipcRenderer.invoke('ssh:listTargets'),
|
||||
|
||||
|
|
|
|||
|
|
@ -13,17 +13,37 @@ import { Badge } from '../ui/badge'
|
|||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
|
||||
import { Separator } from '../ui/separator'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { AlertTriangle, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
Lock,
|
||||
LockOpen,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from '../status-bar/icons'
|
||||
import {
|
||||
ClaudeIcon,
|
||||
GeminiIcon,
|
||||
MiniMaxIcon,
|
||||
OpenAIIcon,
|
||||
OpenCodeGoIcon
|
||||
} from '../status-bar/icons'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
getAccountsClaudeSearchEntries,
|
||||
getAccountsCodexSearchEntries,
|
||||
getAccountsGeminiSearchEntries,
|
||||
getAccountsLocationSearchEntries,
|
||||
getAccountsMiniMaxSearchEntries,
|
||||
getAccountsOpencodeSearchEntries,
|
||||
getAccountsPaneSearchEntries
|
||||
} from './accounts-search'
|
||||
|
|
@ -41,10 +61,73 @@ import {
|
|||
} from '../ui/dialog'
|
||||
import { getCodexAccountAuthWarning } from './codex-account-auth-warning'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export { getAccountsPaneSearchEntries }
|
||||
|
||||
const EMPTY_WSL_DISTROS: string[] = []
|
||||
const MINIMAX_CONSOLE_URL = 'https://platform.minimax.io/console/usage'
|
||||
|
||||
function formatMiniMaxRelativeRefresh(updatedAt: number, now: number): string {
|
||||
const diffMs = Math.max(0, now - updatedAt)
|
||||
if (diffMs < 60_000) {
|
||||
return translate('auto.components.settings.AccountsPane.3a30aaf526', 'just now')
|
||||
}
|
||||
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
const minutes = Math.round(diffMs / 60_000)
|
||||
if (minutes < 60) {
|
||||
return formatter.format(-minutes, 'minute')
|
||||
}
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 24) {
|
||||
return formatter.format(-hours, 'hour')
|
||||
}
|
||||
return formatter.format(-Math.round(hours / 24), 'day')
|
||||
}
|
||||
|
||||
function MiniMaxCookieHelpPopover(): React.JSX.Element {
|
||||
const steps = [
|
||||
translate(
|
||||
'auto.components.settings.AccountsPane.f5d8d2a6a1',
|
||||
'Open platform.minimax.io/console/usage in your browser and sign in.'
|
||||
),
|
||||
translate('auto.components.settings.AccountsPane.24560fe830', 'Open DevTools.'),
|
||||
translate(
|
||||
'auto.components.settings.AccountsPane.4cab0fa42d',
|
||||
'Go to the Network tab and enable Preserve log.'
|
||||
),
|
||||
translate('auto.components.settings.AccountsPane.bee4e63e1c', 'Reload the page.'),
|
||||
translate(
|
||||
'auto.components.settings.AccountsPane.87f814af6f',
|
||||
'Filter for remains and select the coding_plan/remains request.'
|
||||
),
|
||||
translate(
|
||||
'auto.components.settings.AccountsPane.435df0ee51',
|
||||
'Under Request Headers, copy the Cookie value.'
|
||||
),
|
||||
translate('auto.components.settings.AccountsPane.7492fb3bba', 'Paste it here and click Save.')
|
||||
]
|
||||
return (
|
||||
<div className="space-y-3 p-3 text-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
{translate('auto.components.settings.AccountsPane.9fec52de4b', 'How to copy the cookie')}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.4e32e030b2',
|
||||
'The cookie stays on this device. Orca only sends it to platform.minimax.io for usage refreshes.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<ol className="list-decimal space-y-1 pl-4 text-muted-foreground">
|
||||
{steps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AccountsPaneProps = {
|
||||
settings: GlobalSettings
|
||||
|
|
@ -254,9 +337,14 @@ export function AccountsPane({
|
|||
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
const codexRateLimits = useAppStore((s) => s.rateLimits.codex)
|
||||
const codexRateLimitTarget = useAppStore((s) => s.rateLimits.codexTarget)
|
||||
const miniMaxRateLimits = useAppStore((s) => s.rateLimits.minimax)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const fetchSettings = useAppStore((s) => s.fetchSettings)
|
||||
const refreshRateLimits = useAppStore((s) => s.refreshRateLimits)
|
||||
const recordedOpenCodeSettingEditsRef = useRef<Set<'cookie' | 'workspaceId'>>(new Set())
|
||||
const [miniMaxCookieDraft, setMiniMaxCookieDraft] = useState('')
|
||||
const [miniMaxConfigured, setMiniMaxConfigured] = useState(false)
|
||||
const [miniMaxCredentialBusy, setMiniMaxCredentialBusy] = useState(false)
|
||||
const accountRuntime = getSelectedAccountRuntime(
|
||||
settings,
|
||||
wslSupportedPlatform,
|
||||
|
|
@ -319,6 +407,70 @@ export function AccountsPane({
|
|||
recordFeatureInteraction('usage-tracking')
|
||||
}
|
||||
|
||||
const refreshMiniMaxCredentialStatus = async (): Promise<void> => {
|
||||
try {
|
||||
const status = await window.api.minimaxCredentials.getStatus()
|
||||
setMiniMaxConfigured(status.configured)
|
||||
} catch (error) {
|
||||
console.error('Failed to load MiniMax credential status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveMiniMaxCookie = async (): Promise<void> => {
|
||||
if (!miniMaxCookieDraft.trim()) {
|
||||
toast.error(
|
||||
translate('auto.components.settings.AccountsPane.2f24f244a4', 'MiniMax cookie is required.')
|
||||
)
|
||||
return
|
||||
}
|
||||
setMiniMaxCredentialBusy(true)
|
||||
try {
|
||||
const status = await window.api.minimaxCredentials.saveCookie(miniMaxCookieDraft)
|
||||
setMiniMaxConfigured(status.configured)
|
||||
setMiniMaxCookieDraft('')
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void refreshRateLimits()
|
||||
toast.success(
|
||||
translate('auto.components.settings.AccountsPane.8d61637a77', 'MiniMax cookie saved.')
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.settings.AccountsPane.b43e761fe5',
|
||||
'MiniMax cookie update failed.'
|
||||
),
|
||||
{ description: String((error as Error)?.message ?? error) }
|
||||
)
|
||||
} finally {
|
||||
setMiniMaxCredentialBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearMiniMaxCookie = async (): Promise<void> => {
|
||||
setMiniMaxCredentialBusy(true)
|
||||
try {
|
||||
const status = await window.api.minimaxCredentials.clearCookie()
|
||||
setMiniMaxConfigured(status.configured)
|
||||
setMiniMaxCookieDraft('')
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void refreshRateLimits()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.settings.AccountsPane.b43e761fe5',
|
||||
'MiniMax cookie update failed.'
|
||||
),
|
||||
{ description: String((error as Error)?.message ?? error) }
|
||||
)
|
||||
} finally {
|
||||
setMiniMaxCredentialBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMiniMaxCredentialStatus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
|
||||
|
|
@ -1351,6 +1503,247 @@ export function AccountsPane({
|
|||
</p>
|
||||
</SearchableSetting>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, getAccountsMiniMaxSearchEntries()) ? (
|
||||
<section key="minimax" id="accounts-minimax" className="space-y-4 scroll-mt-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<MiniMaxIcon size={16} />
|
||||
{translate('auto.components.settings.AccountsPane.5d63bbfbec', 'MiniMax')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.15e831350e',
|
||||
'Configure MiniMax usage tracking from platform.minimax.io.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={MINIMAX_CONSOLE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{translate('auto.components.settings.AccountsPane.0d8e77bc40', 'Open console')}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border bg-muted/20 p-3',
|
||||
miniMaxConfigured ? 'border-border/60' : 'border-border/40'
|
||||
)}
|
||||
>
|
||||
<ShieldCheck
|
||||
className={cn(
|
||||
'mt-0.5 size-4 shrink-0',
|
||||
miniMaxConfigured ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-medium">
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.0b8c1c7e02',
|
||||
miniMaxConfigured ? 'Encrypted locally' : 'Cookie not set'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.5e08b0fe57',
|
||||
'Sent only to platform.minimax.io. Never leaves Orca.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AccountsPane.21d6eb141e',
|
||||
'MiniMax Session Cookie'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AccountsPane.33bba5ad83',
|
||||
'Paste your MiniMax session cookie for local rate-limit fetching.'
|
||||
)}
|
||||
keywords={['minimax', 'cookie', 'session', 'rate limit', 'status bar']}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.21d6eb141e',
|
||||
'MiniMax Session Cookie'
|
||||
)}
|
||||
</Label>
|
||||
<Badge
|
||||
variant={miniMaxConfigured ? 'secondary' : 'outline'}
|
||||
className="h-5 gap-1 rounded-full px-2 text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{miniMaxConfigured ? <Lock className="size-3" /> : <LockOpen className="size-3" />}
|
||||
{miniMaxConfigured
|
||||
? translate('auto.components.settings.AccountsPane.73ea15f24b', 'Saved')
|
||||
: translate('auto.components.settings.AccountsPane.23afe8f226', 'Not saved')}
|
||||
</Badge>
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HelpCircle className="size-3" />
|
||||
{translate('auto.components.settings.AccountsPane.43d7a45b97', 'How to copy')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" side="bottom" sideOffset={6} className="w-80 p-0">
|
||||
<MiniMaxCookieHelpPopover />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={miniMaxCookieDraft}
|
||||
onChange={(e) => setMiniMaxCookieDraft(e.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.AccountsPane.b8a4f21c3e',
|
||||
'Paste the Cookie header from DevTools'
|
||||
)}
|
||||
spellCheck={false}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => void saveMiniMaxCookie()}
|
||||
disabled={miniMaxCredentialBusy || !miniMaxCookieDraft.trim()}
|
||||
className="h-7 shrink-0 text-xs"
|
||||
>
|
||||
{miniMaxCredentialBusy ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
{miniMaxConfigured
|
||||
? translate('auto.components.settings.AccountsPane.f38b9cc4bd', 'Replace')
|
||||
: translate('auto.components.settings.AccountsPane.590a3130f9', 'Save')}
|
||||
</Button>
|
||||
{miniMaxConfigured ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => void clearMiniMaxCookie()}
|
||||
disabled={miniMaxCredentialBusy}
|
||||
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{translate('auto.components.settings.AccountsPane.b398b834c9', 'Forget cookie')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.79418c782a',
|
||||
'Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).'
|
||||
)}
|
||||
</p>
|
||||
{miniMaxConfigured &&
|
||||
miniMaxRateLimits?.status === 'ok' &&
|
||||
miniMaxRateLimits.error === null ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.53f7b8c7a2',
|
||||
'Last refresh: {{value0}}',
|
||||
{ value0: formatMiniMaxRelativeRefresh(miniMaxRateLimits.updatedAt, Date.now()) }
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.31d24a4e87',
|
||||
'Cookie expires when you sign out in the browser.'
|
||||
)}
|
||||
</p>
|
||||
</SearchableSetting>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">
|
||||
{translate('auto.components.settings.AccountsPane.9dd50d3f75', 'Advanced')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.174fb408f9',
|
||||
'Leave these defaults alone unless MiniMax usage refresh points at the wrong workspace or model.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{miniMaxConfigured ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => void clearMiniMaxCookie()}
|
||||
disabled={miniMaxCredentialBusy}
|
||||
className="h-6 shrink-0 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{translate('auto.components.settings.AccountsPane.42c2cb21cf', 'Forget cookie')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AccountsPane.bf160bb6c0',
|
||||
'Group ID override'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AccountsPane.b1e2743313',
|
||||
'Optional. Leave blank to use minimax_group_id_v2 from the cookie.'
|
||||
)}
|
||||
keywords={['minimax', 'group', 'id', 'rate limit']}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label>
|
||||
{translate('auto.components.settings.AccountsPane.bf160bb6c0', 'Group ID override')}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.minimaxGroupId}
|
||||
onChange={(e) => updateSettings({ minimaxGroupId: e.target.value })}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.AccountsPane.0747d6391a',
|
||||
settings.minimaxGroupId ? 'From cookie' : 'Use group ID from cookie'
|
||||
)}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AccountsPane.4ff2af7524',
|
||||
'Usage model names'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AccountsPane.5cf4b0f85f',
|
||||
'Optional comma-separated model names. Leave as general unless MiniMax returns a model-specific error.'
|
||||
)}
|
||||
keywords={['minimax', 'model', 'general', 'rate limit']}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label>
|
||||
{translate('auto.components.settings.AccountsPane.4ff2af7524', 'Usage model names')}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.minimaxUsageModels}
|
||||
onChange={(e) => updateSettings({ minimaxUsageModels: e.target.value })}
|
||||
placeholder={translate('auto.components.settings.AccountsPane.3c92b0d31c', 'general')}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</section>
|
||||
) : null
|
||||
].filter(Boolean)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/localized-catalog', () => ({
|
||||
createLocalizedCatalog:
|
||||
<T>(loader: () => T) =>
|
||||
() =>
|
||||
loader()
|
||||
}))
|
||||
|
||||
vi.mock('./settings-search-keywords', () => ({
|
||||
translateSearchKeyword: (_key: string, fallback: string) => [fallback]
|
||||
}))
|
||||
|
||||
import { getAccountsMiniMaxSearchEntries, getAccountsPaneSearchEntries } from './accounts-search'
|
||||
|
||||
describe('getAccountsMiniMaxSearchEntries', () => {
|
||||
it('returns a single entry that targets the MiniMax session cookie flow', () => {
|
||||
const entries = getAccountsMiniMaxSearchEntries()
|
||||
expect(entries).toHaveLength(1)
|
||||
const [entry] = entries
|
||||
expect(entry.title).toBe('MiniMax Usage')
|
||||
expect(entry.description).toContain('platform.minimax.io')
|
||||
expect(entry.description.toLowerCase()).toContain('cookie')
|
||||
})
|
||||
|
||||
it('exposes the keywords that drive the Settings search index', () => {
|
||||
const [entry] = getAccountsMiniMaxSearchEntries()
|
||||
// Why: the Settings search needs at least one of these tokens to
|
||||
// surface the MiniMax section when the user types a related term.
|
||||
expect(entry.keywords).toEqual(
|
||||
expect.arrayContaining(['minimax', 'cookie', 'session', 'rate limit', 'status bar'])
|
||||
)
|
||||
})
|
||||
|
||||
it('is included in the rolled-up pane search entries', () => {
|
||||
const allEntries = getAccountsPaneSearchEntries()
|
||||
const titles = allEntries.map((entry) => entry.title)
|
||||
expect(titles).toContain('MiniMax Usage')
|
||||
})
|
||||
})
|
||||
|
|
@ -171,10 +171,31 @@ export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [
|
|||
}
|
||||
])
|
||||
|
||||
export const getAccountsMiniMaxSearchEntries = createLocalizedCatalog(() => [
|
||||
{
|
||||
title: translate('auto.components.settings.accounts.search.733f9e2a93', 'MiniMax Usage'),
|
||||
description: translate(
|
||||
'auto.components.settings.accounts.search.f8374c3151',
|
||||
'Paste your platform.minimax.io session cookie for local rate-limit fetching.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword('auto.components.settings.accounts.search.d16378a88f', 'minimax'),
|
||||
...translateSearchKeyword('auto.components.settings.accounts.search.61f7d1fcbe', 'cookie'),
|
||||
...translateSearchKeyword('auto.components.settings.accounts.search.9c4e40cf6b', 'session'),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.accounts.search.e949b08ffb',
|
||||
'rate limit'
|
||||
),
|
||||
...translateSearchKeyword('auto.components.settings.accounts.search.86edc96bc9', 'status bar')
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
|
||||
...getAccountsLocationSearchEntries(),
|
||||
...getAccountsClaudeSearchEntries(),
|
||||
...getAccountsCodexSearchEntries(),
|
||||
...getAccountsGeminiSearchEntries(),
|
||||
...getAccountsOpencodeSearchEntries()
|
||||
...getAccountsOpencodeSearchEntries(),
|
||||
...getAccountsMiniMaxSearchEntries()
|
||||
])
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ import {
|
|||
formatResetCreditExpiry,
|
||||
getProviderUsageStatusLabel
|
||||
} from './tooltip'
|
||||
import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
|
||||
import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { formatWindowLabel } from '@/lib/window-label-formatter'
|
||||
import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart'
|
||||
|
|
@ -1689,7 +1689,9 @@ export function ProviderDetailsMenu({
|
|||
? 'O'
|
||||
: provider.provider === 'kimi'
|
||||
? 'K'
|
||||
: 'X'}
|
||||
: provider.provider === 'minimax'
|
||||
? 'M'
|
||||
: 'X'}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -1831,17 +1833,21 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
|||
return null
|
||||
}
|
||||
|
||||
const { claude, codex, gemini, opencodeGo, kimi } = rateLimits
|
||||
const { claude, codex, gemini, opencodeGo, kimi, minimax } = rateLimits
|
||||
|
||||
// Why: a provider earns a bar from either a usable live snapshot or durable
|
||||
// setup in Settings. The durable path keeps account switchers visible while
|
||||
// usage snapshots hydrate, fail, or temporarily report unavailable.
|
||||
// Detection-gating (see status-bar-agent-gating) additionally hides per-CLI
|
||||
// bars when the agent isn't installed on PATH.
|
||||
const visibleClaude = getVisibleUsageProvider('claude', claude, settings)
|
||||
const visibleCodex = getVisibleUsageProvider('codex', codex, settings)
|
||||
const visibleGemini = getVisibleUsageProvider('gemini', gemini, settings)
|
||||
const visibleKimi = getVisibleUsageProvider('kimi', kimi, settings)
|
||||
// Why: thread the cookie durability flag from RateLimitState so the
|
||||
// MiniMax bar stays visible after a reload and between snapshot refreshes.
|
||||
const usageSettings = { ...settings, minimaxCookieConfigured: rateLimits.minimaxCookieConfigured }
|
||||
const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings)
|
||||
const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings)
|
||||
const visibleGemini = getVisibleUsageProvider('gemini', gemini, usageSettings)
|
||||
const visibleKimi = getVisibleUsageProvider('kimi', kimi, usageSettings)
|
||||
const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings)
|
||||
const showClaude =
|
||||
visibleClaude !== null &&
|
||||
statusBarItems.includes('claude') &&
|
||||
|
|
@ -1858,9 +1864,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
|||
visibleKimi !== null &&
|
||||
statusBarItems.includes('kimi') &&
|
||||
isStatusBarItemAvailable('kimi', detectedAgentIds)
|
||||
const showMiniMax = visibleMiniMax !== null && statusBarItems.includes('minimax')
|
||||
// Why: OpenCode Go is a web/cookie-auth provider, not a CLI on PATH, so
|
||||
// detection-gating doesn't apply.
|
||||
const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, settings)
|
||||
const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings)
|
||||
const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go')
|
||||
const showSsh = statusBarItems.includes('ssh')
|
||||
const showResourceUsage = statusBarItems.includes('resource-usage')
|
||||
|
|
@ -1868,12 +1875,21 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
|||
const showFloatingTerminalToggle =
|
||||
floatingTerminalEnabled && floatingTerminalTriggerLocation === 'status-bar'
|
||||
const anyVisible =
|
||||
showClaude || showCodex || showGemini || showOpencodeGo || showKimi || showResourceUsage
|
||||
showClaude ||
|
||||
showCodex ||
|
||||
showGemini ||
|
||||
showOpencodeGo ||
|
||||
showKimi ||
|
||||
showMiniMax ||
|
||||
showResourceUsage
|
||||
// Why: a brand-new user with no provider configured would otherwise see an
|
||||
// empty left side of the status bar and wonder what's missing. Settings are
|
||||
// included because managed accounts are durable even when live usage
|
||||
// snapshots are still hydrating or unavailable after an update.
|
||||
const isEmptyUsageState = isUsageEmptyState({ claude, codex, gemini, opencodeGo, kimi }, settings)
|
||||
const isEmptyUsageState = isUsageEmptyState(
|
||||
{ claude, codex, gemini, opencodeGo, kimi, minimax },
|
||||
usageSettings
|
||||
)
|
||||
// Why: the teaching CTA is a one-time nudge — once the user hides it, keep it
|
||||
// hidden even after providers are disconnected again.
|
||||
const showEmptyUsageCta = isEmptyUsageState && !usageEmptyStateDismissed
|
||||
|
|
@ -1882,7 +1898,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
|||
codex?.status === 'fetching' ||
|
||||
gemini?.status === 'fetching' ||
|
||||
opencodeGo?.status === 'fetching' ||
|
||||
kimi?.status === 'fetching'
|
||||
kimi?.status === 'fetching' ||
|
||||
minimax?.status === 'fetching'
|
||||
|
||||
const compact = containerWidth < 900
|
||||
const iconOnly = containerWidth < 500
|
||||
|
|
@ -1957,6 +1974,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
{showMiniMax && (
|
||||
<ProviderDetailsMenu
|
||||
provider={visibleMiniMax}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
ariaLabel={translate(
|
||||
'auto.components.status.bar.StatusBar.06741a2f3d',
|
||||
'Open MiniMax usage details'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{anyVisible && !isEmptyUsageState && (
|
||||
|
|
@ -2087,6 +2115,16 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
|||
{translate('auto.components.status.bar.StatusBar.5e59007df4', 'Kimi Usage')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusBarItems.includes('minimax')}
|
||||
onCheckedChange={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
toggleStatusBarItem('minimax')
|
||||
}}
|
||||
>
|
||||
<MiniMaxIcon size={14} />
|
||||
{translate('auto.components.status.bar.StatusBar.3bbf140864', 'MiniMax Usage')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusBarItems.includes('ssh')}
|
||||
onCheckedChange={() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MiniMaxIcon } from './icons'
|
||||
|
||||
describe('MiniMaxIcon', () => {
|
||||
it('renders the official MiniMax mark as an image', () => {
|
||||
const markup = renderToStaticMarkup(<MiniMaxIcon size={14} />)
|
||||
expect(markup.startsWith('<img')).toBe(true)
|
||||
expect(markup).toContain('aria-hidden="true"')
|
||||
expect(markup).toContain('width="14"')
|
||||
expect(markup).toContain('height="14"')
|
||||
})
|
||||
|
||||
it('honors a custom size prop', () => {
|
||||
const markup = renderToStaticMarkup(<MiniMaxIcon size={20} />)
|
||||
expect(markup).toContain('width="20"')
|
||||
expect(markup).toContain('height="20"')
|
||||
})
|
||||
|
||||
it('does not render the legacy "M" placeholder text', () => {
|
||||
const markup = renderToStaticMarkup(<MiniMaxIcon size={14} />)
|
||||
expect(markup).not.toContain('>M<')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react'
|
||||
import minimaxIconUrl from '../../../../../resources/minimax-icon.svg?url'
|
||||
|
||||
export function OpenAIIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
return (
|
||||
|
|
@ -15,6 +16,24 @@ export function OpenAIIcon({ size = 14 }: { size?: number }): React.JSX.Element
|
|||
)
|
||||
}
|
||||
|
||||
export function MiniMaxIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
// Why: ship the official MiniMax wordmark mark so the icon is recognizable
|
||||
// in both light and dark themes. The SVG carries its own pink→orange gradient
|
||||
// that reads on muted backgrounds, so we keep the asset monochrome-agnostic
|
||||
// and let CSS theme tokens drive the surrounding chrome instead.
|
||||
return (
|
||||
<img
|
||||
src={minimaxIconUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={size}
|
||||
height={size}
|
||||
className="block shrink-0"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Why: each instance needs unique filter/mask IDs — reusing the same ID across
|
||||
// multiple SVGs on the same page causes the browser to resolve to the first one,
|
||||
// breaking all subsequent instances.
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ function usageSettings(overrides: Partial<UsageProviderSettings> = {}): UsagePro
|
|||
claudeManagedAccounts: [],
|
||||
opencodeSessionCookie: '',
|
||||
geminiCliOAuthEnabled: false,
|
||||
minimaxCookieConfigured: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
|
@ -117,6 +118,7 @@ describe('hasUsageProviderSettings', () => {
|
|||
expect(
|
||||
hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' }))
|
||||
).toBe(true)
|
||||
expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not treat empty or unloaded settings as configured', () => {
|
||||
|
|
@ -147,6 +149,17 @@ describe('hasUsageProviderSettingsForProvider', () => {
|
|||
expect(hasUsageProviderSettingsForProvider('claude', usageSettings())).toBe(false)
|
||||
expect(hasUsageProviderSettingsForProvider('kimi', usageSettings())).toBe(false)
|
||||
})
|
||||
|
||||
it('treats minimaxCookieConfigured as the durable signal for MiniMax', () => {
|
||||
expect(
|
||||
hasUsageProviderSettingsForProvider(
|
||||
'minimax',
|
||||
usageSettings({ minimaxCookieConfigured: true })
|
||||
)
|
||||
).toBe(true)
|
||||
expect(hasUsageProviderSettingsForProvider('minimax', usageSettings())).toBe(false)
|
||||
expect(hasUsageProviderSettingsForProvider('minimax', null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVisibleUsageProvider', () => {
|
||||
|
|
@ -207,6 +220,45 @@ describe('getVisibleUsageProvider', () => {
|
|||
expect(getVisibleUsageProvider('codex', null, usageSettings())).toBe(null)
|
||||
expect(getVisibleUsageProvider('gemini', provider('fetching'), usageSettings())).toBe(null)
|
||||
})
|
||||
|
||||
it('keeps MiniMax visible while the snapshot is pending when a cookie is configured', () => {
|
||||
const visible = getVisibleUsageProvider(
|
||||
'minimax',
|
||||
null,
|
||||
usageSettings({ minimaxCookieConfigured: true })
|
||||
)
|
||||
expect(visible).toMatchObject({
|
||||
provider: 'minimax',
|
||||
status: 'fetching',
|
||||
session: null,
|
||||
weekly: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps MiniMax visible when the fetch returns unavailable for a configured cookie', () => {
|
||||
const unavailable = provider('unavailable', {
|
||||
provider: 'minimax',
|
||||
error: 'MiniMax session expired. Replace the MiniMax cookie in Settings.'
|
||||
})
|
||||
expect(
|
||||
getVisibleUsageProvider(
|
||||
'minimax',
|
||||
unavailable,
|
||||
usageSettings({ minimaxCookieConfigured: true })
|
||||
)
|
||||
).toBe(unavailable)
|
||||
})
|
||||
|
||||
it('hides MiniMax when no cookie is configured and the snapshot is empty', () => {
|
||||
expect(getVisibleUsageProvider('minimax', null, usageSettings())).toBe(null)
|
||||
expect(
|
||||
getVisibleUsageProvider(
|
||||
'minimax',
|
||||
provider('unavailable', { provider: 'minimax' }),
|
||||
usageSettings()
|
||||
)
|
||||
).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUsageEmptyState', () => {
|
||||
|
|
@ -218,7 +270,8 @@ describe('isUsageEmptyState', () => {
|
|||
codex: null,
|
||||
gemini: null,
|
||||
opencodeGo: null,
|
||||
kimi: null
|
||||
kimi: null,
|
||||
minimax: null
|
||||
},
|
||||
usageSettings()
|
||||
)
|
||||
|
|
@ -233,7 +286,8 @@ describe('isUsageEmptyState', () => {
|
|||
codex: provider('fetching', { provider: 'codex' }),
|
||||
gemini: provider('unavailable'),
|
||||
opencodeGo: provider('unavailable', { provider: 'opencode-go' }),
|
||||
kimi: provider('unavailable', { provider: 'kimi' })
|
||||
kimi: provider('unavailable', { provider: 'kimi' }),
|
||||
minimax: provider('unavailable', { provider: 'minimax' })
|
||||
},
|
||||
usageSettings()
|
||||
)
|
||||
|
|
@ -248,7 +302,8 @@ describe('isUsageEmptyState', () => {
|
|||
codex: provider('unavailable', { provider: 'codex' }),
|
||||
gemini: provider('unavailable'),
|
||||
opencodeGo: provider('unavailable', { provider: 'opencode-go' }),
|
||||
kimi: provider('unavailable', { provider: 'kimi' })
|
||||
kimi: provider('unavailable', { provider: 'kimi' }),
|
||||
minimax: provider('unavailable', { provider: 'minimax' })
|
||||
},
|
||||
usageSettings({
|
||||
codexManagedAccounts: [
|
||||
|
|
@ -274,7 +329,8 @@ describe('isUsageEmptyState', () => {
|
|||
codex: null,
|
||||
gemini: null,
|
||||
opencodeGo: null,
|
||||
kimi: null
|
||||
kimi: null,
|
||||
minimax: null
|
||||
},
|
||||
null
|
||||
)
|
||||
|
|
@ -289,7 +345,8 @@ describe('isUsageEmptyState', () => {
|
|||
codex: provider('unavailable', { provider: 'codex' }),
|
||||
gemini: provider('unavailable'),
|
||||
opencodeGo: provider('unavailable', { provider: 'opencode-go' }),
|
||||
kimi: provider('unavailable', { provider: 'kimi' })
|
||||
kimi: provider('unavailable', { provider: 'kimi' }),
|
||||
minimax: provider('unavailable', { provider: 'minimax' })
|
||||
},
|
||||
usageSettings()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ export type UsageProviderSettings = Pick<
|
|||
| 'claudeManagedAccounts'
|
||||
| 'opencodeSessionCookie'
|
||||
| 'geminiCliOAuthEnabled'
|
||||
>
|
||||
> & {
|
||||
// Why: the MiniMax cookie lives in the file system, not GlobalSettings, so
|
||||
// we can't derive durability from settings alone. The renderer threads the
|
||||
// flag from RateLimitState (pushed by the main process) so the bar stays
|
||||
// visible across reloads and between snapshot refreshes.
|
||||
minimaxCookieConfigured: boolean
|
||||
}
|
||||
|
||||
type UsageProviderSnapshots = {
|
||||
claude: ProviderRateLimits | null
|
||||
|
|
@ -15,6 +21,7 @@ type UsageProviderSnapshots = {
|
|||
gemini: ProviderRateLimits | null
|
||||
opencodeGo: ProviderRateLimits | null
|
||||
kimi: ProviderRateLimits | null
|
||||
minimax: ProviderRateLimits | null
|
||||
}
|
||||
|
||||
type UsageProviderId = ProviderRateLimits['provider']
|
||||
|
|
@ -58,7 +65,8 @@ export function hasUsageProviderSettings(
|
|||
(settings?.codexManagedAccounts?.length ?? 0) > 0 ||
|
||||
(settings?.claudeManagedAccounts?.length ?? 0) > 0 ||
|
||||
settings?.geminiCliOAuthEnabled === true ||
|
||||
Boolean(settings?.opencodeSessionCookie?.trim())
|
||||
Boolean(settings?.opencodeSessionCookie?.trim()) ||
|
||||
settings?.minimaxCookieConfigured === true
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +89,9 @@ export function hasUsageProviderSettingsForProvider(
|
|||
if (providerId === 'opencode-go') {
|
||||
return Boolean(settings.opencodeSessionCookie?.trim())
|
||||
}
|
||||
if (providerId === 'minimax') {
|
||||
return settings.minimaxCookieConfigured === true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +139,8 @@ export function isUsageEmptyState(
|
|||
isProviderSnapshotPending(providers.codex) ||
|
||||
isProviderSnapshotPending(providers.gemini) ||
|
||||
isProviderSnapshotPending(providers.opencodeGo) ||
|
||||
isProviderSnapshotPending(providers.kimi)
|
||||
isProviderSnapshotPending(providers.kimi) ||
|
||||
isProviderSnapshotPending(providers.minimax)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -138,6 +150,7 @@ export function isUsageEmptyState(
|
|||
!isProviderConfigured(providers.codex) &&
|
||||
!isProviderConfigured(providers.gemini) &&
|
||||
!isProviderConfigured(providers.opencodeGo) &&
|
||||
!isProviderConfigured(providers.kimi)
|
||||
!isProviderConfigured(providers.kimi) &&
|
||||
!isProviderConfigured(providers.minimax)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
getProviderUsageErrorMessage,
|
||||
getProviderUsageStatusLabel,
|
||||
getWindowSections,
|
||||
ProviderIcon,
|
||||
ProviderPanel
|
||||
} from './tooltip'
|
||||
|
||||
|
|
@ -402,4 +403,58 @@ describe('ProviderPanel reset rendering', () => {
|
|||
expect(markup).toContain('Fable')
|
||||
expect(markup).toContain('Resets in 6d 17h')
|
||||
})
|
||||
|
||||
it('renders MiniMax session as `100 - usedPercent` left so the value matches the bar', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 6, 4, 15, 0))
|
||||
const p = provider({
|
||||
provider: 'minimax',
|
||||
status: 'ok',
|
||||
session: {
|
||||
usedPercent: 35,
|
||||
windowMinutes: 300,
|
||||
resetsAt: Date.now() + 2 * 60 * 60_000,
|
||||
resetDescription: null
|
||||
}
|
||||
})
|
||||
|
||||
const markup = renderToStaticMarkup(ProviderPanel({ p }))
|
||||
|
||||
// Why: the bar reads "65% 5h"; the tooltip must read "65% left" from the
|
||||
// same source field so the two views stay consistent.
|
||||
expect(markup).toContain('65%')
|
||||
expect(markup).toContain('% left')
|
||||
expect(markup).not.toContain('100% left')
|
||||
})
|
||||
|
||||
it('clamps MiniMax session to 0% left when usedPercent reports 100', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 6, 4, 15, 0))
|
||||
const p = provider({
|
||||
provider: 'minimax',
|
||||
status: 'ok',
|
||||
session: {
|
||||
usedPercent: 100,
|
||||
windowMinutes: 300,
|
||||
resetsAt: Date.now() + 2 * 60 * 60_000,
|
||||
resetDescription: null
|
||||
}
|
||||
})
|
||||
|
||||
const markup = renderToStaticMarkup(ProviderPanel({ p }))
|
||||
|
||||
expect(markup).toContain('0% left')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderIcon', () => {
|
||||
it('renders the official MiniMax icon asset for the minimax provider', () => {
|
||||
// Why: the icon must travel to the status bar / tooltip unchanged so the
|
||||
// user recognises the brand. We pin it to an <img> with a non-empty
|
||||
// resource URL and aria-hidden so the icon stays purely decorative.
|
||||
const markup = renderToStaticMarkup(ProviderIcon({ provider: 'minimax' }))
|
||||
expect(markup.startsWith('<img')).toBe(true)
|
||||
expect(markup).toContain('aria-hidden="true"')
|
||||
expect(markup).toMatch(/src="[^"]+"/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
|
||||
import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
|
|
@ -93,6 +93,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem
|
|||
if (provider === 'kimi') {
|
||||
return <AgentIcon agent="kimi" size={13} />
|
||||
}
|
||||
if (provider === 'minimax') {
|
||||
return <MiniMaxIcon size={13} />
|
||||
}
|
||||
return <ClaudeIcon size={13} />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
import { getProviderDisplayName } from './usage-error-copy'
|
||||
|
||||
describe('getProviderDisplayName', () => {
|
||||
it('returns the MiniMax brand name', () => {
|
||||
expect(getProviderDisplayName('minimax')).toBe('MiniMax')
|
||||
})
|
||||
|
||||
it('returns the existing provider brand names', () => {
|
||||
expect(getProviderDisplayName('claude')).toBe('Claude')
|
||||
expect(getProviderDisplayName('codex')).toBe('Codex')
|
||||
expect(getProviderDisplayName('gemini')).toBe('Gemini')
|
||||
expect(getProviderDisplayName('opencode-go')).toBe('OpenCode Go')
|
||||
expect(getProviderDisplayName('kimi')).toBe('Kimi')
|
||||
})
|
||||
|
||||
it('falls back to the raw provider id when no mapping exists', () => {
|
||||
// Why: provider id is a closed union, but TypeScript may not enforce
|
||||
// exhaustiveness on dynamic callers. Fallback keeps logging safe.
|
||||
expect(getProviderDisplayName('unknown-provider' as never)).toBe('unknown-provider')
|
||||
})
|
||||
})
|
||||
|
|
@ -17,6 +17,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider'])
|
|||
if (provider === 'kimi') {
|
||||
return 'Kimi'
|
||||
}
|
||||
if (provider === 'minimax') {
|
||||
return 'MiniMax'
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2935,7 +2935,9 @@
|
|||
"972a1ff497": "Reset Codex limits?",
|
||||
"6d1042aa6f": "This uses one Codex rate-limit reset credit for the active account and resets any eligible usage windows immediately.",
|
||||
"f077f586db": "Don't ask again",
|
||||
"c0e972d726": "Cancel"
|
||||
"c0e972d726": "Cancel",
|
||||
"06741a2f3d": "Open MiniMax usage details",
|
||||
"3bbf140864": "MiniMax Usage"
|
||||
},
|
||||
"StatusBarUsageEmptyCta": {
|
||||
"828c764a79": "Connect an account",
|
||||
|
|
@ -4634,7 +4636,46 @@
|
|||
"75ca9b718e": "Codex reported that the active account needs a fresh sign-in. Re-authenticate it before starting new Codex sessions.",
|
||||
"b11078a9c2": "wsl",
|
||||
"350b2a1aa7": "Use your current",
|
||||
"e05d0ff737": "Use your current {{value0}} Claude login."
|
||||
"e05d0ff737": "Use your current {{value0}} Claude login.",
|
||||
"2f24f244a4": "MiniMax cookie is required.",
|
||||
"8d61637a77": "MiniMax cookie saved.",
|
||||
"b43e761fe5": "MiniMax cookie update failed.",
|
||||
"5d63bbfbec": "MiniMax",
|
||||
"15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.",
|
||||
"21d6eb141e": "MiniMax Session Cookie",
|
||||
"33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.",
|
||||
"73ea15f24b": "Saved",
|
||||
"23afe8f226": "Not saved",
|
||||
"566d9a99ab": "_token=…; minimax_group_id_v2=…",
|
||||
"f38b9cc4bd": "Replace",
|
||||
"590a3130f9": "Save",
|
||||
"79418c782a": "Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).",
|
||||
"9dd50d3f75": "Advanced",
|
||||
"174fb408f9": "Leave these defaults alone unless MiniMax usage refresh points at the wrong workspace or model.",
|
||||
"bf160bb6c0": "Group ID override",
|
||||
"b1e2743313": "Optional. Leave blank to use minimax_group_id_v2 from the cookie.",
|
||||
"0747d6391a": "Use group ID from cookie",
|
||||
"4ff2af7524": "Usage model names",
|
||||
"5cf4b0f85f": "Optional comma-separated model names. Leave as general unless MiniMax returns a model-specific error.",
|
||||
"3c92b0d31c": "general",
|
||||
"0d8e77bc40": "Open console",
|
||||
"0b8c1c7e02": "Encrypted locally",
|
||||
"5e08b0fe57": "Sent only to platform.minimax.io. Never leaves Orca.",
|
||||
"43d7a45b97": "How to copy",
|
||||
"b8a4f21c3e": "Paste the Cookie header from DevTools",
|
||||
"53f7b8c7a2": "Last refresh: {{value0}}",
|
||||
"31d24a4e87": "Cookie expires when you sign out in the browser.",
|
||||
"42c2cb21cf": "Forget cookie",
|
||||
"3a30aaf526": "just now",
|
||||
"f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in.",
|
||||
"24560fe830": "Open DevTools.",
|
||||
"4cab0fa42d": "Go to the Network tab and enable Preserve log.",
|
||||
"bee4e63e1c": "Reload the page.",
|
||||
"87f814af6f": "Filter for remains and select the coding_plan/remains request.",
|
||||
"435df0ee51": "Under Request Headers, copy the Cookie value.",
|
||||
"7492fb3bba": "Paste it here and click Save.",
|
||||
"9fec52de4b": "How to copy the cookie",
|
||||
"4e32e030b2": "The cookie stays on this device. Orca only sends it to platform.minimax.io for usage refreshes."
|
||||
},
|
||||
"AdvancedPane": {
|
||||
"40b29e0bf3": "Restart",
|
||||
|
|
@ -6802,7 +6843,9 @@
|
|||
"bdbd1e668e": "windows",
|
||||
"593720c17f": "location",
|
||||
"b84a5b0c8a": "Choose whether provider accounts are inspected and added on this device or in WSL.",
|
||||
"d09fb5ca92": "Account Location"
|
||||
"d09fb5ca92": "Account Location",
|
||||
"733f9e2a93": "MiniMax Usage",
|
||||
"f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching."
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
|
|
|
|||
|
|
@ -2935,7 +2935,9 @@
|
|||
"972a1ff497": "¿Restablecer límites de Codex?",
|
||||
"6d1042aa6f": "Esto usa un crédito de reset de rate limit de Codex para la cuenta activa y restablece de inmediato las ventanas de uso elegibles.",
|
||||
"f077f586db": "No volver a preguntar",
|
||||
"c0e972d726": "Cancelar"
|
||||
"c0e972d726": "Cancelar",
|
||||
"06741a2f3d": "Open MiniMax usage details",
|
||||
"3bbf140864": "MiniMax Usage"
|
||||
},
|
||||
"StatusBarUsageEmptyCta": {
|
||||
"828c764a79": "Conectar una cuenta",
|
||||
|
|
@ -4634,7 +4636,46 @@
|
|||
"75ca9b718e": "Codex informó que la cuenta activa requiere un inicio de sesión nuevo. Reautentícala antes de iniciar nuevas sesiones de Codex.",
|
||||
"b11078a9c2": "wsl",
|
||||
"350b2a1aa7": "Usa tu actual",
|
||||
"e05d0ff737": "Usa tu login actual de Claude de {{value0}}."
|
||||
"e05d0ff737": "Usa tu login actual de Claude de {{value0}}.",
|
||||
"2f24f244a4": "La cookie de MiniMax es obligatoria.",
|
||||
"8d61637a77": "Cookie de MiniMax guardada.",
|
||||
"b43e761fe5": "No se pudo actualizar la cookie de MiniMax.",
|
||||
"5d63bbfbec": "MiniMax",
|
||||
"15e831350e": "Configura el seguimiento de uso de MiniMax desde platform.minimax.io.",
|
||||
"21d6eb141e": "Cookie de sesión de MiniMax",
|
||||
"33bba5ad83": "Pega tu cookie de sesión de MiniMax para obtener límites de uso locales.",
|
||||
"73ea15f24b": "Guardado",
|
||||
"23afe8f226": "No guardado",
|
||||
"566d9a99ab": "_token=…; minimax_group_id_v2=…",
|
||||
"f38b9cc4bd": "Reemplazar",
|
||||
"590a3130f9": "Guardar",
|
||||
"79418c782a": "Abre platform.minimax.io/console/usage en tu navegador, inicia sesión y copia el encabezado Cookie de DevTools (Red → cualquier solicitud a remains → Cookie).",
|
||||
"9dd50d3f75": "Avanzado",
|
||||
"174fb408f9": "Deja estos valores predeterminados a menos que la actualización de uso de MiniMax apunte al workspace o modelo equivocado.",
|
||||
"bf160bb6c0": "Anulación de Group ID",
|
||||
"b1e2743313": "Opcional. Déjalo en blanco para usar minimax_group_id_v2 de la cookie.",
|
||||
"0747d6391a": "Usar el Group ID de la cookie",
|
||||
"4ff2af7524": "Nombres de modelos de uso",
|
||||
"5cf4b0f85f": "Nombres de modelos opcionales separados por comas. Déjalo en general a menos que MiniMax devuelva un error específico del modelo.",
|
||||
"3c92b0d31c": "general",
|
||||
"0d8e77bc40": "Abrir consola",
|
||||
"0b8c1c7e02": "Cifrado localmente",
|
||||
"5e08b0fe57": "Solo se envía a platform.minimax.io. Nunca sale de Orca.",
|
||||
"43d7a45b97": "Cómo copiar",
|
||||
"b8a4f21c3e": "Pega el encabezado Cookie desde DevTools",
|
||||
"53f7b8c7a2": "Última actualización: {{value0}}",
|
||||
"31d24a4e87": "La cookie caduca cuando cierras sesión en el navegador.",
|
||||
"42c2cb21cf": "Olvidar cookie",
|
||||
"3a30aaf526": "ahora mismo",
|
||||
"f5d8d2a6a1": "Abre platform.minimax.io/console/usage en tu navegador e inicia sesión.",
|
||||
"24560fe830": "Abre DevTools.",
|
||||
"4cab0fa42d": "Ve a la pestaña Red y activa Preserve log.",
|
||||
"bee4e63e1c": "Recarga la página.",
|
||||
"87f814af6f": "Filtra por remains y selecciona la solicitud coding_plan/remains.",
|
||||
"435df0ee51": "En Request Headers, copia el valor de Cookie.",
|
||||
"7492fb3bba": "Pégalo aquí y haz clic en Guardar.",
|
||||
"9fec52de4b": "Cómo copiar la cookie",
|
||||
"4e32e030b2": "La cookie permanece en este dispositivo. Orca solo la envía a platform.minimax.io para actualizar el uso."
|
||||
},
|
||||
"AdvancedPane": {
|
||||
"40b29e0bf3": "Reiniciar",
|
||||
|
|
@ -6765,7 +6806,9 @@
|
|||
"bdbd1e668e": "Windows",
|
||||
"593720c17f": "ubicación",
|
||||
"b84a5b0c8a": "Elija si las cuentas de proveedor se inspeccionan y agregan en este dispositivo o en WSL.",
|
||||
"d09fb5ca92": "Ubicación de cuentas"
|
||||
"d09fb5ca92": "Ubicación de cuentas",
|
||||
"733f9e2a93": "Uso de MiniMax",
|
||||
"f8374c3151": "Pega tu cookie de sesión de platform.minimax.io para obtener límites de uso locales."
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
|
|
|
|||
|
|
@ -2935,7 +2935,9 @@
|
|||
"972a1ff497": "Codex の制限をリセットしますか?",
|
||||
"6d1042aa6f": "アクティブなアカウントの Codex レート制限リセット枠を 1 回使用し、対象の使用量ウィンドウを直ちにリセットします。",
|
||||
"f077f586db": "今後表示しない",
|
||||
"c0e972d726": "キャンセル"
|
||||
"c0e972d726": "キャンセル",
|
||||
"06741a2f3d": "Open MiniMax usage details",
|
||||
"3bbf140864": "MiniMax Usage"
|
||||
},
|
||||
"StatusBarUsageEmptyCta": {
|
||||
"828c764a79": "アカウントを接続する",
|
||||
|
|
@ -4619,7 +4621,46 @@
|
|||
"75ca9b718e": "Codex は、アクティブなアカウントには新たにサインインする必要があると報告しました。新規 Codex セッションを開始する前に、再認証してください。",
|
||||
"b11078a9c2": "wsl",
|
||||
"350b2a1aa7": "Use your current",
|
||||
"e05d0ff737": "現在の {{value0}} Claude ログインを使用します。"
|
||||
"e05d0ff737": "現在の {{value0}} Claude ログインを使用します。",
|
||||
"2f24f244a4": "MiniMax cookie is required.",
|
||||
"8d61637a77": "MiniMax cookie saved.",
|
||||
"b43e761fe5": "MiniMax cookie update failed.",
|
||||
"5d63bbfbec": "MiniMax",
|
||||
"15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.",
|
||||
"21d6eb141e": "MiniMax Session Cookie",
|
||||
"33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.",
|
||||
"73ea15f24b": "Saved",
|
||||
"23afe8f226": "Not saved",
|
||||
"566d9a99ab": "_token=…; minimax_group_id_v2=…",
|
||||
"f38b9cc4bd": "Replace",
|
||||
"590a3130f9": "Save",
|
||||
"79418c782a": "ブラウザで platform.minimax.io/console/usage を開き、サインインして、DevTools(ネットワーク→remains のリクエスト→Cookie)から Cookie リクエストヘッダーをコピーしてください。",
|
||||
"9dd50d3f75": "詳細設定",
|
||||
"174fb408f9": "MiniMax の使用量更新が誤ったワークスペースやモデルを指す場合のみ、これらの既定値を変更してください。",
|
||||
"bf160bb6c0": "グループ ID の上書き",
|
||||
"b1e2743313": "任意。空白にすると Cookie の minimax_group_id_v2 が使われます。",
|
||||
"0747d6391a": "Cookie からグループ ID を使用",
|
||||
"4ff2af7524": "使用モデルの名前",
|
||||
"5cf4b0f85f": "任意のコンマ区切りのモデル名。MiniMax がモデル固有のエラーを返す場合を除き、general のままにしてください。",
|
||||
"3c92b0d31c": "general",
|
||||
"0d8e77bc40": "コンソールを開く",
|
||||
"0b8c1c7e02": "ローカルで暗号化",
|
||||
"5e08b0fe57": "platform.minimax.io のみに送信されます。Orca の外部に出ることはありません。",
|
||||
"43d7a45b97": "コピー方法",
|
||||
"b8a4f21c3e": "DevTools から Cookie ヘッダーを貼り付け",
|
||||
"53f7b8c7a2": "最終更新: {{value0}}",
|
||||
"31d24a4e87": "ブラウザでサインアウトすると Cookie は無効になります。",
|
||||
"42c2cb21cf": "Cookie を削除",
|
||||
"3a30aaf526": "たった今",
|
||||
"f5d8d2a6a1": "ブラウザで platform.minimax.io/console/usage を開き、サインインします。",
|
||||
"24560fe830": "DevTools を開きます。",
|
||||
"4cab0fa42d": "ネットワークタブを開き、Preserve log を有効にします。",
|
||||
"bee4e63e1c": "ページを再読み込みします。",
|
||||
"87f814af6f": "remains でフィルタリングし、coding_plan/remains のリクエストを選択します。",
|
||||
"435df0ee51": "リクエストヘッダーの Cookie の値をコピーします。",
|
||||
"7492fb3bba": "ここに貼り付けて保存をクリックします。",
|
||||
"9fec52de4b": "Cookie のコピー方法",
|
||||
"4e32e030b2": "Cookie はこのデバイスにのみ保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。"
|
||||
},
|
||||
"AdvancedPane": {
|
||||
"40b29e0bf3": "再起動",
|
||||
|
|
@ -6787,7 +6828,9 @@
|
|||
"bdbd1e668e": "窓",
|
||||
"593720c17f": "場所",
|
||||
"b84a5b0c8a": "プロバイダー アカウントをこのデバイスまたは WSL で検査および追加するかどうかを選択します。",
|
||||
"d09fb5ca92": "アカウントの場所"
|
||||
"d09fb5ca92": "アカウントの場所",
|
||||
"733f9e2a93": "MiniMax Usage",
|
||||
"f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching."
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
|
|
|
|||
|
|
@ -2935,7 +2935,9 @@
|
|||
"972a1ff497": "Codex 한도를 재설정할까요?",
|
||||
"6d1042aa6f": "활성 계정의 Codex rate-limit 재설정 크레딧 1회를 사용해 재설정 가능한 사용량 창을 즉시 초기화합니다.",
|
||||
"f077f586db": "다시 묻지 않기",
|
||||
"c0e972d726": "취소"
|
||||
"c0e972d726": "취소",
|
||||
"06741a2f3d": "Open MiniMax usage details",
|
||||
"3bbf140864": "MiniMax Usage"
|
||||
},
|
||||
"StatusBarUsageEmptyCta": {
|
||||
"828c764a79": "계정 연결",
|
||||
|
|
@ -4619,7 +4621,46 @@
|
|||
"75ca9b718e": "Codex는 활성 계정에 새로 로그인해야 한다고 보고했습니다. 새 Codex 세션을 시작하기 전에 다시 인증하세요.",
|
||||
"b11078a9c2": "wsl",
|
||||
"350b2a1aa7": "현재 사용 중인",
|
||||
"e05d0ff737": "현재 {{value0}} Claude 로그인을 사용하세요."
|
||||
"e05d0ff737": "현재 {{value0}} Claude 로그인을 사용하세요.",
|
||||
"2f24f244a4": "MiniMax cookie is required.",
|
||||
"8d61637a77": "MiniMax cookie saved.",
|
||||
"b43e761fe5": "MiniMax cookie update failed.",
|
||||
"5d63bbfbec": "MiniMax",
|
||||
"15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.",
|
||||
"21d6eb141e": "MiniMax Session Cookie",
|
||||
"33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.",
|
||||
"73ea15f24b": "Saved",
|
||||
"23afe8f226": "Not saved",
|
||||
"566d9a99ab": "_token=…; minimax_group_id_v2=…",
|
||||
"f38b9cc4bd": "Replace",
|
||||
"590a3130f9": "Save",
|
||||
"79418c782a": "브라우저에서 platform.minimax.io/console/usage 를 열고 로그인한 다음 DevTools(네트워크 → 임의의 remains 요청 → Cookie)에서 Cookie 요청 헤더를 복사하세요.",
|
||||
"9dd50d3f75": "고급",
|
||||
"174fb408f9": "MiniMax 사용량 새로 고침이 잘못된 워크스페이스나 모델을 가리키는 경우에만 이 기본값을 변경하세요.",
|
||||
"bf160bb6c0": "Group ID 재정의",
|
||||
"b1e2743313": "선택 항목입니다. 비워두면 Cookie의 minimax_group_id_v2가 사용됩니다.",
|
||||
"0747d6391a": "Cookie의 Group ID 사용",
|
||||
"4ff2af7524": "사용 모델 이름",
|
||||
"5cf4b0f85f": "선택적인 쉼표로 구분된 모델 이름. MiniMax가 모델 관련 오류를 반환하지 않는 한 general로 두세요.",
|
||||
"3c92b0d31c": "general",
|
||||
"0d8e77bc40": "콘솔 열기",
|
||||
"0b8c1c7e02": "로컬에서 암호화됨",
|
||||
"5e08b0fe57": "platform.minimax.io에만 전송됩니다. Orca 외부로 나가지 않습니다.",
|
||||
"43d7a45b97": "복사 방법",
|
||||
"b8a4f21c3e": "DevTools에서 Cookie 헤더 붙여넣기",
|
||||
"53f7b8c7a2": "마지막 새로 고침: {{value0}}",
|
||||
"31d24a4e87": "브라우저에서 로그아웃하면 Cookie가 만료됩니다.",
|
||||
"42c2cb21cf": "Cookie 삭제",
|
||||
"3a30aaf526": "방금 전",
|
||||
"f5d8d2a6a1": "브라우저에서 platform.minimax.io/console/usage를 열고 로그인합니다.",
|
||||
"24560fe830": "DevTools를 엽니다.",
|
||||
"4cab0fa42d": "네트워크 탭으로 이동하여 Preserve log를 활성화합니다.",
|
||||
"bee4e63e1c": "페이지를 새로 고침합니다.",
|
||||
"87f814af6f": "remains로 필터링하고 coding_plan/remains 요청을 선택합니다.",
|
||||
"435df0ee51": "Request Headers에서 Cookie 값을 복사합니다.",
|
||||
"7492fb3bba": "여기에 붙여넣고 저장을 클릭합니다.",
|
||||
"9fec52de4b": "Cookie 복사 방법",
|
||||
"4e32e030b2": "Cookie는 이 기기에만 보관됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다."
|
||||
},
|
||||
"AdvancedPane": {
|
||||
"40b29e0bf3": "다시 시작",
|
||||
|
|
@ -6750,7 +6791,9 @@
|
|||
"bdbd1e668e": "Windows",
|
||||
"593720c17f": "위치",
|
||||
"b84a5b0c8a": "이 장치 또는 WSL에서 공급자 계정을 검사하고 추가할지 선택합니다.",
|
||||
"d09fb5ca92": "계정 위치"
|
||||
"d09fb5ca92": "계정 위치",
|
||||
"733f9e2a93": "MiniMax Usage",
|
||||
"f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching."
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
|
|
|
|||
|
|
@ -2935,7 +2935,9 @@
|
|||
"972a1ff497": "重置 Codex 限额?",
|
||||
"6d1042aa6f": "这会为当前账户使用一次 Codex 速率限制重置额度,并立即重置所有符合条件的用量窗口。",
|
||||
"f077f586db": "不再询问",
|
||||
"c0e972d726": "取消"
|
||||
"c0e972d726": "取消",
|
||||
"06741a2f3d": "Open MiniMax usage details",
|
||||
"3bbf140864": "MiniMax Usage"
|
||||
},
|
||||
"StatusBarUsageEmptyCta": {
|
||||
"828c764a79": "连接账户",
|
||||
|
|
@ -4619,7 +4621,46 @@
|
|||
"75ca9b718e": "Codex 报告活动账户需要重新登录。在开始新的 Codex 会话之前重新对其进行身份验证。",
|
||||
"b11078a9c2": "wsl",
|
||||
"350b2a1aa7": "用你已有的",
|
||||
"e05d0ff737": "使用您当前 {{value0}} Claude 登录名。"
|
||||
"e05d0ff737": "使用您当前 {{value0}} Claude 登录名。",
|
||||
"2f24f244a4": "MiniMax cookie is required.",
|
||||
"8d61637a77": "MiniMax cookie saved.",
|
||||
"b43e761fe5": "MiniMax cookie update failed.",
|
||||
"5d63bbfbec": "MiniMax",
|
||||
"15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.",
|
||||
"21d6eb141e": "MiniMax Session Cookie",
|
||||
"33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.",
|
||||
"73ea15f24b": "Saved",
|
||||
"23afe8f226": "Not saved",
|
||||
"566d9a99ab": "_token=…; minimax_group_id_v2=…",
|
||||
"f38b9cc4bd": "Replace",
|
||||
"590a3130f9": "Save",
|
||||
"79418c782a": "在浏览器中打开 platform.minimax.io/console/usage 并登录,然后从 DevTools(网络 → 任一 remains 请求 → Cookie)复制 Cookie 请求头。",
|
||||
"9dd50d3f75": "高级",
|
||||
"174fb408f9": "除非 MiniMax 使用量刷新指向了错误的工作区或模型,否则请保持这些默认值。",
|
||||
"bf160bb6c0": "Group ID 覆盖",
|
||||
"b1e2743313": "可选。留空将使用 Cookie 中的 minimax_group_id_v2。",
|
||||
"0747d6391a": "使用 Cookie 中的 Group ID",
|
||||
"4ff2af7524": "使用模型名称",
|
||||
"5cf4b0f85f": "可选的逗号分隔的模型名称。除非 MiniMax 返回模型特定错误,否则请保持为 general。",
|
||||
"3c92b0d31c": "general",
|
||||
"0d8e77bc40": "打开控制台",
|
||||
"0b8c1c7e02": "本地加密",
|
||||
"5e08b0fe57": "仅发送到 platform.minimax.io。不会离开 Orca。",
|
||||
"43d7a45b97": "如何复制",
|
||||
"b8a4f21c3e": "粘贴来自 DevTools 的 Cookie 请求头",
|
||||
"53f7b8c7a2": "上次刷新: {{value0}}",
|
||||
"31d24a4e87": "在浏览器中退出登录后,Cookie 将过期。",
|
||||
"42c2cb21cf": "忘记 Cookie",
|
||||
"3a30aaf526": "刚刚",
|
||||
"f5d8d2a6a1": "在浏览器中打开 platform.minimax.io/console/usage 并登录。",
|
||||
"24560fe830": "打开 DevTools。",
|
||||
"4cab0fa42d": "转到“网络”选项卡并启用“保留日志”。",
|
||||
"bee4e63e1c": "重新加载页面。",
|
||||
"87f814af6f": "按 remains 筛选并选择 coding_plan/remains 请求。",
|
||||
"435df0ee51": "在请求头中,复制 Cookie 的值。",
|
||||
"7492fb3bba": "在此粘贴并点击保存。",
|
||||
"9fec52de4b": "如何复制 Cookie",
|
||||
"4e32e030b2": "Cookie 仅保留在此设备上。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。"
|
||||
},
|
||||
"AdvancedPane": {
|
||||
"40b29e0bf3": "重新启动",
|
||||
|
|
@ -6750,7 +6791,9 @@
|
|||
"bdbd1e668e": "视窗",
|
||||
"593720c17f": "位置",
|
||||
"b84a5b0c8a": "选择是否在此设备上或 WSL 中检查和添加提供商账户。",
|
||||
"d09fb5ca92": "账户位置"
|
||||
"d09fb5ca92": "账户位置",
|
||||
"733f9e2a93": "MiniMax Usage",
|
||||
"f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching."
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
|
|
|
|||
|
|
@ -37,4 +37,21 @@ describe('formatWindowLabel', () => {
|
|||
it('returns "3d" for 4320 minutes (3 days)', () => {
|
||||
expect(formatWindowLabel(4320)).toBe('3d')
|
||||
})
|
||||
|
||||
it('documents the formatter contract: 295 minutes stays raw ("295m")', () => {
|
||||
// Why: the formatter does NOT snap — the snap lives in the MiniMax
|
||||
// fetcher (snapMiniMaxWindowMinutes), where raw drift of a few minutes
|
||||
// rounds to the canonical 300-minute bucket before reaching this label.
|
||||
// This test pins the contract so a future change to the formatter
|
||||
// does not silently regress the snap's "5h" output.
|
||||
expect(formatWindowLabel(295)).toBe('295m')
|
||||
expect(formatWindowLabel(300)).toBe('5h')
|
||||
})
|
||||
|
||||
it('falls back to per-minute labels outside canonical buckets', () => {
|
||||
// Why: when the window length lands between buckets (e.g. 2h30m), we
|
||||
// render the raw minute count rather than guess at a half-bucket label.
|
||||
expect(formatWindowLabel(75)).toBe('75m')
|
||||
expect(formatWindowLabel(150)).toBe('150m')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export const createRateLimitSlice: StateCreator<AppState, [], [], RateLimitSlice
|
|||
gemini: null,
|
||||
opencodeGo: null,
|
||||
kimi: null,
|
||||
minimax: null,
|
||||
minimaxCookieConfigured: false,
|
||||
claudeTarget: { runtime: 'host', wslDistro: null },
|
||||
codexTarget: { runtime: 'host', wslDistro: null },
|
||||
inactiveClaudeAccounts: [],
|
||||
|
|
|
|||
|
|
@ -2453,6 +2453,8 @@ function createRateLimitsApi(): NonNullable<Partial<PreloadApi>['rateLimits']> {
|
|||
gemini: null,
|
||||
opencodeGo: null,
|
||||
kimi: null,
|
||||
minimax: null,
|
||||
minimaxCookieConfigured: false,
|
||||
claudeTarget: { runtime: 'host', wslDistro: null },
|
||||
codexTarget: { runtime: 'host', wslDistro: null },
|
||||
inactiveClaudeAccounts: [],
|
||||
|
|
@ -2469,6 +2471,7 @@ function createRateLimitsApi(): NonNullable<Partial<PreloadApi>['rateLimits']> {
|
|||
setPollingInterval: () => Promise.resolve(),
|
||||
fetchInactiveClaudeAccounts: () => Promise.resolve(),
|
||||
fetchInactiveCodexAccounts: () => Promise.resolve(),
|
||||
refreshMiniMax: () => Promise.resolve(empty),
|
||||
onUpdate: () => noopUnsubscribe
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,3 +125,15 @@ describe('getDefaultPrimarySelectionMiddleClickPaste', () => {
|
|||
expect(getDefaultPrimarySelectionMiddleClickPaste('win32')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MiniMax defaults', () => {
|
||||
it('starts MiniMax with empty group id and the canonical default model', () => {
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
// Why: the fetcher reads these defaults on first launch. An empty
|
||||
// group id is the signal that the fetcher must pull the value from
|
||||
// the cookie itself, and "general" matches the model name the
|
||||
// MiniMax usage endpoint exposes by default.
|
||||
expect(settings.minimaxGroupId).toBe('')
|
||||
expect(settings.minimaxUsageModels).toBe('general')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -320,6 +320,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
defaultLinearTeamSelection: null,
|
||||
opencodeSessionCookie: '',
|
||||
opencodeWorkspaceId: '',
|
||||
minimaxGroupId: '',
|
||||
minimaxUsageModels: 'general',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { RateLimitState } from './rate-limit-types'
|
||||
|
||||
describe('RateLimitState', () => {
|
||||
it('documents the MiniMax surface used by the AccountsPane settings UI', () => {
|
||||
// Why: the AccountsPane and the status bar both read these fields
|
||||
// from RateLimitState. The shape must stay stable so that the
|
||||
// visibility check (status-bar-provider-visibility) keeps working
|
||||
// across refactors.
|
||||
const state: RateLimitState = {
|
||||
claude: null,
|
||||
codex: null,
|
||||
gemini: null,
|
||||
opencodeGo: null,
|
||||
kimi: null,
|
||||
minimax: null,
|
||||
minimaxCookieConfigured: false,
|
||||
claudeTarget: { runtime: 'host', wslDistro: null },
|
||||
codexTarget: { runtime: 'host', wslDistro: null },
|
||||
inactiveClaudeAccounts: [],
|
||||
inactiveCodexAccounts: []
|
||||
}
|
||||
|
||||
expect(state.minimax).toBeNull()
|
||||
expect(state.minimaxCookieConfigured).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -44,7 +44,7 @@ export type UsageRateLimitMetadata = {
|
|||
}
|
||||
|
||||
export type ProviderRateLimits = {
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax'
|
||||
/** 5-hour session window, null if not available. */
|
||||
session: RateLimitWindow | null
|
||||
/** 7-day weekly window, null if not available. */
|
||||
|
|
@ -101,6 +101,14 @@ export type RateLimitState = {
|
|||
gemini: ProviderRateLimits | null
|
||||
opencodeGo: ProviderRateLimits | null
|
||||
kimi: ProviderRateLimits | null
|
||||
minimax: ProviderRateLimits | null
|
||||
/**
|
||||
* True when a MiniMax session cookie is persisted on disk. The cookie lives
|
||||
* outside GlobalSettings, so this flag is the durable signal that the
|
||||
* status bar uses to keep the MiniMax provider visible across reloads and
|
||||
* between snapshot refreshes.
|
||||
*/
|
||||
minimaxCookieConfigured: boolean
|
||||
claudeTarget: RateLimitRuntimeTarget
|
||||
codexTarget: RateLimitRuntimeTarget
|
||||
inactiveClaudeAccounts: InactiveAccountUsage[]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [
|
|||
'gemini',
|
||||
'opencode-go',
|
||||
'kimi',
|
||||
'minimax',
|
||||
'ssh',
|
||||
'resource-usage',
|
||||
'ports'
|
||||
|
|
|
|||
|
|
@ -2763,6 +2763,10 @@ export type GlobalSettings = {
|
|||
/** Optional workspace ID override for OpenCode Go. When set, skips the
|
||||
* workspaces lookup and fetches usage directly for this workspace. */
|
||||
opencodeWorkspaceId: string
|
||||
/** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */
|
||||
minimaxGroupId: string
|
||||
/** Comma-separated MiniMax model names to show in the status bar usage window. */
|
||||
minimaxUsageModels: string
|
||||
/** Whether to extract OAuth credentials from the local Gemini CLI installation
|
||||
* for rate-limit fetching. Disabled by default for explicit opt-in. */
|
||||
geminiCliOAuthEnabled: boolean
|
||||
|
|
@ -3109,6 +3113,7 @@ export type StatusBarItem =
|
|||
| 'gemini'
|
||||
| 'opencode-go'
|
||||
| 'kimi'
|
||||
| 'minimax'
|
||||
| 'ssh'
|
||||
| 'resource-usage'
|
||||
| 'ports'
|
||||
|
|
|
|||
Loading…
Reference in New Issue