1
0
forked from boos/BoosAPI

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 <xiangli>
This commit is contained in:
Sean Lee
2026-03-24 20:01:48 +08:00
committed by GitHub
parent 9cf3dd1904
commit 3e0083582e
2 changed files with 230 additions and 47 deletions
@@ -1145,4 +1145,154 @@ describe('refreshModelsForAccount credential discovery', () => {
project: 'project-demo', 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);
});
}); });
+80 -47
View File
@@ -302,7 +302,10 @@ export async function refreshModelsForAccount(
const previousModelAvailability = restoreAvailabilityOnFailure const previousModelAvailability = restoreAvailabilityOnFailure
? await db.select() ? await db.select()
.from(schema.modelAvailability) .from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, accountId)) .where(and(
eq(schema.modelAvailability.accountId, accountId),
eq(schema.modelAvailability.isManual, false),
))
.all() .all()
: []; : [];
const previousTokenModelAvailability = restoreAvailabilityOnFailure const previousTokenModelAvailability = restoreAvailabilityOnFailure
@@ -314,7 +317,10 @@ export async function refreshModelsForAccount(
const clearExistingAvailability = async () => { const clearExistingAvailability = async () => {
await db.delete(schema.modelAvailability) await db.delete(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, accountId)) .where(and(
eq(schema.modelAvailability.accountId, accountId),
eq(schema.modelAvailability.isManual, false),
))
.run(); .run();
const currentAccountTokens = await db.select({ id: schema.accountTokens.id }) const currentAccountTokens = await db.select({ id: schema.accountTokens.id })
@@ -346,6 +352,18 @@ export async function refreshModelsForAccount(
await clearExistingAvailability(); 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)) { if (isSiteDisabled(site.status)) {
return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用'); return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用');
} }
@@ -368,15 +386,18 @@ export async function refreshModelsForAccount(
throw new Error('未获取到可用模型'); throw new Error('未获取到可用模型');
} }
await db.insert(schema.modelAvailability).values( const newCodexModels = codexModels.filter((m) => !manualModelNames.has(m));
codexModels.map((modelName) => ({ if (newCodexModels.length > 0) {
accountId, await db.insert(schema.modelAvailability).values(
modelName, newCodexModels.map((modelName) => ({
available: true, accountId,
latencyMs: Date.now() - startedAt, modelName,
checkedAt, available: true,
})), latencyMs: Date.now() - startedAt,
).run(); checkedAt,
})),
).run();
}
await updateOauthModelDiscoveryState({ await updateOauthModelDiscoveryState({
account, account,
checkedAt, checkedAt,
@@ -439,15 +460,18 @@ export async function refreshModelsForAccount(
if (claudeModels.length === 0) { if (claudeModels.length === 0) {
throw new Error('未获取到可用模型'); throw new Error('未获取到可用模型');
} }
await db.insert(schema.modelAvailability).values( const newClaudeModels = claudeModels.filter((m) => !manualModelNames.has(m));
claudeModels.map((modelName) => ({ if (newClaudeModels.length > 0) {
accountId, await db.insert(schema.modelAvailability).values(
modelName, newClaudeModels.map((modelName) => ({
available: true, accountId,
latencyMs: Date.now() - startedAt, modelName,
checkedAt, available: true,
})), latencyMs: Date.now() - startedAt,
).run(); checkedAt,
})),
).run();
}
await updateOauthModelDiscoveryState({ await updateOauthModelDiscoveryState({
account, account,
checkedAt, checkedAt,
@@ -529,15 +553,18 @@ export async function refreshModelsForAccount(
`gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, `gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
); );
} }
await db.insert(schema.modelAvailability).values( const newGeminiModels = GEMINI_CLI_STATIC_MODELS.filter((m) => !manualModelNames.has(m));
GEMINI_CLI_STATIC_MODELS.map((modelName) => ({ if (newGeminiModels.length > 0) {
accountId, await db.insert(schema.modelAvailability).values(
modelName, newGeminiModels.map((modelName) => ({
available: true, accountId,
latencyMs: Date.now() - startedAt, modelName,
checkedAt, available: true,
})), latencyMs: Date.now() - startedAt,
).run(); checkedAt,
})),
).run();
}
await updateOauthModelDiscoveryState({ await updateOauthModelDiscoveryState({
account: discoveryAccount, account: discoveryAccount,
checkedAt, checkedAt,
@@ -601,15 +628,18 @@ export async function refreshModelsForAccount(
throw new Error('未获取到可用模型'); throw new Error('未获取到可用模型');
} }
await db.insert(schema.modelAvailability).values( const newAntigravityModels = antigravityModels.filter((m) => !manualModelNames.has(m));
antigravityModels.map((modelName) => ({ if (newAntigravityModels.length > 0) {
accountId, await db.insert(schema.modelAvailability).values(
modelName, newAntigravityModels.map((modelName) => ({
available: true, accountId,
latencyMs: Date.now() - startedAt, modelName,
checkedAt, available: true,
})), latencyMs: Date.now() - startedAt,
).run(); checkedAt,
})),
).run();
}
await updateOauthModelDiscoveryState({ await updateOauthModelDiscoveryState({
account, account,
checkedAt, checkedAt,
@@ -832,15 +862,18 @@ export async function refreshModelsForAccount(
} }
const checkedAt = new Date().toISOString(); const checkedAt = new Date().toISOString();
await db.insert(schema.modelAvailability).values( const newAccountModels = Array.from(accountModels).filter((m) => !manualModelNames.has(m));
Array.from(accountModels).map((modelName) => ({ if (newAccountModels.length > 0) {
accountId: account.id, await db.insert(schema.modelAvailability).values(
modelName, newAccountModels.map((modelName) => ({
available: true, accountId: account.id,
latencyMs: modelLatency.get(modelName) ?? null, modelName,
checkedAt, available: true,
})), latencyMs: modelLatency.get(modelName) ?? null,
).run(); checkedAt,
})),
).run();
}
await setAccountRuntimeHealth(account.id, { await setAccountRuntimeHealth(account.id, {
state: 'healthy', state: 'healthy',