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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,8 +386,10 @@ export async function refreshModelsForAccount(
|
||||
throw new Error('未获取到可用模型');
|
||||
}
|
||||
|
||||
const newCodexModels = codexModels.filter((m) => !manualModelNames.has(m));
|
||||
if (newCodexModels.length > 0) {
|
||||
await db.insert(schema.modelAvailability).values(
|
||||
codexModels.map((modelName) => ({
|
||||
newCodexModels.map((modelName) => ({
|
||||
accountId,
|
||||
modelName,
|
||||
available: true,
|
||||
@@ -377,6 +397,7 @@ export async function refreshModelsForAccount(
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
await updateOauthModelDiscoveryState({
|
||||
account,
|
||||
checkedAt,
|
||||
@@ -439,8 +460,10 @@ export async function refreshModelsForAccount(
|
||||
if (claudeModels.length === 0) {
|
||||
throw new Error('未获取到可用模型');
|
||||
}
|
||||
const newClaudeModels = claudeModels.filter((m) => !manualModelNames.has(m));
|
||||
if (newClaudeModels.length > 0) {
|
||||
await db.insert(schema.modelAvailability).values(
|
||||
claudeModels.map((modelName) => ({
|
||||
newClaudeModels.map((modelName) => ({
|
||||
accountId,
|
||||
modelName,
|
||||
available: true,
|
||||
@@ -448,6 +471,7 @@ export async function refreshModelsForAccount(
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
await updateOauthModelDiscoveryState({
|
||||
account,
|
||||
checkedAt,
|
||||
@@ -529,8 +553,10 @@ export async function refreshModelsForAccount(
|
||||
`gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
);
|
||||
}
|
||||
const newGeminiModels = GEMINI_CLI_STATIC_MODELS.filter((m) => !manualModelNames.has(m));
|
||||
if (newGeminiModels.length > 0) {
|
||||
await db.insert(schema.modelAvailability).values(
|
||||
GEMINI_CLI_STATIC_MODELS.map((modelName) => ({
|
||||
newGeminiModels.map((modelName) => ({
|
||||
accountId,
|
||||
modelName,
|
||||
available: true,
|
||||
@@ -538,6 +564,7 @@ export async function refreshModelsForAccount(
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
await updateOauthModelDiscoveryState({
|
||||
account: discoveryAccount,
|
||||
checkedAt,
|
||||
@@ -601,8 +628,10 @@ export async function refreshModelsForAccount(
|
||||
throw new Error('未获取到可用模型');
|
||||
}
|
||||
|
||||
const newAntigravityModels = antigravityModels.filter((m) => !manualModelNames.has(m));
|
||||
if (newAntigravityModels.length > 0) {
|
||||
await db.insert(schema.modelAvailability).values(
|
||||
antigravityModels.map((modelName) => ({
|
||||
newAntigravityModels.map((modelName) => ({
|
||||
accountId,
|
||||
modelName,
|
||||
available: true,
|
||||
@@ -610,6 +639,7 @@ export async function refreshModelsForAccount(
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
await updateOauthModelDiscoveryState({
|
||||
account,
|
||||
checkedAt,
|
||||
@@ -832,8 +862,10 @@ export async function refreshModelsForAccount(
|
||||
}
|
||||
|
||||
const checkedAt = new Date().toISOString();
|
||||
const newAccountModels = Array.from(accountModels).filter((m) => !manualModelNames.has(m));
|
||||
if (newAccountModels.length > 0) {
|
||||
await db.insert(schema.modelAvailability).values(
|
||||
Array.from(accountModels).map((modelName) => ({
|
||||
newAccountModels.map((modelName) => ({
|
||||
accountId: account.id,
|
||||
modelName,
|
||||
available: true,
|
||||
@@ -841,6 +873,7 @@ export async function refreshModelsForAccount(
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
|
||||
await setAccountRuntimeHealth(account.id, {
|
||||
state: 'healthy',
|
||||
|
||||
Reference in New Issue
Block a user