[codex] fix expired connection health recovery (#393)

* fix expired connection health recovery

* address review comments and test regressions
This commit is contained in:
Cita
2026-04-02 17:27:42 +08:00
committed by GitHub
parent 082891a217
commit 2be0603e6f
10 changed files with 405 additions and 16 deletions
@@ -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',
+15 -3
View File
@@ -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,
};
}