diff --git a/src/main/rate-limits/minimax/minimax-fetcher-data.ts b/src/main/rate-limits/minimax/minimax-fetcher-data.ts index 19e6d3f6768..25506bb891b 100644 --- a/src/main/rate-limits/minimax/minimax-fetcher-data.ts +++ b/src/main/rate-limits/minimax/minimax-fetcher-data.ts @@ -43,7 +43,10 @@ export function makeMiniMaxUnavailable(error: string): ProviderRateLimits { export function makeMiniMaxError( error: string, - failureKind: NonNullable['failureKind'] + failureKind: NonNullable['failureKind'], + // Why: the status bar localizes the expiry copy per credential kind; the raw + // `error` string stays English for logs. + credentialSource?: 'api-key' | 'session-cookie' ): ProviderRateLimits { return { provider: 'minimax', @@ -52,7 +55,7 @@ export function makeMiniMaxError( updatedAt: Date.now(), error, status: 'error', - usageMetadata: { failureKind, source: 'web' } + usageMetadata: { failureKind, source: 'web', ...(credentialSource ? { credentialSource } : {}) } } } diff --git a/src/main/rate-limits/minimax/minimax-fetcher-parse.ts b/src/main/rate-limits/minimax/minimax-fetcher-parse.ts index 8e776654b15..fdb4d728b0b 100644 --- a/src/main/rate-limits/minimax/minimax-fetcher-parse.ts +++ b/src/main/rate-limits/minimax/minimax-fetcher-parse.ts @@ -34,6 +34,19 @@ export type MiniMaxUsageResponse = { }[] } +// Why: MiniMax answers an expired cookie/key with HTTP 200 + base_resp.status_code 1004, +// so the credential-expiry signal has to be read from the payload, not the status line. +const MINIMAX_UNAUTHENTICATED_STATUS_CODE = 1004 + +function makeMiniMaxExpiredCredentialError(fetchResult: MiniMaxFetchResponse): ProviderRateLimits { + const usesApiKey = fetchResult.transport === 'api-key' + return makeMiniMaxError( + `MiniMax ${usesApiKey ? 'API key' : 'session cookie'} expired. Replace it in Settings.`, + 'stale-token', + usesApiKey ? 'api-key' : 'session-cookie' + ) +} + function handleMiniMaxHttpError(fetchResult: MiniMaxFetchResponse): ProviderRateLimits | null { const { response } = fetchResult if (response.status === 401 || response.status === 403) { @@ -43,11 +56,7 @@ function handleMiniMaxHttpError(fetchResult: MiniMaxFetchResponse): ProviderRate cookieNames: fetchResult.cookieNames, requestHeaderNames: fetchResult.requestHeaderNames }) - const credentialLabel = fetchResult.transport === 'api-key' ? 'API key' : 'session cookie' - return makeMiniMaxError( - `MiniMax ${credentialLabel} expired. Replace it in Settings.`, - 'stale-token' - ) + return makeMiniMaxExpiredCredentialError(fetchResult) } if (!response.ok) { logMiniMaxFetchFailure({ @@ -77,6 +86,9 @@ function handleMiniMaxPayloadError( cookieNames: fetchResult.cookieNames, requestHeaderNames: fetchResult.requestHeaderNames }) + if (statusCode === MINIMAX_UNAUTHENTICATED_STATUS_CODE) { + return makeMiniMaxExpiredCredentialError(fetchResult) + } const message = typeof payload.base_resp?.status_msg === 'string' ? payload.base_resp.status_msg diff --git a/src/main/rate-limits/minimax/minimax-fetcher.test.ts b/src/main/rate-limits/minimax/minimax-fetcher.test.ts index f3ca0709aba..d587c6b9256 100644 --- a/src/main/rate-limits/minimax/minimax-fetcher.test.ts +++ b/src/main/rate-limits/minimax/minimax-fetcher.test.ts @@ -356,6 +356,32 @@ describe('fetchMiniMaxRateLimits', () => { expect(result.error).toContain('unauth') }) + // Why: the live API answers an expired cookie/key with HTTP 200 + status_code 1004, + // never 401/403, so this is the only signal that reaches the stale-credential path. + it('classifies status_code 1004 on the cookie path as an expired session cookie', async () => { + netFetchMock.mockResolvedValueOnce( + makeResponse({ + base_resp: { status_code: 1004, status_msg: 'cookie is missing, log in again' } + }) + ) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('stale-token') + expect(result.error).toMatch(/session cookie expired/i) + }) + + it('classifies status_code 1004 on the API key path as an expired API key', async () => { + netFetchMock.mockResolvedValueOnce( + makeResponse({ + base_resp: { status_code: 1004, status_msg: 'cookie is missing, log in again' } + }) + ) + const result = await fetchMiniMaxRateLimits({ apiKey: 'sk-expired', endpointMode: 'cn' }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('stale-token') + expect(result.error).toMatch(/API key expired/i) + }) + it('classifies malformed MiniMax JSON responses as parse failures', async () => { netFetchMock.mockResolvedValueOnce({ ok: true, diff --git a/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts b/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts index e5cb14671e3..a823e7d4591 100644 --- a/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts +++ b/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts @@ -30,6 +30,7 @@ describe('OrcaRuntimeService', () => { compactWorktreeCards: true, minimaxGroupId: 'group-42', minimaxUsageModels: 'general,abab6.5', + minimaxEndpoint: 'cn', terminalQuickCommands }) } as never) @@ -39,7 +40,9 @@ describe('OrcaRuntimeService', () => { experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, minimaxGroupId: 'group-42', - minimaxUsageModels: 'general,abab6.5' + minimaxUsageModels: 'general,abab6.5', + // Why: without this the paired client silently falls back to 'overseas' and shows the wrong region. + minimaxEndpoint: 'cn' }) expect(runtime.getClientSettings()).not.toHaveProperty('terminalQuickCommands') expect(runtime.getClientSettings().hostSettingOverrides).toEqual({ @@ -194,7 +197,8 @@ describe('OrcaRuntimeService', () => { experimentalNewWorktreeCardStyle: false, compactWorktreeCards: false, minimaxGroupId: '', - minimaxUsageModels: 'general' + minimaxUsageModels: 'general', + minimaxEndpoint: 'overseas' } const updateSettings = vi.fn((updates: Partial) => { settings = { ...settings, ...updates } @@ -211,20 +215,23 @@ describe('OrcaRuntimeService', () => { experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, minimaxGroupId: 'group-42', - minimaxUsageModels: 'general,abab6.5' + minimaxUsageModels: 'general,abab6.5', + minimaxEndpoint: 'cn' }) ).toMatchObject({ experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, minimaxGroupId: 'group-42', - minimaxUsageModels: 'general,abab6.5' + minimaxUsageModels: 'general,abab6.5', + minimaxEndpoint: 'cn' }) expect(updateSettings).toHaveBeenCalledWith( { experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, minimaxGroupId: 'group-42', - minimaxUsageModels: 'general,abab6.5' + minimaxUsageModels: 'general,abab6.5', + minimaxEndpoint: 'cn' }, { notifyListeners: true } ) @@ -232,7 +239,8 @@ describe('OrcaRuntimeService', () => { experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, minimaxGroupId: 'group-42', - minimaxUsageModels: 'general,abab6.5' + minimaxUsageModels: 'general,abab6.5', + minimaxEndpoint: 'cn' }) }) diff --git a/src/main/runtime/runtime-client-settings-minimax-projection.test.ts b/src/main/runtime/runtime-client-settings-minimax-projection.test.ts new file mode 100644 index 00000000000..1088ee47772 --- /dev/null +++ b/src/main/runtime/runtime-client-settings-minimax-projection.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeClientSettingsController } from './runtime-client-settings' +import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' +import type { GlobalSettings } from '../../shared/global-settings-types' + +// Why: the paired client renders the region selector and console link from this projection. +// Omitting a field here silently falls the client back to its own default, and the RPC-level +// tests mock the controller, so only a real get() covers it. +function getProjected(overrides: Partial) { + const settings = createGlobalSettingsFixture({ workspaceDir: '/w', ...overrides }) + return new RuntimeClientSettingsController({ getSettings: () => settings } as never).get() +} + +describe('RuntimeClientSettingsController MiniMax projection', () => { + it('publishes the China endpoint to paired clients', () => { + expect(getProjected({ minimaxEndpoint: 'cn' }).minimaxEndpoint).toBe('cn') + }) + + it('publishes the overseas endpoint to paired clients', () => { + expect(getProjected({ minimaxEndpoint: 'overseas' }).minimaxEndpoint).toBe('overseas') + }) + + it('falls back to overseas when the host has no persisted endpoint', () => { + const settings = createGlobalSettingsFixture({ workspaceDir: '/w' }) + delete (settings as Partial).minimaxEndpoint + const projected = new RuntimeClientSettingsController({ + getSettings: () => settings + } as never).get() + expect(projected.minimaxEndpoint).toBe('overseas') + }) +}) diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index fc80c924156..900900700f3 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -40,6 +40,7 @@ export type RuntimeClientSettings = Pick< | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' + | 'minimaxEndpoint' | 'prBotAuthorOverrides' | 'artifactSharingEnabled' | 'worktreeVisibilityDefaults' @@ -70,6 +71,7 @@ export type RuntimeClientSettingsUpdate = Pick< | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' + | 'minimaxEndpoint' | 'prBotAuthorOverrides' | 'worktreeVisibilityDefaults' > @@ -110,6 +112,7 @@ export class RuntimeClientSettingsController { compactWorktreeCards: settings.compactWorktreeCards === true, minimaxGroupId: settings.minimaxGroupId ?? '', minimaxUsageModels: settings.minimaxUsageModels ?? 'general', + minimaxEndpoint: settings.minimaxEndpoint ?? 'overseas', prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [], artifactSharingEnabled: isArtifactSharingEnabled(settings), worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' }, diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 854d52bd0ba..f3f5d5a8f51 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -100,6 +100,7 @@ export type RuntimeStore = { compactWorktreeCards?: GlobalSettings['compactWorktreeCards'] minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] + minimaxEndpoint?: GlobalSettings['minimaxEndpoint'] prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides'] artifactSharingEnabled?: GlobalSettings['artifactSharingEnabled'] terminalQuickCommands?: GlobalSettings['terminalQuickCommands'] diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index ebfdd73f2f8..cf22c90b323 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -86,6 +86,21 @@ export function initializeMainProcessAccountServices(): void { void syncAccountRuntimeTargets(updates, settings).catch((error) => console.warn('[rate-limits] Failed to apply account runtime target:', error) ) + // Why: these three pick the MiniMax host and quota bucket, so a stale snapshot from the + // previous endpoint would otherwise sit in the status bar until the next poll. + if ( + 'minimaxEndpoint' in updates || + 'minimaxGroupId' in updates || + 'minimaxUsageModels' in updates + ) { + state.rateLimits?.invalidateMiniMaxCredentialState() + void state.rateLimits?.refresh().catch((error: unknown) => { + console.warn( + '[rate-limits] Failed to refresh MiniMax usage after a settings change:', + error + ) + }) + } }) state.rateLimits.setClaudeAuthPreparationResolver((target) => state.claudeRuntimeAuth!.prepareForRateLimitFetch(target) 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 39032ab5e2c..dff1d178034 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -111,6 +111,11 @@ export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { break } } + // Why: MiniMax reports credential expiry through the payload, not an HTTP status, + // so it needs its own copy rather than the generic refresh-failure label. + if (p.provider === 'minimax' && p.usageMetadata?.failureKind === 'stale-token') { + return translate('auto.components.status.bar.tooltip.minimax.expired.label', 'Sign-in expired') + } if (isUsageRateLimitError(p.error)) { return translate('auto.components.status.bar.tooltip.7ad719c4bf', 'Limited') } @@ -182,6 +187,17 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { if (isUsageRateLimitError(p.error)) { return p.error } + if (p.provider === 'minimax' && p.usageMetadata?.failureKind === 'stale-token') { + return p.usageMetadata.credentialSource === 'api-key' + ? translate( + 'auto.components.status.bar.tooltip.minimax.expired.apiKey', + 'MiniMax API key expired. Replace it in Settings.' + ) + : translate( + 'auto.components.status.bar.tooltip.minimax.expired.cookie', + 'MiniMax session cookie expired. Replace it in Settings.' + ) + } if (isUsageAuthError(p.error)) { const name = getProviderDisplayName(p.provider) return translate( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cf00f09b861..b23161d3887 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3903,7 +3903,14 @@ "e2c6a4f917": "Run Grok to refresh", "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", "f90b3d7a16": "Run Kimi to refresh", - "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage.", + "minimax": { + "expired": { + "label": "Sign-in expired", + "apiKey": "MiniMax API key expired. Replace it in Settings.", + "cookie": "MiniMax session cookie expired. Replace it in Settings." + } + } }, "SshTargetStatusRow": { "sshHost": "SSH Host"