1
0
forked from boos/BoosAPI

[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
@@ -0,0 +1,216 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
const getModelsMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
getModels: (...args: unknown[]) => getModelsMock(...args),
}),
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts api key recovery', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-apikey-recovery-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(async () => {
getModelsMock.mockReset();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.siteApiEndpoints).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('reactivates an expired API key connection after editing in a working replacement key', async () => {
getModelsMock.mockResolvedValueOnce(['gpt-4.1']);
const site = await db.insert(schema.sites).values({
name: 'Recovery Site',
url: 'https://recovery.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'expired-apikey-user',
accessToken: '',
apiToken: 'sk-old-expired-key',
status: 'expired',
checkinEnabled: false,
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
const response = await app.inject({
method: 'PUT',
url: `/api/accounts/${account.id}`,
payload: {
username: 'expired-apikey-user',
status: 'expired',
checkinEnabled: false,
accessToken: '',
apiToken: 'sk-new-valid-key',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
id: account.id,
status: 'active',
apiToken: 'sk-new-valid-key',
});
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
expect(latest).toMatchObject({
id: account.id,
status: 'active',
apiToken: 'sk-new-valid-key',
});
const availabilityRows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(availabilityRows.map((row) => row.modelName)).toContain('gpt-4.1');
const routeChannels = await db.select().from(schema.routeChannels).all();
expect(routeChannels.some((channel) => channel.accountId === account.id)).toBe(true);
});
it('keeps an expired API key connection pinned when only status is edited', async () => {
const site = await db.insert(schema.sites).values({
name: 'Pinned Site',
url: 'https://pinned.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'status-only-user',
accessToken: '',
apiToken: 'sk-still-expired',
status: 'expired',
checkinEnabled: false,
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
const response = await app.inject({
method: 'PUT',
url: `/api/accounts/${account.id}`,
payload: {
username: 'status-only-user-edited',
status: 'active',
checkinEnabled: false,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
id: account.id,
username: 'status-only-user-edited',
status: 'expired',
});
expect(getModelsMock).not.toHaveBeenCalled();
});
it('keeps the new api key but preserves expired status and previous availability when recovery fails', async () => {
getModelsMock.mockRejectedValueOnce(new Error('upstream unavailable'));
const site = await db.insert(schema.sites).values({
name: 'Recovery Failure Site',
url: 'https://recovery-failure.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'expired-apikey-user',
accessToken: '',
apiToken: 'sk-old-expired-key',
status: 'expired',
checkinEnabled: false,
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-4.1',
available: true,
latencyMs: 100,
checkedAt: '2026-04-01T10:00:00.000Z',
}).run();
const response = await app.inject({
method: 'PUT',
url: `/api/accounts/${account.id}`,
payload: {
username: 'expired-apikey-user',
status: 'expired',
checkinEnabled: false,
accessToken: '',
apiToken: 'sk-new-invalid-key',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
id: account.id,
status: 'expired',
apiToken: 'sk-new-invalid-key',
});
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
expect(latest).toMatchObject({
id: account.id,
status: 'expired',
apiToken: 'sk-new-invalid-key',
});
const availabilityRows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(availabilityRows).toHaveLength(1);
expect(availabilityRows[0]).toMatchObject({
accountId: account.id,
modelName: 'gpt-4.1',
});
const routeChannels = await db.select().from(schema.routeChannels).all();
expect(routeChannels.some((channel) => channel.accountId === account.id)).toBe(false);
});
});
+21 -8
View File
@@ -20,6 +20,7 @@ import {
type AccountCredentialMode, type AccountCredentialMode,
} from '../../services/accountExtraConfig.js'; } from '../../services/accountExtraConfig.js';
import { encryptAccountPassword } from '../../services/accountCredentialService.js'; import { encryptAccountPassword } from '../../services/accountCredentialService.js';
import { applyAccountUpdateWorkflow } from '../../services/accountUpdateWorkflow.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js'; import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js'; import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
@@ -1392,33 +1393,45 @@ export async function accountsRoutes(app: FastifyInstance) {
}); });
} }
updates.updatedAt = new Date().toISOString();
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
const nextAccessToken = typeof updates.accessToken === 'string' ? updates.accessToken : account.accessToken; const nextAccessToken = typeof updates.accessToken === 'string' ? updates.accessToken : account.accessToken;
const nextApiToken = Object.prototype.hasOwnProperty.call(updates, 'apiToken')
? updates.apiToken
: account.apiToken;
const nextExtraConfig = typeof updates.extraConfig === 'string' ? updates.extraConfig : account.extraConfig; const nextExtraConfig = typeof updates.extraConfig === 'string' ? updates.extraConfig : account.extraConfig;
const explicitNextMode = getCredentialModeFromExtraConfig(nextExtraConfig); const explicitNextMode = getCredentialModeFromExtraConfig(nextExtraConfig);
const nextCredentialMode = const nextCredentialMode =
explicitNextMode && explicitNextMode !== 'auto' explicitNextMode && explicitNextMode !== 'auto'
? explicitNextMode ? explicitNextMode
: (hasSessionTokenValue(nextAccessToken) ? 'session' : 'apikey'); : (hasSessionTokenValue(nextAccessToken) ? 'session' : 'apikey');
const nextStatus = typeof updates.status === 'string' && updates.status.trim()
? updates.status.trim()
: (account.status || 'active');
const needsModelRefresh = const needsModelRefresh =
Object.prototype.hasOwnProperty.call(body, 'accessToken') Object.prototype.hasOwnProperty.call(body, 'accessToken')
|| Object.prototype.hasOwnProperty.call(body, 'apiToken') || Object.prototype.hasOwnProperty.call(body, 'apiToken')
|| Object.prototype.hasOwnProperty.call(body, 'extraConfig') || Object.prototype.hasOwnProperty.call(body, 'extraConfig')
|| Object.prototype.hasOwnProperty.call(body, 'proxyUrl') || Object.prototype.hasOwnProperty.call(body, 'proxyUrl')
|| wantsManagedSub2ApiAuthPatch; || wantsManagedSub2ApiAuthPatch;
const isExpiredApiKeyAccount =
account.status === 'expired'
&& nextCredentialMode === 'apikey'
&& nextStatus !== 'disabled';
const shouldAttemptExpiredApiKeyRecovery =
isExpiredApiKeyAccount
&& needsModelRefresh;
await convergeAccountMutation({ const { account: updatedAccount } = await applyAccountUpdateWorkflow({
accountId: id, accountId: id,
preferredApiToken: nextCredentialMode !== 'apikey' ? updates.apiToken : null, updates,
defaultTokenSource: 'manual', preferredApiToken: nextCredentialMode !== 'apikey' ? nextApiToken : null,
refreshModels: needsModelRefresh, refreshModels: needsModelRefresh,
rebuildRoutes: true, preserveExpiredStatus: isExpiredApiKeyAccount,
allowInactiveModelRefresh: shouldAttemptExpiredApiKeyRecovery,
reactivateAfterSuccessfulModelRefresh: shouldAttemptExpiredApiKeyRecovery,
continueOnError: true, continueOnError: true,
}); });
return await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get(); return updatedAccount;
}); });
// Delete an account // Delete an account
@@ -24,17 +24,61 @@ describe('accountHealthService', () => {
const health = buildRuntimeHealthForAccount({ const health = buildRuntimeHealthForAccount({
accountStatus: 'expired', accountStatus: 'expired',
siteStatus: 'active', siteStatus: 'active',
extraConfig: null, extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
sessionCapable: false, sessionCapable: false,
}); });
expect(health).toMatchObject({ expect(health).toMatchObject({
state: 'unknown', state: 'unhealthy',
source: 'none', source: 'auth',
}); });
expect(health.reason).toContain('连接已过期');
expect(health.reason).not.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', () => { it('ignores stored auth failures for proxy-only accounts', () => {
const health = buildRuntimeHealthForAccount({ const health = buildRuntimeHealthForAccount({
accountStatus: 'active', accountStatus: 'active',
+15 -3
View File
@@ -1,6 +1,10 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js'; 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'; 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 { return {
state: 'unhealthy', state: 'unhealthy',
reason: '访问令牌已过期', reason: usesOauthCredential
? '连接凭证已过期,请更新凭证'
: credentialMode === 'apikey'
? '连接已过期,请更新 API Key'
: (credentialMode === 'session'
? '访问令牌已过期'
: '连接凭证已过期,请更新凭证'),
source: 'auth', source: 'auth',
checkedAt: null, checkedAt: null,
}; };
@@ -120,6 +120,19 @@ describe('accountMutationWorkflow', () => {
expect(result.refreshedModels).toBe(false); 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 () => { it('refreshes model coverage in batches and maps failures', async () => {
refreshModelsForAccountMock refreshModelsForAccountMock
.mockResolvedValueOnce({ accountId: 1, refreshed: true, status: 'success' }) .mockResolvedValueOnce({ accountId: 1, refreshed: true, status: 'success' })
@@ -32,6 +32,7 @@ export async function convergeAccountMutation(input: {
upstreamTokens?: UpstreamTokenLike[]; upstreamTokens?: UpstreamTokenLike[];
refreshBalance?: boolean; refreshBalance?: boolean;
refreshModels?: boolean; refreshModels?: boolean;
allowInactiveModelRefresh?: boolean;
rebuildRoutes?: boolean; rebuildRoutes?: boolean;
continueOnError?: boolean; continueOnError?: boolean;
}): Promise<{ }): Promise<{
@@ -111,7 +112,11 @@ export async function convergeAccountMutation(input: {
} }
if (input.refreshModels) { 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) { if (modelRefreshResult) {
result.modelRefreshResult = modelRefreshResult; result.modelRefreshResult = modelRefreshResult;
result.refreshedModels = modelRefreshResult.refreshed === true; 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,
};
}
+7
View File
@@ -597,6 +597,13 @@ export default function Accounts() {
}; };
const resolveRuntimeHealth = (account: any) => { const resolveRuntimeHealth = (account: any) => {
if (account.status === 'expired') {
return {
...runtimeHealthMap.unhealthy,
label: '已过期',
reason: account.runtimeHealth?.reason || '连接凭证已过期,请更新凭证',
};
}
const capabilities = resolveAccountCapabilities(account); const capabilities = resolveAccountCapabilities(account);
const fallbackState = account.status === 'disabled' || account.site?.status === 'disabled' const fallbackState = account.status === 'disabled' || account.site?.status === 'disabled'
? 'disabled' ? 'disabled'
@@ -51,6 +51,10 @@ describe('Accounts proxy-only expired state', () => {
accessToken: 'api-key-only-token', accessToken: 'api-key-only-token',
status: 'expired', status: 'expired',
checkinEnabled: false, checkinEnabled: false,
runtimeHealth: {
state: 'unhealthy',
reason: '连接已过期,请更新 API Key',
},
capabilities: { capabilities: {
canCheckin: false, canCheckin: false,
canRefreshBalance: false, canRefreshBalance: false,
@@ -77,8 +81,18 @@ describe('Accounts proxy-only expired state', () => {
const rendered = JSON.stringify(root.toJSON()); const rendered = JSON.stringify(root.toJSON());
expect(rendered).not.toContain('仅代理'); expect(rendered).not.toContain('仅代理');
expect(rendered).toContain('已过期');
expect(rendered).toContain('连接已过期,请更新 API Key');
expect(rendered).not.toContain('访问令牌已过期'); expect(rendered).not.toContain('访问令牌已过期');
const badgeTexts = root.root.findAll((node) => (
node.type === 'span'
&& typeof node.props.className === 'string'
&& node.props.className.includes('badge')
)).map((node) => collectText(node).trim());
expect(badgeTexts).toContain('已过期');
expect(badgeTexts).not.toContain('健康');
const actionTexts = root.root.findAll((node) => node.type === 'button').map((node) => collectText(node)); const actionTexts = root.root.findAll((node) => node.type === 'button').map((node) => collectText(node));
expect(actionTexts).not.toContain('重新绑定'); expect(actionTexts).not.toContain('重新绑定');
} finally { } finally {
@@ -105,7 +105,7 @@ describe('Accounts refresh action', () => {
expect(apiMock.getAccounts).toHaveBeenCalledTimes(2); expect(apiMock.getAccounts).toHaveBeenCalledTimes(2);
const rendered = JSON.stringify(root.toJSON()); const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('异常'); expect(rendered).toContain('已过期');
} finally { } finally {
root?.unmount(); root?.unmount();
} }