From 3e0083582e7d41d2c9caedaa657cbb8dc8699b2e Mon Sep 17 00:00:00 2001 From: Sean Lee <276371998@qq.com> Date: Tue, 24 Mar 2026 20:01:48 +0800 Subject: [PATCH] feat: Preserve manual models during discovery and refresh, and add Claude configuration files and project documentation.feat: Preserve manual models during discovery and refresh, and add Claude configuration and project documentation files. (#270) Co-authored-by: Xiang Li --- .../services/modelService.discovery.test.ts | 150 ++++++++++++++++++ src/server/services/modelService.ts | 127 +++++++++------ 2 files changed, 230 insertions(+), 47 deletions(-) diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 145d924..8bda03b 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -1145,4 +1145,154 @@ describe('refreshModelsForAccount credential discovery', () => { project: 'project-demo', }); }); + + it('preserves manual models after successful model refresh', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockResolvedValue(['gpt-4.1', 'claude-opus-4-6']); + + const site = await db.insert(schema.sites).values({ + name: 'site-manual', + url: 'https://site-manual.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'manual-user', + accessToken: 'session-token', + apiToken: null, + status: 'active', + }).returning().get(); + + // Add a manual model before refresh + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'my-custom-model', + available: true, + isManual: true, + }).run(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + + const modelNames = rows.map((r) => r.modelName).sort(); + expect(modelNames).toContain('my-custom-model'); + expect(modelNames).toContain('gpt-4.1'); + expect(modelNames).toContain('claude-opus-4-6'); + + const manualRow = rows.find((r) => r.modelName === 'my-custom-model'); + expect(manualRow?.isManual).toBe(true); + }); + + it('preserves manual models even when discovered models overlap', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockResolvedValue(['gpt-4.1', 'my-custom-model']); + + const site = await db.insert(schema.sites).values({ + name: 'site-overlap', + url: 'https://site-overlap.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'overlap-user', + accessToken: 'session-token', + apiToken: null, + status: 'active', + }).returning().get(); + + // Manual model that also exists upstream + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'my-custom-model', + available: true, + isManual: true, + }).run(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + + // Should have gpt-4.1 (discovered) and my-custom-model (manual, kept as-is) + const modelNames = rows.map((r) => r.modelName).sort(); + expect(modelNames).toEqual(['gpt-4.1', 'my-custom-model']); + + // The manual model should still have isManual=true (not overwritten by discovery) + const manualRow = rows.find((r) => r.modelName === 'my-custom-model'); + expect(manualRow?.isManual).toBe(true); + }); + + it('preserves manual models when refresh fails and restores previous availability', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockResolvedValue([]); + + const site = await db.insert(schema.sites).values({ + name: 'site-fail', + url: 'https://site-fail.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'fail-user', + accessToken: 'session-token', + apiToken: null, + status: 'active', + }).returning().get(); + + // Existing synced model + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-4.1', + available: true, + isManual: false, + }).run(); + + // Manual model + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'my-custom-model', + available: true, + isManual: true, + }).run(); + + const result = await refreshModelsForAccount(account.id, { allowInactive: true }); + + expect(result).toMatchObject({ + status: 'failed', + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + + // Both manual model and restored synced model should exist + const modelNames = rows.map((r) => r.modelName).sort(); + expect(modelNames).toContain('my-custom-model'); + expect(modelNames).toContain('gpt-4.1'); + + const manualRow = rows.find((r) => r.modelName === 'my-custom-model'); + expect(manualRow?.isManual).toBe(true); + }); }); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index e8c0ac9..c69d1ef 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -302,7 +302,10 @@ export async function refreshModelsForAccount( const previousModelAvailability = restoreAvailabilityOnFailure ? await db.select() .from(schema.modelAvailability) - .where(eq(schema.modelAvailability.accountId, accountId)) + .where(and( + eq(schema.modelAvailability.accountId, accountId), + eq(schema.modelAvailability.isManual, false), + )) .all() : []; const previousTokenModelAvailability = restoreAvailabilityOnFailure @@ -314,7 +317,10 @@ export async function refreshModelsForAccount( const clearExistingAvailability = async () => { await db.delete(schema.modelAvailability) - .where(eq(schema.modelAvailability.accountId, accountId)) + .where(and( + eq(schema.modelAvailability.accountId, accountId), + eq(schema.modelAvailability.isManual, false), + )) .run(); const currentAccountTokens = await db.select({ id: schema.accountTokens.id }) @@ -346,6 +352,18 @@ export async function refreshModelsForAccount( await clearExistingAvailability(); + // Collect manual model names so discovered models that collide are skipped (unique index). + const manualModelNames = new Set( + (await db.select({ modelName: schema.modelAvailability.modelName }) + .from(schema.modelAvailability) + .where(and( + eq(schema.modelAvailability.accountId, accountId), + eq(schema.modelAvailability.isManual, true), + )) + .all() + ).map((r) => r.modelName), + ); + if (isSiteDisabled(site.status)) { return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用'); } @@ -368,15 +386,18 @@ export async function refreshModelsForAccount( throw new Error('未获取到可用模型'); } - await db.insert(schema.modelAvailability).values( - codexModels.map((modelName) => ({ - accountId, - modelName, - available: true, - latencyMs: Date.now() - startedAt, - checkedAt, - })), - ).run(); + const newCodexModels = codexModels.filter((m) => !manualModelNames.has(m)); + if (newCodexModels.length > 0) { + await db.insert(schema.modelAvailability).values( + newCodexModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + } await updateOauthModelDiscoveryState({ account, checkedAt, @@ -439,15 +460,18 @@ export async function refreshModelsForAccount( if (claudeModels.length === 0) { throw new Error('未获取到可用模型'); } - await db.insert(schema.modelAvailability).values( - claudeModels.map((modelName) => ({ - accountId, - modelName, - available: true, - latencyMs: Date.now() - startedAt, - checkedAt, - })), - ).run(); + const newClaudeModels = claudeModels.filter((m) => !manualModelNames.has(m)); + if (newClaudeModels.length > 0) { + await db.insert(schema.modelAvailability).values( + newClaudeModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + } await updateOauthModelDiscoveryState({ account, checkedAt, @@ -529,15 +553,18 @@ export async function refreshModelsForAccount( `gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, ); } - await db.insert(schema.modelAvailability).values( - GEMINI_CLI_STATIC_MODELS.map((modelName) => ({ - accountId, - modelName, - available: true, - latencyMs: Date.now() - startedAt, - checkedAt, - })), - ).run(); + const newGeminiModels = GEMINI_CLI_STATIC_MODELS.filter((m) => !manualModelNames.has(m)); + if (newGeminiModels.length > 0) { + await db.insert(schema.modelAvailability).values( + newGeminiModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + } await updateOauthModelDiscoveryState({ account: discoveryAccount, checkedAt, @@ -601,15 +628,18 @@ export async function refreshModelsForAccount( throw new Error('未获取到可用模型'); } - await db.insert(schema.modelAvailability).values( - antigravityModels.map((modelName) => ({ - accountId, - modelName, - available: true, - latencyMs: Date.now() - startedAt, - checkedAt, - })), - ).run(); + const newAntigravityModels = antigravityModels.filter((m) => !manualModelNames.has(m)); + if (newAntigravityModels.length > 0) { + await db.insert(schema.modelAvailability).values( + newAntigravityModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + } await updateOauthModelDiscoveryState({ account, checkedAt, @@ -832,15 +862,18 @@ export async function refreshModelsForAccount( } const checkedAt = new Date().toISOString(); - await db.insert(schema.modelAvailability).values( - Array.from(accountModels).map((modelName) => ({ - accountId: account.id, - modelName, - available: true, - latencyMs: modelLatency.get(modelName) ?? null, - checkedAt, - })), - ).run(); + const newAccountModels = Array.from(accountModels).filter((m) => !manualModelNames.has(m)); + if (newAccountModels.length > 0) { + await db.insert(schema.modelAvailability).values( + newAccountModels.map((modelName) => ({ + accountId: account.id, + modelName, + available: true, + latencyMs: modelLatency.get(modelName) ?? null, + checkedAt, + })), + ).run(); + } await setAccountRuntimeHealth(account.id, { state: 'healthy',