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',
|
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
|
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',
|
||||||
|
|||||||
Reference in New Issue
Block a user