forked from boos/BoosAPI
refactor: centralize oauth identity and route refresh workflow
This commit is contained in:
+4
-2
@@ -22,13 +22,14 @@ import { oauthRoutes } from './routes/api/oauth.js';
|
|||||||
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
||||||
import { proxyRoutes } from './routes/proxy/router.js';
|
import { proxyRoutes } from './routes/proxy/router.js';
|
||||||
import { startScheduler } from './services/checkinScheduler.js';
|
import { startScheduler } from './services/checkinScheduler.js';
|
||||||
import { rebuildTokenRoutesFromAvailability } from './services/modelService.js';
|
import * as routeRefreshWorkflow from './services/routeRefreshWorkflow.js';
|
||||||
import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js';
|
import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js';
|
||||||
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||||
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
||||||
|
import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js';
|
||||||
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
|
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
|
||||||
import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
|
import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
|
||||||
import { reloadBackupWebdavScheduler } from './services/backupService.js';
|
import { reloadBackupWebdavScheduler } from './services/backupService.js';
|
||||||
@@ -332,7 +333,8 @@ try {
|
|||||||
await repairStoredCreatedAtValues();
|
await repairStoredCreatedAtValues();
|
||||||
await migrateSiteApiKeysToAccounts();
|
await migrateSiteApiKeysToAccounts();
|
||||||
await ensureDefaultSitesSeeded();
|
await ensureDefaultSitesSeeded();
|
||||||
await rebuildTokenRoutesFromAvailability();
|
await ensureOauthIdentityBackfill();
|
||||||
|
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
|
|
||||||
console.log('Loaded runtime settings overrides');
|
console.log('Loaded runtime settings overrides');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
|||||||
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
||||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js';
|
import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js';
|
||||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||||
@@ -278,7 +278,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
if (!isDirectGeminiFamilyPlatform(selected.site.platform)) {
|
if (!isDirectGeminiFamilyPlatform(selected.site.platform)) {
|
||||||
let models = await readRouteAwareGeminiModels(request);
|
let models = await readRouteAwareGeminiModels(request);
|
||||||
if (models.length <= 0) {
|
if (models.length <= 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
models = await readRouteAwareGeminiModels(request);
|
models = await readRouteAwareGeminiModels(request);
|
||||||
}
|
}
|
||||||
return reply.code(200).send({ models });
|
return reply.code(200).send({ models });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import type { SiteProxyConfigLike } from '../../services/siteProxy.js';
|
import type { SiteProxyConfigLike } from '../../services/siteProxy.js';
|
||||||
@@ -86,7 +86,7 @@ export async function selectSurfaceChannelForAttempt(input: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!selected && input.retryCount === 0) {
|
if (!selected && input.retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy);
|
selected = await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1537,6 +1537,69 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('backfills structured oauth identity columns for legacy oauth rows before listing connections', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'Legacy Codex',
|
||||||
|
url: 'https://codex.example.com',
|
||||||
|
platform: 'codex',
|
||||||
|
status: 'active',
|
||||||
|
useSystemProxy: false,
|
||||||
|
isPinned: false,
|
||||||
|
globalWeight: 1,
|
||||||
|
sortOrder: 0,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'legacy-user@example.com',
|
||||||
|
accessToken: 'legacy-oauth-access-token',
|
||||||
|
status: 'active',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
accountKey: 'legacy-chatgpt-account',
|
||||||
|
projectId: 'legacy-project',
|
||||||
|
refreshToken: 'legacy-refresh-token',
|
||||||
|
modelDiscoveryStatus: 'healthy',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
isPinned: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
expect(account.oauthProvider).toBeNull();
|
||||||
|
expect(account.oauthAccountKey).toBeNull();
|
||||||
|
expect(account.oauthProjectId).toBeNull();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/oauth/connections',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
total: 1,
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: 'codex',
|
||||||
|
accountKey: 'legacy-chatgpt-account',
|
||||||
|
projectId: 'legacy-project',
|
||||||
|
username: 'legacy-user@example.com',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const backfilled = await db.select().from(schema.accounts)
|
||||||
|
.where(eq(schema.accounts.id, account.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
expect(backfilled).toEqual(expect.objectContaining({
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'legacy-chatgpt-account',
|
||||||
|
oauthProjectId: 'legacy-project',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects malformed manual callback submissions', async () => {
|
it('rejects malformed manual callback submissions', async () => {
|
||||||
const startResponse = await app.inject({
|
const startResponse = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNoDirectModelServiceRouteRefresh(source: string): void {
|
||||||
|
expect(source).not.toMatch(/import\s*\{[^}]*\brefreshModelsAndRebuildRoutes\b[^}]*\}\s*from\s*['"][^'"]*modelService\.js['"]/m);
|
||||||
|
expect(source).not.toMatch(/import\s*\{[^}]*\brebuildTokenRoutesFromAvailability\b[^}]*\}\s*from\s*['"][^'"]*modelService\.js['"]/m);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('route refresh workflow architecture boundaries', () => {
|
||||||
|
it('keeps api controllers on the shared route refresh workflow instead of modelService', () => {
|
||||||
|
const tokensSource = readSource('./tokens.ts');
|
||||||
|
const settingsSource = readSource('./settings.ts');
|
||||||
|
const statsSource = readSource('./stats.ts');
|
||||||
|
|
||||||
|
expect(tokensSource).toContain("from '../../services/routeRefreshWorkflow.js'");
|
||||||
|
expect(tokensSource).not.toContain("from '../../services/modelService.js'");
|
||||||
|
|
||||||
|
expect(settingsSource).toContain("from '../../services/routeRefreshWorkflow.js'");
|
||||||
|
expect(settingsSource).not.toContain("from '../../services/modelService.js'");
|
||||||
|
|
||||||
|
expect(statsSource).toContain("from '../../services/routeRefreshWorkflow.js'");
|
||||||
|
expectNoDirectModelServiceRouteRefresh(statsSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps proxy fallback refreshes and scheduler hooks on the route refresh workflow', () => {
|
||||||
|
const completionsSource = readSource('../proxy/completions.ts');
|
||||||
|
const embeddingsSource = readSource('../proxy/embeddings.ts');
|
||||||
|
const imagesSource = readSource('../proxy/images.ts');
|
||||||
|
const modelsRouteSource = readSource('../proxy/models.ts');
|
||||||
|
const searchSource = readSource('../proxy/search.ts');
|
||||||
|
const videosSource = readSource('../proxy/videos.ts');
|
||||||
|
const schedulerSource = readSource('../../services/checkinScheduler.ts');
|
||||||
|
const oauthServiceSource = readSource('../../services/oauth/service.ts');
|
||||||
|
const sharedSurfaceSource = readSource('../../proxy-core/surfaces/sharedSurface.ts');
|
||||||
|
const geminiSurfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts');
|
||||||
|
|
||||||
|
for (const source of [
|
||||||
|
completionsSource,
|
||||||
|
embeddingsSource,
|
||||||
|
imagesSource,
|
||||||
|
modelsRouteSource,
|
||||||
|
searchSource,
|
||||||
|
videosSource,
|
||||||
|
schedulerSource,
|
||||||
|
oauthServiceSource,
|
||||||
|
sharedSurfaceSource,
|
||||||
|
geminiSurfaceSource,
|
||||||
|
]) {
|
||||||
|
expect(source).toContain('routeRefreshWorkflow');
|
||||||
|
expectNoDirectModelServiceRouteRefresh(source);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { fetch } from 'undici';
|
|||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
||||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
|
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
|
||||||
import { sendNotification } from '../../services/notifyService.js';
|
import { sendNotification } from '../../services/notifyService.js';
|
||||||
import {
|
import {
|
||||||
@@ -1404,7 +1404,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
failureMessage: (currentTask) => `缓存清理后重建失败:${currentTask.error || 'unknown error'}`,
|
failureMessage: (currentTask) => `缓存清理后重建失败:${currentTask.error || 'unknown error'}`,
|
||||||
},
|
},
|
||||||
async () => refreshModelsAndRebuildRoutes(),
|
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return reply.code(202).send({
|
return reply.code(202).send({
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
|
import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
|
||||||
import {
|
import { refreshModelsForAccount } from '../../services/modelService.js';
|
||||||
refreshModelsForAccount,
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
refreshModelsAndRebuildRoutes,
|
|
||||||
rebuildTokenRoutesFromAvailability,
|
|
||||||
} from '../../services/modelService.js';
|
|
||||||
import { buildModelAnalysis } from '../../services/modelAnalysisService.js';
|
import { buildModelAnalysis } from '../../services/modelAnalysisService.js';
|
||||||
import { fallbackTokenCost, fetchModelPricingCatalog } from '../../services/modelPricingService.js';
|
import { fallbackTokenCost, fetchModelPricingCatalog } from '../../services/modelPricingService.js';
|
||||||
import { getUpstreamModelDescriptionsCached } from '../../services/upstreamModelDescriptionService.js';
|
import { getUpstreamModelDescriptionsCached } from '../../services/upstreamModelDescriptionService.js';
|
||||||
@@ -847,7 +844,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
failureMessage: (currentTask) => `模型广场刷新失败:${currentTask.error || 'unknown error'}`,
|
failureMessage: (currentTask) => `模型广场刷新失败:${currentTask.error || 'unknown error'}`,
|
||||||
},
|
},
|
||||||
async () => refreshModelsAndRebuildRoutes(),
|
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||||
);
|
);
|
||||||
refreshQueued = !reused;
|
refreshQueued = !reused;
|
||||||
refreshReused = reused;
|
refreshReused = reused;
|
||||||
@@ -1390,7 +1387,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refresh = await refreshModelsForAccount(accountId);
|
const refresh = await refreshModelsForAccount(accountId);
|
||||||
const rebuild = rebuildTokenRoutesFromAvailability();
|
const rebuild = routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
return { success: true, refresh, rebuild };
|
return { success: true, refresh, rebuild };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import {
|
import {
|
||||||
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
||||||
isUsableAccountToken,
|
isUsableAccountToken,
|
||||||
@@ -1170,12 +1170,12 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
|
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
|
||||||
const body = (request.body || {}) as { refreshModels?: boolean };
|
const body = (request.body || {}) as { refreshModels?: boolean };
|
||||||
if (body.refreshModels === false) {
|
if (body.refreshModels === false) {
|
||||||
const rebuild = rebuildTokenRoutesFromAvailability();
|
const rebuild = routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
return { success: true, rebuild };
|
return { success: true, rebuild };
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((request.body as { wait?: boolean } | undefined)?.wait) {
|
if ((request.body as { wait?: boolean } | undefined)?.wait) {
|
||||||
const result = await refreshModelsAndRebuildRoutes();
|
const result = await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
return { success: true, ...result };
|
return { success: true, ...result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1192,8 +1192,8 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
failureMessage: (currentTask) => `刷新模型并重建路由失败:${currentTask.error || 'unknown error'}`,
|
failureMessage: (currentTask) => `刷新模型并重建路由失败:${currentTask.error || 'unknown error'}`,
|
||||||
},
|
},
|
||||||
async () => refreshModelsAndRebuildRoutes(),
|
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return reply.code(202).send({
|
return reply.code(202).send({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
@@ -48,7 +48,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
@@ -46,7 +46,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||||
@@ -43,7 +43,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { listModelsSurface } from '../../proxy-core/surfaces/modelsSurface.js';
|
import { listModelsSurface } from '../../proxy-core/surfaces/modelsSurface.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||||
@@ -14,7 +14,7 @@ export async function modelsProxyRoute(app: FastifyInstance) {
|
|||||||
downstreamPolicy,
|
downstreamPolicy,
|
||||||
responseFormat: wantsClaudeFormat ? 'claude' : 'openai',
|
responseFormat: wantsClaudeFormat ? 'claude' : 'openai',
|
||||||
tokenRouter,
|
tokenRouter,
|
||||||
refreshModelsAndRebuildRoutes,
|
refreshModelsAndRebuildRoutes: routeRefreshWorkflow.refreshModelsAndRebuildRoutes,
|
||||||
isModelAllowed: isModelAllowedByPolicyOrAllowedRoutes,
|
isModelAllowed: isModelAllowedByPolicyOrAllowedRoutes,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
@@ -71,7 +71,7 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||||
@@ -57,7 +57,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import {
|
|||||||
syncTokensFromUpstream,
|
syncTokensFromUpstream,
|
||||||
} from './accountTokenService.js';
|
} from './accountTokenService.js';
|
||||||
import {
|
import {
|
||||||
rebuildTokenRoutesFromAvailability,
|
|
||||||
refreshModelsForAccount,
|
refreshModelsForAccount,
|
||||||
type ModelRefreshResult,
|
type ModelRefreshResult,
|
||||||
} from './modelService.js';
|
} from './modelService.js';
|
||||||
|
import * as routeRefreshWorkflow from './routeRefreshWorkflow.js';
|
||||||
|
|
||||||
type UpstreamTokenLike = {
|
type UpstreamTokenLike = {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
@@ -17,16 +17,11 @@ type UpstreamTokenLike = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CoverageBatchRebuildResult =
|
export type CoverageBatchRebuildResult =
|
||||||
| { success: true; result: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
|
| { success: true; result: Awaited<ReturnType<typeof routeRefreshWorkflow.rebuildRoutesOnly>> }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
export async function rebuildRoutesBestEffort(): Promise<boolean> {
|
export async function rebuildRoutesBestEffort(): Promise<boolean> {
|
||||||
try {
|
return routeRefreshWorkflow.rebuildRoutesBestEffort();
|
||||||
await rebuildTokenRoutesFromAvailability();
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function convergeAccountMutation(input: {
|
export async function convergeAccountMutation(input: {
|
||||||
@@ -47,7 +42,7 @@ export async function convergeAccountMutation(input: {
|
|||||||
rebuiltRoutes: boolean;
|
rebuiltRoutes: boolean;
|
||||||
balanceResult: Awaited<ReturnType<typeof refreshBalance>> | null;
|
balanceResult: Awaited<ReturnType<typeof refreshBalance>> | null;
|
||||||
modelRefreshResult: ModelRefreshResult | null;
|
modelRefreshResult: ModelRefreshResult | null;
|
||||||
rebuildResult: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> | null;
|
rebuildResult: Awaited<ReturnType<typeof routeRefreshWorkflow.rebuildRoutesOnly>> | null;
|
||||||
}> {
|
}> {
|
||||||
const result = {
|
const result = {
|
||||||
defaultTokenId: null as number | null,
|
defaultTokenId: null as number | null,
|
||||||
@@ -57,7 +52,7 @@ export async function convergeAccountMutation(input: {
|
|||||||
rebuiltRoutes: false,
|
rebuiltRoutes: false,
|
||||||
balanceResult: null as Awaited<ReturnType<typeof refreshBalance>> | null,
|
balanceResult: null as Awaited<ReturnType<typeof refreshBalance>> | null,
|
||||||
modelRefreshResult: null as ModelRefreshResult | null,
|
modelRefreshResult: null as ModelRefreshResult | null,
|
||||||
rebuildResult: null as Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> | null,
|
rebuildResult: null as Awaited<ReturnType<typeof routeRefreshWorkflow.rebuildRoutesOnly>> | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const runStep = async <T>(fn: () => Promise<T>): Promise<T | null> => {
|
const runStep = async <T>(fn: () => Promise<T>): Promise<T | null> => {
|
||||||
@@ -124,7 +119,7 @@ export async function convergeAccountMutation(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.rebuildRoutes) {
|
if (input.rebuildRoutes) {
|
||||||
const rebuildResult = await runStep(() => rebuildTokenRoutesFromAvailability());
|
const rebuildResult = await runStep(() => routeRefreshWorkflow.rebuildRoutesOnly());
|
||||||
if (rebuildResult) {
|
if (rebuildResult) {
|
||||||
result.rebuildResult = rebuildResult;
|
result.rebuildResult = rebuildResult;
|
||||||
result.rebuiltRoutes = true;
|
result.rebuiltRoutes = true;
|
||||||
@@ -180,7 +175,7 @@ export async function refreshAccountCoverageBatch<TFailure>(input: {
|
|||||||
refresh,
|
refresh,
|
||||||
rebuild: {
|
rebuild: {
|
||||||
success: true,
|
success: true,
|
||||||
result: await rebuildTokenRoutesFromAvailability(),
|
result: await routeRefreshWorkflow.rebuildRoutesOnly(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const TASK_CLEANUP_INTERVAL_MS = 60 * 1000;
|
|||||||
|
|
||||||
const tasks = new Map<string, BackgroundTask>();
|
const tasks = new Map<string, BackgroundTask>();
|
||||||
const dedupeTaskIds = new Map<string, string>();
|
const dedupeTaskIds = new Map<string, string>();
|
||||||
|
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -157,13 +158,17 @@ function cleanupExpiredTasks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanupTimer = setInterval(cleanupExpiredTasks, TASK_CLEANUP_INTERVAL_MS);
|
function ensureCleanupTimer() {
|
||||||
cleanupTimer.unref?.();
|
if (cleanupTimer) return;
|
||||||
|
cleanupTimer = setInterval(cleanupExpiredTasks, TASK_CLEANUP_INTERVAL_MS);
|
||||||
|
cleanupTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
export function startBackgroundTask(
|
export function startBackgroundTask(
|
||||||
options: BackgroundTaskStartOptions,
|
options: BackgroundTaskStartOptions,
|
||||||
runner: () => Promise<unknown>,
|
runner: () => Promise<unknown>,
|
||||||
): { task: BackgroundTask; reused: boolean } {
|
): { task: BackgroundTask; reused: boolean } {
|
||||||
|
ensureCleanupTimer();
|
||||||
const dedupeKey = options.dedupeKey?.trim() || '';
|
const dedupeKey = options.dedupeKey?.trim() || '';
|
||||||
if (dedupeKey) {
|
if (dedupeKey) {
|
||||||
const existingTaskId = dedupeTaskIds.get(dedupeKey);
|
const existingTaskId = dedupeTaskIds.get(dedupeKey);
|
||||||
@@ -241,4 +246,8 @@ export function summarizeCheckinResults(results: Array<{ result?: any }>): { tot
|
|||||||
export function __resetBackgroundTasksForTests() {
|
export function __resetBackgroundTasksForTests() {
|
||||||
tasks.clear();
|
tasks.clear();
|
||||||
dedupeTaskIds.clear();
|
dedupeTaskIds.clear();
|
||||||
|
if (cleanupTimer) {
|
||||||
|
clearInterval(cleanupTimer);
|
||||||
|
cleanupTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { config } from '../config.js';
|
|||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { refreshAllBalances } from './balanceService.js';
|
import { refreshAllBalances } from './balanceService.js';
|
||||||
import { checkinAll } from './checkinService.js';
|
import { checkinAll } from './checkinService.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from './modelService.js';
|
import * as routeRefreshWorkflow from './routeRefreshWorkflow.js';
|
||||||
import { sendNotification } from './notifyService.js';
|
import { sendNotification } from './notifyService.js';
|
||||||
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
|
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
|
||||||
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
|
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
|
||||||
@@ -162,7 +162,7 @@ function createBalanceTask(cronExpr: string) {
|
|||||||
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
|
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
|
||||||
try {
|
try {
|
||||||
await refreshAllBalances();
|
await refreshAllBalances();
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
console.log('[Scheduler] Balance refresh complete');
|
console.log('[Scheduler] Balance refresh complete');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Scheduler] Balance refresh error:', err);
|
console.error('[Scheduler] Balance refresh error:', err);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
buildOauthIdentityBackfillPatch,
|
||||||
buildOauthInfoFromAccount,
|
buildOauthInfoFromAccount,
|
||||||
buildStoredOauthStateFromAccount,
|
buildStoredOauthStateFromAccount,
|
||||||
getOauthInfoFromAccount,
|
getOauthInfoFromAccount,
|
||||||
@@ -133,4 +134,40 @@ describe('oauth account identity helpers', () => {
|
|||||||
quota: { status: 'supported' },
|
quota: { status: 'supported' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds a structured identity backfill patch from legacy oauth metadata only for missing columns', () => {
|
||||||
|
expect(buildOauthIdentityBackfillPatch({
|
||||||
|
oauthProvider: null,
|
||||||
|
oauthAccountKey: null,
|
||||||
|
oauthProjectId: null,
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
accountKey: 'legacy-account',
|
||||||
|
projectId: 'legacy-project',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})).toEqual({
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'legacy-account',
|
||||||
|
oauthProjectId: 'legacy-project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buildOauthIdentityBackfillPatch({
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'structured-account',
|
||||||
|
oauthProjectId: null,
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
oauth: {
|
||||||
|
provider: 'legacy-provider-ignored',
|
||||||
|
accountKey: 'legacy-account-ignored',
|
||||||
|
projectId: 'legacy-project',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})).toEqual({
|
||||||
|
oauthProjectId: 'legacy-project',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ type OauthIdentityCarrier = {
|
|||||||
oauthProjectId?: string | null;
|
oauthProjectId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StoredOauthIdentity = Pick<OauthInfo, 'provider' | 'accountId' | 'accountKey' | 'projectId'>;
|
||||||
|
|
||||||
function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig {
|
function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig {
|
||||||
if (!extraConfig) return {};
|
if (!extraConfig) return {};
|
||||||
try {
|
try {
|
||||||
@@ -113,17 +115,26 @@ function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | und
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStoredOauthState(extraConfig?: string | null): Partial<OauthInfo> | null {
|
function parseStoredOauthIdentity(extraConfig?: string | null): StoredOauthIdentity | null {
|
||||||
const parsed = parseExtraConfig(extraConfig).oauth;
|
const parsed = parseExtraConfig(extraConfig).oauth;
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||||
const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId);
|
const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId);
|
||||||
|
const provider = asTrimmedString(parsed.provider);
|
||||||
|
if (!provider) return null;
|
||||||
return {
|
return {
|
||||||
provider: asTrimmedString(parsed.provider),
|
provider,
|
||||||
accountId: asTrimmedString(parsed.accountId) || accountKey,
|
accountId: asTrimmedString(parsed.accountId) || accountKey,
|
||||||
accountKey,
|
accountKey,
|
||||||
|
projectId: asTrimmedString(parsed.projectId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredOauthRuntimeState(extraConfig?: string | null): Partial<OauthInfo> | null {
|
||||||
|
const parsed = parseExtraConfig(extraConfig).oauth;
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||||
|
return {
|
||||||
email: asTrimmedString(parsed.email),
|
email: asTrimmedString(parsed.email),
|
||||||
planType: asTrimmedString(parsed.planType),
|
planType: asTrimmedString(parsed.planType),
|
||||||
projectId: asTrimmedString(parsed.projectId),
|
|
||||||
tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt),
|
tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt),
|
||||||
refreshToken: asTrimmedString(parsed.refreshToken),
|
refreshToken: asTrimmedString(parsed.refreshToken),
|
||||||
idToken: asTrimmedString(parsed.idToken),
|
idToken: asTrimmedString(parsed.idToken),
|
||||||
@@ -137,45 +148,68 @@ function parseStoredOauthState(extraConfig?: string | null): Partial<OauthInfo>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
|
export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
|
||||||
const parsed = parseStoredOauthState(extraConfig);
|
const identity = parseStoredOauthIdentity(extraConfig);
|
||||||
const provider = parsed?.provider;
|
const runtime = parseStoredOauthRuntimeState(extraConfig);
|
||||||
|
const provider = identity?.provider;
|
||||||
if (!provider) return null;
|
if (!provider) return null;
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
accountId: parsed.accountId || parsed.accountKey,
|
accountId: identity?.accountId || identity?.accountKey,
|
||||||
accountKey: parsed.accountKey,
|
accountKey: identity?.accountKey,
|
||||||
email: parsed.email,
|
projectId: identity?.projectId,
|
||||||
planType: parsed.planType,
|
email: runtime?.email,
|
||||||
projectId: parsed.projectId,
|
planType: runtime?.planType,
|
||||||
tokenExpiresAt: parsed.tokenExpiresAt,
|
tokenExpiresAt: runtime?.tokenExpiresAt,
|
||||||
refreshToken: parsed.refreshToken,
|
refreshToken: runtime?.refreshToken,
|
||||||
idToken: parsed.idToken,
|
idToken: runtime?.idToken,
|
||||||
providerData: parsed.providerData,
|
providerData: runtime?.providerData,
|
||||||
quota: parsed.quota,
|
quota: runtime?.quota,
|
||||||
modelDiscoveryStatus: parsed.modelDiscoveryStatus,
|
modelDiscoveryStatus: runtime?.modelDiscoveryStatus,
|
||||||
lastModelSyncAt: parsed.lastModelSyncAt,
|
lastModelSyncAt: runtime?.lastModelSyncAt,
|
||||||
lastModelSyncError: parsed.lastModelSyncError,
|
lastModelSyncError: runtime?.lastModelSyncError,
|
||||||
lastDiscoveredModels: parsed.lastDiscoveredModels,
|
lastDiscoveredModels: runtime?.lastDiscoveredModels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOauthInfoFromAccount(account?: OauthIdentityCarrier | null): OauthInfo | null {
|
export function getOauthInfoFromAccount(account?: OauthIdentityCarrier | null): OauthInfo | null {
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
const stored = parseStoredOauthState(account.extraConfig);
|
const storedIdentity = parseStoredOauthIdentity(account.extraConfig);
|
||||||
const provider = asTrimmedString(account.oauthProvider) || stored?.provider;
|
const storedRuntime = parseStoredOauthRuntimeState(account.extraConfig);
|
||||||
|
const provider = asTrimmedString(account.oauthProvider) || storedIdentity?.provider;
|
||||||
if (!provider) return null;
|
if (!provider) return null;
|
||||||
const structuredAccountKey = asTrimmedString(account.oauthAccountKey);
|
const structuredAccountKey = asTrimmedString(account.oauthAccountKey);
|
||||||
const accountKey = structuredAccountKey || stored?.accountKey || stored?.accountId;
|
const accountKey = structuredAccountKey || storedIdentity?.accountKey || storedIdentity?.accountId;
|
||||||
const projectId = asTrimmedString(account.oauthProjectId) || stored?.projectId;
|
const projectId = asTrimmedString(account.oauthProjectId) || storedIdentity?.projectId;
|
||||||
return {
|
return {
|
||||||
...(stored || {}),
|
...(storedRuntime || {}),
|
||||||
provider,
|
provider,
|
||||||
accountId: structuredAccountKey || stored?.accountId || accountKey,
|
accountId: structuredAccountKey || storedIdentity?.accountId || accountKey,
|
||||||
accountKey,
|
accountKey,
|
||||||
projectId,
|
projectId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildOauthIdentityBackfillPatch(
|
||||||
|
account?: OauthIdentityCarrier | null,
|
||||||
|
): Partial<Pick<OauthIdentityCarrier, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId'>> | null {
|
||||||
|
if (!account) return null;
|
||||||
|
const legacyIdentity = parseStoredOauthIdentity(account.extraConfig);
|
||||||
|
if (!legacyIdentity?.provider) return null;
|
||||||
|
|
||||||
|
const patch: Partial<Pick<OauthIdentityCarrier, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId'>> = {};
|
||||||
|
if (!asTrimmedString(account.oauthProvider)) {
|
||||||
|
patch.oauthProvider = legacyIdentity.provider;
|
||||||
|
}
|
||||||
|
if (!asTrimmedString(account.oauthAccountKey) && (legacyIdentity.accountKey || legacyIdentity.accountId)) {
|
||||||
|
patch.oauthAccountKey = legacyIdentity.accountKey || legacyIdentity.accountId;
|
||||||
|
}
|
||||||
|
if (!asTrimmedString(account.oauthProjectId) && legacyIdentity.projectId) {
|
||||||
|
patch.oauthProjectId = legacyIdentity.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(patch).length > 0 ? patch : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildOauthInfo(
|
export function buildOauthInfo(
|
||||||
extraConfig?: string | null,
|
extraConfig?: string | null,
|
||||||
patch: Partial<OauthInfo> = {},
|
patch: Partial<OauthInfo> = {},
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { eq, isNotNull, isNull, or } from 'drizzle-orm';
|
||||||
|
import { db, schema } from '../../db/index.js';
|
||||||
|
import { buildOauthIdentityBackfillPatch } from './oauthAccount.js';
|
||||||
|
|
||||||
|
let inFlightOauthIdentityBackfill: Promise<number> | null = null;
|
||||||
|
|
||||||
|
async function runOauthIdentityBackfill(): Promise<number> {
|
||||||
|
const rows = await db.select().from(schema.accounts)
|
||||||
|
.where(or(
|
||||||
|
isNotNull(schema.accounts.extraConfig),
|
||||||
|
isNull(schema.accounts.oauthProvider),
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const patch = buildOauthIdentityBackfillPatch(row);
|
||||||
|
if (!patch) continue;
|
||||||
|
await db.update(schema.accounts).set({
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}).where(eq(schema.accounts.id, row.id)).run();
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureOauthIdentityBackfill(): Promise<number> {
|
||||||
|
if (inFlightOauthIdentityBackfill) {
|
||||||
|
return inFlightOauthIdentityBackfill;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlightOauthIdentityBackfill = (async () => {
|
||||||
|
try {
|
||||||
|
return await runOauthIdentityBackfill();
|
||||||
|
} finally {
|
||||||
|
inFlightOauthIdentityBackfill = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return inFlightOauthIdentityBackfill;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
||||||
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../modelService.js';
|
import { refreshModelsForAccount } from '../modelService.js';
|
||||||
|
import * as routeRefreshWorkflow from '../routeRefreshWorkflow.js';
|
||||||
import {
|
import {
|
||||||
createOauthSession,
|
createOauthSession,
|
||||||
getOauthSession,
|
getOauthSession,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
getOauthInfoFromAccount,
|
getOauthInfoFromAccount,
|
||||||
} from './oauthAccount.js';
|
} from './oauthAccount.js';
|
||||||
import { buildCodexOauthInfo } from './codexAccount.js';
|
import { buildCodexOauthInfo } from './codexAccount.js';
|
||||||
|
import { ensureOauthIdentityBackfill } from './oauthIdentityBackfill.js';
|
||||||
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
|
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
|
||||||
|
|
||||||
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
|
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
|
||||||
@@ -404,7 +406,7 @@ export async function handleOauthCallback(input: {
|
|||||||
updatedAt: previousAccount.updatedAt,
|
updatedAt: previousAccount.updatedAt,
|
||||||
}).where(eq(schema.accounts.id, previousAccount.id)).run();
|
}).where(eq(schema.accounts.id, previousAccount.id)).run();
|
||||||
}
|
}
|
||||||
await rebuildTokenRoutesFromAvailability();
|
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`;
|
const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`;
|
||||||
markOauthSessionError(input.state, errorMessage);
|
markOauthSessionError(input.state, errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
@@ -417,7 +419,7 @@ export async function handleOauthCallback(input: {
|
|||||||
}).where(eq(schema.accounts.id, account.id)).run();
|
}).where(eq(schema.accounts.id, account.id)).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
await rebuildTokenRoutesFromAvailability();
|
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
markOauthSessionSuccess(input.state, {
|
markOauthSessionSuccess(input.state, {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
@@ -464,6 +466,7 @@ export async function listOauthConnections(options: {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
await ensureOauthIdentityBackfill();
|
||||||
const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50)));
|
const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50)));
|
||||||
const offset = Math.max(0, Math.trunc(options.offset ?? 0));
|
const offset = Math.max(0, Math.trunc(options.offset ?? 0));
|
||||||
|
|
||||||
@@ -565,7 +568,7 @@ export async function deleteOauthConnection(accountId: number) {
|
|||||||
throw new Error('account is not managed by oauth');
|
throw new Error('account is not managed by oauth');
|
||||||
}
|
}
|
||||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run();
|
await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run();
|
||||||
await rebuildTokenRoutesFromAvailability();
|
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { startBackgroundTask } from './backgroundTaskService.js';
|
||||||
|
import {
|
||||||
|
rebuildTokenRoutesFromAvailability,
|
||||||
|
refreshModelsAndRebuildRoutes as refreshModelsAndRebuildRoutesViaModelService,
|
||||||
|
} from './modelService.js';
|
||||||
|
|
||||||
|
export async function rebuildRoutesOnly() {
|
||||||
|
return rebuildTokenRoutesFromAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rebuildRoutesBestEffort() {
|
||||||
|
try {
|
||||||
|
await rebuildRoutesOnly();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshModelsAndRebuildRoutes() {
|
||||||
|
return refreshModelsAndRebuildRoutesViaModelService();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueRefreshModelsAndRebuildRoutesTask(input: {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
dedupeKey?: string;
|
||||||
|
notifyOnFailure?: boolean;
|
||||||
|
successMessage: (currentTask: { result?: unknown }) => string;
|
||||||
|
failureMessage: (currentTask: { error?: string | null }) => string;
|
||||||
|
}) {
|
||||||
|
return startBackgroundTask(
|
||||||
|
{
|
||||||
|
type: input.type,
|
||||||
|
title: input.title,
|
||||||
|
dedupeKey: input.dedupeKey || 'refresh-models-and-rebuild-routes',
|
||||||
|
notifyOnFailure: input.notifyOnFailure ?? true,
|
||||||
|
successMessage: input.successMessage,
|
||||||
|
failureMessage: input.failureMessage,
|
||||||
|
},
|
||||||
|
async () => refreshModelsAndRebuildRoutes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user