diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 3cef888..42ec938 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -387,6 +387,83 @@ describe('refreshModelsForAccount credential discovery', () => { }); }); + it('preserves existing availability when allowInactive refresh fails', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('upstream unavailable')); + + const site = await db.insert(schema.sites).values({ + name: 'site-rebind-refresh', + url: 'https://site-rebind-refresh.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'rebind-user', + accessToken: 'session-token', + apiToken: null, + status: 'disabled', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'sk-stored-token', + source: 'manual', + enabled: true, + isDefault: true, + valueStatus: 'ready' as any, + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-4.1', + available: true, + latencyMs: 120, + checkedAt: '2026-03-21T11:30:00.000Z', + }).run(); + + await db.insert(schema.tokenModelAvailability).values({ + tokenId: token.id, + modelName: 'gpt-4.1', + available: true, + latencyMs: 90, + checkedAt: '2026-03-21T11:30:00.000Z', + }).run(); + + const result = await refreshModelsForAccount(account.id, { allowInactive: true }); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'failed', + modelCount: 0, + discoveredByCredential: false, + }); + + const modelRows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(modelRows).toHaveLength(1); + expect(modelRows[0]).toMatchObject({ + accountId: account.id, + modelName: 'gpt-4.1', + available: true, + }); + + const tokenRows = await db.select().from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token.id)) + .all(); + expect(tokenRows).toHaveLength(1); + expect(tokenRows[0]).toMatchObject({ + tokenId: token.id, + modelName: 'gpt-4.1', + available: true, + }); + }); + it('does not scan masked_pending placeholders as token credentials', async () => { getApiTokenMock.mockResolvedValue(null); getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => ( diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 3e66a83..0138953 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -499,21 +499,59 @@ export async function refreshModelsForAccount( const oauth = getOauthInfoFromExtraConfig(account.extraConfig); const adapter = getAdapter(site.platform); const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig); - - const accountTokens = await db.select() - .from(schema.accountTokens) - .where(eq(schema.accountTokens.accountId, accountId)) - .all(); - - await db.delete(schema.modelAvailability) - .where(eq(schema.modelAvailability.accountId, accountId)) - .run(); - - for (const token of accountTokens) { - await db.delete(schema.tokenModelAvailability) + const restoreAvailabilityOnFailure = options?.allowInactive === true; + const previousAccountTokens = restoreAvailabilityOnFailure + ? await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, accountId)) + .all() + : []; + const previousModelAvailability = restoreAvailabilityOnFailure + ? await db.select() + .from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, accountId)) + .all() + : []; + const previousTokenModelAvailability = restoreAvailabilityOnFailure + ? (await Promise.all(previousAccountTokens.map(async (token) => db.select() + .from(schema.tokenModelAvailability) .where(eq(schema.tokenModelAvailability.tokenId, token.id)) + .all()))).flat() + : []; + + const clearExistingAvailability = async () => { + await db.delete(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, accountId)) .run(); - } + + const currentAccountTokens = await db.select({ id: schema.accountTokens.id }) + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, accountId)) + .all(); + + for (const token of currentAccountTokens) { + await db.delete(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token.id)) + .run(); + } + }; + + const restorePreviousAvailability = async () => { + if (!restoreAvailabilityOnFailure) return; + await clearExistingAvailability(); + if (previousModelAvailability.length > 0) { + await db.insert(schema.modelAvailability).values( + previousModelAvailability.map(({ id: _id, ...row }) => row), + ).run(); + } + if (previousTokenModelAvailability.length > 0) { + await db.insert(schema.tokenModelAvailability).values( + previousTokenModelAvailability.map(({ id: _id, ...row }) => row), + ).run(); + } + }; + + await clearExistingAvailability(); if (isSiteDisabled(site.status)) { return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用'); @@ -583,6 +621,7 @@ export async function refreshModelsForAccount( source: 'model-discovery', checkedAt, }); + await restorePreviousAvailability(); return buildFailedRefreshResult({ accountId, errorCode, @@ -650,6 +689,7 @@ export async function refreshModelsForAccount( source: 'model-discovery', checkedAt, }); + await restorePreviousAvailability(); return buildFailedRefreshResult({ accountId, errorCode, @@ -717,6 +757,7 @@ export async function refreshModelsForAccount( source: 'model-discovery', checkedAt, }); + await restorePreviousAvailability(); return buildFailedRefreshResult({ accountId, errorCode, @@ -788,6 +829,7 @@ export async function refreshModelsForAccount( source: 'model-discovery', checkedAt, }); + await restorePreviousAvailability(); return buildFailedRefreshResult({ accountId, errorCode, @@ -960,6 +1002,7 @@ export async function refreshModelsForAccount( source: 'model-discovery', checkedAt: new Date().toISOString(), }); + await restorePreviousAvailability(); return buildFailedRefreshResult({ accountId, errorCode,