[codex] fix expired connection health recovery (#393)
* fix expired connection health recovery * address review comments and test regressions
This commit is contained in:
@@ -24,17 +24,61 @@ describe('accountHealthService', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'expired',
|
||||
siteStatus: 'active',
|
||||
extraConfig: null,
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
sessionCapable: false,
|
||||
});
|
||||
|
||||
expect(health).toMatchObject({
|
||||
state: 'unknown',
|
||||
source: 'none',
|
||||
state: 'unhealthy',
|
||||
source: 'auth',
|
||||
});
|
||||
expect(health.reason).toContain('连接已过期');
|
||||
expect(health.reason).not.toContain('访问令牌已过期');
|
||||
});
|
||||
|
||||
it('does not show proxy-only expired accounts as healthy just because models were discovered before expiry', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'expired',
|
||||
siteStatus: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'apikey',
|
||||
runtimeHealth: {
|
||||
state: 'healthy',
|
||||
reason: '模型探测成功',
|
||||
source: 'model-discovery',
|
||||
checkedAt: '2026-04-01T10:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
sessionCapable: false,
|
||||
hasDiscoveredModels: true,
|
||||
});
|
||||
|
||||
expect(health).toMatchObject({
|
||||
state: 'unhealthy',
|
||||
source: 'auth',
|
||||
});
|
||||
expect(health.reason).toContain('连接已过期');
|
||||
});
|
||||
|
||||
it('uses a generic expired-credential message for oauth-backed accounts', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'expired',
|
||||
siteStatus: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
},
|
||||
}),
|
||||
sessionCapable: false,
|
||||
});
|
||||
|
||||
expect(health).toMatchObject({
|
||||
state: 'unhealthy',
|
||||
source: 'auth',
|
||||
reason: '连接凭证已过期,请更新凭证',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores stored auth failures for proxy-only accounts', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'active',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||
import {
|
||||
getCredentialModeFromExtraConfig,
|
||||
hasOauthProvider,
|
||||
mergeAccountExtraConfig,
|
||||
} from './accountExtraConfig.js';
|
||||
|
||||
export type RuntimeHealthState = 'healthy' | 'unhealthy' | 'degraded' | 'unknown' | 'disabled';
|
||||
|
||||
@@ -115,10 +119,18 @@ export function buildRuntimeHealthForAccount(input: {
|
||||
};
|
||||
}
|
||||
|
||||
if (accountStatus === 'expired' && input.sessionCapable !== false) {
|
||||
if (accountStatus === 'expired') {
|
||||
const credentialMode = getCredentialModeFromExtraConfig(input.extraConfig);
|
||||
const usesOauthCredential = hasOauthProvider({ extraConfig: input.extraConfig });
|
||||
return {
|
||||
state: 'unhealthy',
|
||||
reason: '访问令牌已过期',
|
||||
reason: usesOauthCredential
|
||||
? '连接凭证已过期,请更新凭证'
|
||||
: credentialMode === 'apikey'
|
||||
? '连接已过期,请更新 API Key'
|
||||
: (credentialMode === 'session'
|
||||
? '访问令牌已过期'
|
||||
: '连接凭证已过期,请更新凭证'),
|
||||
source: 'auth',
|
||||
checkedAt: null,
|
||||
};
|
||||
|
||||
@@ -120,6 +120,19 @@ describe('accountMutationWorkflow', () => {
|
||||
expect(result.refreshedModels).toBe(false);
|
||||
});
|
||||
|
||||
it('passes allowInactive to model refresh when recovery explicitly requests it', async () => {
|
||||
refreshModelsForAccountMock.mockResolvedValue({ accountId: 5, refreshed: true, status: 'success' });
|
||||
|
||||
const { convergeAccountMutation } = await import('./accountMutationWorkflow.js');
|
||||
await convergeAccountMutation({
|
||||
accountId: 5,
|
||||
refreshModels: true,
|
||||
allowInactiveModelRefresh: true,
|
||||
});
|
||||
|
||||
expect(refreshModelsForAccountMock).toHaveBeenCalledWith(5, { allowInactive: true });
|
||||
});
|
||||
|
||||
it('refreshes model coverage in batches and maps failures', async () => {
|
||||
refreshModelsForAccountMock
|
||||
.mockResolvedValueOnce({ accountId: 1, refreshed: true, status: 'success' })
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function convergeAccountMutation(input: {
|
||||
upstreamTokens?: UpstreamTokenLike[];
|
||||
refreshBalance?: boolean;
|
||||
refreshModels?: boolean;
|
||||
allowInactiveModelRefresh?: boolean;
|
||||
rebuildRoutes?: boolean;
|
||||
continueOnError?: boolean;
|
||||
}): Promise<{
|
||||
@@ -111,7 +112,11 @@ export async function convergeAccountMutation(input: {
|
||||
}
|
||||
|
||||
if (input.refreshModels) {
|
||||
const modelRefreshResult = await runStep(() => refreshModelsForAccount(input.accountId));
|
||||
const modelRefreshResult = await runStep(() => (
|
||||
input.allowInactiveModelRefresh
|
||||
? refreshModelsForAccount(input.accountId, { allowInactive: true })
|
||||
: refreshModelsForAccount(input.accountId)
|
||||
));
|
||||
if (modelRefreshResult) {
|
||||
result.modelRefreshResult = modelRefreshResult;
|
||||
result.refreshedModels = modelRefreshResult.refreshed === true;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import {
|
||||
convergeAccountMutation,
|
||||
rebuildRoutesBestEffort,
|
||||
} from './accountMutationWorkflow.js';
|
||||
|
||||
type AccountUpdateWorkflowInput = {
|
||||
accountId: number;
|
||||
updates: Partial<typeof schema.accounts.$inferInsert>;
|
||||
preferredApiToken?: string | null;
|
||||
refreshModels: boolean;
|
||||
preserveExpiredStatus?: boolean;
|
||||
allowInactiveModelRefresh?: boolean;
|
||||
reactivateAfterSuccessfulModelRefresh?: boolean;
|
||||
continueOnError?: boolean;
|
||||
};
|
||||
|
||||
export async function applyAccountUpdateWorkflow(input: AccountUpdateWorkflowInput) {
|
||||
const persistedUpdates: Partial<typeof schema.accounts.$inferInsert> = {
|
||||
...input.updates,
|
||||
...(input.preserveExpiredStatus ? { status: 'expired' } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await db.update(schema.accounts)
|
||||
.set(persistedUpdates)
|
||||
.where(eq(schema.accounts.id, input.accountId))
|
||||
.run();
|
||||
|
||||
const convergence = await convergeAccountMutation({
|
||||
accountId: input.accountId,
|
||||
preferredApiToken: input.preferredApiToken,
|
||||
defaultTokenSource: 'manual',
|
||||
refreshModels: input.refreshModels,
|
||||
allowInactiveModelRefresh: input.allowInactiveModelRefresh,
|
||||
rebuildRoutes: false,
|
||||
continueOnError: input.continueOnError,
|
||||
});
|
||||
|
||||
if (
|
||||
input.reactivateAfterSuccessfulModelRefresh
|
||||
&& convergence.modelRefreshResult?.status === 'success'
|
||||
) {
|
||||
await db.update(schema.accounts)
|
||||
.set({
|
||||
status: 'active',
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.accounts.id, input.accountId))
|
||||
.run();
|
||||
}
|
||||
|
||||
await rebuildRoutesBestEffort();
|
||||
|
||||
const account = await db.select()
|
||||
.from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, input.accountId))
|
||||
.get();
|
||||
|
||||
return {
|
||||
account,
|
||||
convergence,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user