diff --git a/resources/minimax-icon.svg b/resources/minimax-icon.svg new file mode 100644 index 000000000..9698ab275 --- /dev/null +++ b/resources/minimax-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 51908be32..0f6d968cc 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -127,6 +127,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 13b39b6b0..69fed98ea 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -131,6 +131,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, diff --git a/src/main/index.ts b/src/main/index.ts index ff4b249f8..902a01a8d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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({ diff --git a/src/main/ipc/minimax-credentials.test.ts b/src/main/ipc/minimax-credentials.test.ts new file mode 100644 index 000000000..b34d85ad1 --- /dev/null +++ b/src/main/ipc/minimax-credentials.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcState = vi.hoisted(() => ({ + handleHandlers: new Map 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 + service: Pick +} { + const refresh = vi.fn(() => Promise.resolve({} as RateLimitState)) + return { refresh, service: { refresh } } +} + +async function invoke(channel: string, ...args: unknown[]): Promise { + 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 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) + ) + }) +}) diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts new file mode 100644 index 000000000..9f7737621 --- /dev/null +++ b/src/main/ipc/minimax-credentials.ts @@ -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() + }) +} diff --git a/src/main/ipc/rate-limits.test.ts b/src/main/ipc/rate-limits.test.ts new file mode 100644 index 000000000..3a299c5bd --- /dev/null +++ b/src/main/ipc/rate-limits.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcState = vi.hoisted(() => ({ + handleHandlers: new Map 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 } { + 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) + }) +}) diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index 3f657c0c1..a28b78102 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -23,4 +23,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void { ipcMain.handle('rateLimits:fetchInactiveCodexAccounts', () => rateLimits.fetchInactiveCodexAccountsOnOpen() ) + ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh()) } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 76e8b1693..5ce6342c3 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 4baae415b..50dbd0a8f 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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) diff --git a/src/main/minimax/minimax-cookie-store.test.ts b/src/main/minimax/minimax-cookie-store.test.ts new file mode 100644 index 000000000..436add5a8 --- /dev/null +++ b/src/main/minimax/minimax-cookie-store.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts new file mode 100644 index 000000000..31102e4bb --- /dev/null +++ b/src/main/minimax/minimax-cookie-store.ts @@ -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 }) +} diff --git a/src/main/rate-limits/minimax-fetcher.test.ts b/src/main/rate-limits/minimax-fetcher.test.ts new file mode 100644 index 000000000..89c095de9 --- /dev/null +++ b/src/main/rate-limits/minimax-fetcher.test.ts @@ -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]') + }) +}) diff --git a/src/main/rate-limits/minimax-fetcher.ts b/src/main/rate-limits/minimax-fetcher.ts new file mode 100644 index 000000000..a04ba89db --- /dev/null +++ b/src/main/rate-limits/minimax-fetcher.ts @@ -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['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 { + 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 { + 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) + } +} diff --git a/src/main/rate-limits/minimax-request-context.test.ts b/src/main/rate-limits/minimax-request-context.test.ts new file mode 100644 index 000000000..15caa5521 --- /dev/null +++ b/src/main/rate-limits/minimax-request-context.test.ts @@ -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 + + 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 + }) + ) + }) +}) diff --git a/src/main/rate-limits/minimax-request-context.ts b/src/main/rate-limits/minimax-request-context.ts new file mode 100644 index 000000000..3953ab53e --- /dev/null +++ b/src/main/rate-limits/minimax-request-context.ts @@ -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 { + const headers: Record = { + 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 { + 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 { + 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 + }) +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 1b810859c..796d7d516 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -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 = { promise: Promise resolve: (value: T) => void @@ -42,7 +52,7 @@ function deferred(): Deferred { } 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') + }) }) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 840f4e4a7..7e2bf9481 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -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 | 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() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0af29e96d..cb9cd51aa 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2796,8 +2796,14 @@ export type PreloadApi = { setPollingInterval: (ms: number) => Promise fetchInactiveClaudeAccounts: () => Promise fetchInactiveCodexAccounts: () => Promise + refreshMiniMax: () => Promise onUpdate: (callback: (state: RateLimitState) => void) => () => void } + minimaxCredentials: { + getStatus: () => Promise<{ configured: boolean }> + saveCookie: (cookie: string) => Promise<{ configured: boolean }> + clearCookie: () => Promise<{ configured: boolean }> + } ssh: { listTargets: () => Promise addTarget: (args: { target: Omit }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 01a42bd43..680c8b8d8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3799,6 +3799,7 @@ const api = { ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), fetchInactiveCodexAccounts: (): Promise => ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), + refreshMiniMax: (): Promise => 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 => ipcRenderer.invoke('ssh:listTargets'), diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index d50003b51..aac01de62 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -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 ( +
+
+

+ {translate('auto.components.settings.AccountsPane.9fec52de4b', 'How to copy the cookie')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.4e32e030b2', + 'The cookie stays on this device. Orca only sends it to platform.minimax.io for usage refreshes.' + )} +

+
+
    + {steps.map((step) => ( +
  1. {step}
  2. + ))} +
+
+ ) +} 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>(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 => { + 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 => { + 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 => { + 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({

+ ) : null, + matchesSettingsSearch(searchQuery, getAccountsMiniMaxSearchEntries()) ? ( +
+
+
+

+ + {translate('auto.components.settings.AccountsPane.5d63bbfbec', 'MiniMax')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.15e831350e', + 'Configure MiniMax usage tracking from platform.minimax.io.' + )} +

+
+ + {translate('auto.components.settings.AccountsPane.0d8e77bc40', 'Open console')} + + +
+ +
+ +
+

+ {translate( + 'auto.components.settings.AccountsPane.0b8c1c7e02', + miniMaxConfigured ? 'Encrypted locally' : 'Cookie not set' + )} +

+

+ {translate( + 'auto.components.settings.AccountsPane.5e08b0fe57', + 'Sent only to platform.minimax.io. Never leaves Orca.' + )} +

+
+
+ + +
+
+ + + {miniMaxConfigured ? : } + {miniMaxConfigured + ? translate('auto.components.settings.AccountsPane.73ea15f24b', 'Saved') + : translate('auto.components.settings.AccountsPane.23afe8f226', 'Not saved')} + +
+ + + + + + + + +
+
+ setMiniMaxCookieDraft(e.target.value)} + placeholder={translate( + 'auto.components.settings.AccountsPane.b8a4f21c3e', + 'Paste the Cookie header from DevTools' + )} + spellCheck={false} + className="flex-1 text-xs" + /> + + {miniMaxConfigured ? ( + + ) : null} +
+

+ {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).' + )} +

+ {miniMaxConfigured && + miniMaxRateLimits?.status === 'ok' && + miniMaxRateLimits.error === null ? ( +

+ {translate( + 'auto.components.settings.AccountsPane.53f7b8c7a2', + 'Last refresh: {{value0}}', + { value0: formatMiniMaxRelativeRefresh(miniMaxRateLimits.updatedAt, Date.now()) } + )} +

+ ) : null} +

+ {translate( + 'auto.components.settings.AccountsPane.31d24a4e87', + 'Cookie expires when you sign out in the browser.' + )} +

+
+ +
+
+
+

+ {translate('auto.components.settings.AccountsPane.9dd50d3f75', 'Advanced')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.174fb408f9', + 'Leave these defaults alone unless MiniMax usage refresh points at the wrong workspace or model.' + )} +

+
+ {miniMaxConfigured ? ( + + ) : null} +
+ + + + 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" + /> + + + + + updateSettings({ minimaxUsageModels: e.target.value })} + placeholder={translate('auto.components.settings.AccountsPane.3c92b0d31c', 'general')} + spellCheck={false} + className="text-xs" + /> + +
+
) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/accounts-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts new file mode 100644 index 000000000..7958045b8 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -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: + (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') + }) +}) diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 1d4e15645..7f0d68a83 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -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() ]) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index d08ffca7a..6fcbc6880 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -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'} ) : ( @@ -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 && ( + + )} )} {anyVisible && !isEmptyUsageState && ( @@ -2087,6 +2115,16 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele {translate('auto.components.status.bar.StatusBar.5e59007df4', 'Kimi Usage')} )} + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('minimax') + }} + > + + {translate('auto.components.status.bar.StatusBar.3bbf140864', 'MiniMax Usage')} + { diff --git a/src/renderer/src/components/status-bar/icons.test.tsx b/src/renderer/src/components/status-bar/icons.test.tsx new file mode 100644 index 000000000..d46d9a65c --- /dev/null +++ b/src/renderer/src/components/status-bar/icons.test.tsx @@ -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() + expect(markup.startsWith(' { + const markup = renderToStaticMarkup() + expect(markup).toContain('width="20"') + expect(markup).toContain('height="20"') + }) + + it('does not render the legacy "M" placeholder text', () => { + const markup = renderToStaticMarkup() + expect(markup).not.toContain('>M<') + }) +}) diff --git a/src/renderer/src/components/status-bar/icons.tsx b/src/renderer/src/components/status-bar/icons.tsx index e6fb38bcc..480297b64 100644 --- a/src/renderer/src/components/status-bar/icons.tsx +++ b/src/renderer/src/components/status-bar/icons.tsx @@ -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 ( + + ) +} + // 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. diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index 790a4dbfa..23de9434c 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -70,6 +70,7 @@ function usageSettings(overrides: Partial = {}): 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() ) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index b6e35c292..bbac8ba8a 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -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) ) } diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index cf6314236..510e3eea6 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -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 with a non-empty + // resource URL and aria-hidden so the icon stays purely decorative. + const markup = renderToStaticMarkup(ProviderIcon({ provider: 'minimax' })) + expect(markup.startsWith(' } + if (provider === 'minimax') { + return + } return } diff --git a/src/renderer/src/components/status-bar/usage-error-copy.test.ts b/src/renderer/src/components/status-bar/usage-error-copy.test.ts new file mode 100644 index 000000000..3fc29780a --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-error-copy.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 60c0b99d4..3d33fe763 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -17,6 +17,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'kimi') { return 'Kimi' } + if (provider === 'minimax') { + return 'MiniMax' + } return provider } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1080958f7..3226d39b6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b7a295853..b7bccb7e4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e6ff4fc77..2496debcd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 705815c77..e774013d0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index db2841621..29d853b24 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/lib/window-label-formatter.test.ts b/src/renderer/src/lib/window-label-formatter.test.ts index 730afbbd5..01050c93c 100644 --- a/src/renderer/src/lib/window-label-formatter.test.ts +++ b/src/renderer/src/lib/window-label-formatter.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index fa4d41b9b..4513119ab 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -21,6 +21,8 @@ export const createRateLimitSlice: StateCreator['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['rateLimits']> { setPollingInterval: () => Promise.resolve(), fetchInactiveClaudeAccounts: () => Promise.resolve(), fetchInactiveCodexAccounts: () => Promise.resolve(), + refreshMiniMax: () => Promise.resolve(empty), onUpdate: () => noopUnsubscribe } } diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 26db02876..76afe5caa 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -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') + }) +}) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4eebbaa93..cc9f8ce96 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 }, diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts new file mode 100644 index 000000000..dfc8c1dad --- /dev/null +++ b/src/shared/rate-limit-types.test.ts @@ -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) + }) +}) diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 59a897f51..6ed2c696f 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -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[] diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 7e6738495..5e9939c64 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -6,6 +6,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'gemini', 'opencode-go', 'kimi', + 'minimax', 'ssh', 'resource-usage', 'ports' diff --git a/src/shared/types.ts b/src/shared/types.ts index 57c2d61c1..8d3268035 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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'