mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Fix MiniMax credential-expiry reporting, region sync, and refresh (#19250)
* Fix MiniMax credential-expiry reporting, region sync, and refresh Three defects from #14929: 1. The usage endpoint answers an expired cookie or key with HTTP 200 and base_resp.status_code 1004, never 401/403 (confirmed against both regional hosts). The stale-token branch was therefore unreachable, so expired credentials surfaced as 'usage-unavailable' with the raw upstream string, and stale policy kept showing old numbers as if the failure were transient. Classify 1004 as an expired credential. 2. minimaxEndpoint reached the SettingsUpdate schema and the web store but was never projected by RuntimeClientSettingsController.get(), so a paired client fell back to 'overseas' regardless of the host's region and rendered the wrong console link. Add it to the projection and the store contract. 3. Changing the region persisted without refreshing usage, leaving the previous host's snapshot in the status bar until the next poll. Invalidate and refetch when the endpoint, group id, or model list changes. The RPC-level tests mock the controller, so the projection had no real coverage; the new test fails against the pre-fix projection. * Localize the MiniMax credential-expiry copy Classifying 1004 as stale-token made the status bar show the raw English error verbatim: the new wording matches none of USAGE_AUTH_ERROR_PATTERNS, whereas the old upstream text ('...log in again') matched and was replaced with localized copy. That traded a localized-but-misleading message for an actionable English-only one, which is the wrong trade for the CN users this work targets. Tag the error with credentialSource so the renderer can pick the right localized string per credential kind, and add the three catalog entries.
This commit is contained in:
@@ -43,7 +43,10 @@ export function makeMiniMaxUnavailable(error: string): ProviderRateLimits {
|
||||
|
||||
export function makeMiniMaxError(
|
||||
error: string,
|
||||
failureKind: NonNullable<ProviderRateLimits['usageMetadata']>['failureKind']
|
||||
failureKind: NonNullable<ProviderRateLimits['usageMetadata']>['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 } : {}) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof settings>) => {
|
||||
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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<GlobalSettings>) {
|
||||
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<GlobalSettings>).minimaxEndpoint
|
||||
const projected = new RuntimeClientSettingsController({
|
||||
getSettings: () => settings
|
||||
} as never).get()
|
||||
expect(projected.minimaxEndpoint).toBe('overseas')
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user