diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts index b6e6615..73570e8 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -136,13 +136,13 @@ export function bindSurfaceStickyChannel(input: { stickySessionKey?: string | null; selected: { channel: { id: number }; - account?: { extraConfig?: string | null } | null; + account?: { extraConfig?: string | null; oauthProvider?: string | null } | null; }; }): void { proxyChannelCoordinator.bindStickyChannel( input.stickySessionKey, input.selected.channel.id, - input.selected.account?.extraConfig, + input.selected.account || undefined, ); } @@ -162,7 +162,7 @@ export async function acquireSurfaceChannelLease(input: { stickySessionKey?: string | null; selected: { channel: { id: number }; - account?: { extraConfig?: string | null } | null; + account?: { extraConfig?: string | null; oauthProvider?: string | null } | null; }; }) { return await proxyChannelCoordinator.acquireChannelLease({ @@ -171,6 +171,7 @@ export async function acquireSurfaceChannelLease(input: { // the pre-sticky-session parallel behavior instead of contending globally. channelId: input.stickySessionKey ? input.selected.channel.id : 0, accountExtraConfig: input.selected.account?.extraConfig, + accountOauthProvider: input.selected.account?.oauthProvider, }); } diff --git a/src/server/routes/api/accounts.credentialMode.test.ts b/src/server/routes/api/accounts.credentialMode.test.ts index 61d6503..84312b9 100644 --- a/src/server/routes/api/accounts.credentialMode.test.ts +++ b/src/server/routes/api/accounts.credentialMode.test.ts @@ -276,6 +276,68 @@ describe('accounts credential mode', { timeout: 15_000 }, () => { }); }); + it('uses structured oauth columns when listing oauth account capabilities and runtime health', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Structured Codex Site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'structured-oauth@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + checkinEnabled: false, + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-structured-123', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + email: 'structured-oauth@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + checkedAt: '2026-04-01T12:00:00.000Z', + }).run(); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/accounts', + }); + expect(listResponse.statusCode).toBe(200); + + const list = listResponse.json() as Array<{ + id: number; + capabilities?: { + canCheckin?: boolean; + canRefreshBalance?: boolean; + proxyOnly?: boolean; + }; + runtimeHealth?: { + state?: string; + reason?: string; + }; + }>; + const item = list.find((entry) => entry.id === account.id); + expect(item?.capabilities).toMatchObject({ + canCheckin: false, + canRefreshBalance: false, + proxyOnly: true, + }); + expect(item?.runtimeHealth).toMatchObject({ + state: 'healthy', + reason: '模型探测成功', + }); + }); + it('stores managed refresh token for sub2api session account', async () => { verifyTokenMock.mockResolvedValueOnce({ tokenType: 'session', diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index fbef5bf..8a97c92 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -105,9 +105,9 @@ function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelec function buildCapabilitiesFromCredentialMode( credentialMode: AccountCredentialMode, hasSessionToken: boolean, - extraConfig?: string | null, + oauthIdentity?: string | null | Pick, ): AccountCapabilities { - if (hasOauthProvider(extraConfig)) { + if (hasOauthProvider(oauthIdentity)) { return { canCheckin: false, canRefreshBalance: false, @@ -126,7 +126,7 @@ function buildCapabilitiesFromCredentialMode( function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelect): AccountCapabilities { const credentialMode = resolveStoredCredentialMode(account); - return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken), account.extraConfig); + return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken), account); } function normalizeBatchIds(input: unknown): number[] { @@ -498,15 +498,12 @@ export async function accountsRoutes(app: FastifyInstance) { return rows.map((r) => { const credentialMode = resolveStoredCredentialMode(r.accounts); + const capabilities = buildCapabilitiesForAccount(r.accounts); return { ...r.accounts, site: r.sites, credentialMode, - capabilities: buildCapabilitiesFromCredentialMode( - credentialMode, - hasSessionTokenValue(r.accounts.accessToken), - r.accounts.extraConfig, - ), + capabilities, todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000, todayReward: Math.round(estimateRewardWithTodayIncomeFallback({ day: localDay, @@ -519,11 +516,7 @@ export async function accountsRoutes(app: FastifyInstance) { accountStatus: r.accounts.status, siteStatus: r.sites.status, extraConfig: r.accounts.extraConfig, - sessionCapable: buildCapabilitiesFromCredentialMode( - credentialMode, - hasSessionTokenValue(r.accounts.accessToken), - r.accounts.extraConfig, - ).canRefreshBalance, + sessionCapable: capabilities.canRefreshBalance, hasDiscoveredModels: (modelCountByAccount[r.accounts.id] || 0) > 0, }), }; diff --git a/src/server/services/accountExtraConfig.test.ts b/src/server/services/accountExtraConfig.test.ts index ef96822..bb0f8dc 100644 --- a/src/server/services/accountExtraConfig.test.ts +++ b/src/server/services/accountExtraConfig.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { buildStoredSub2ApiSubscriptionSummary, getCredentialModeFromExtraConfig, + hasOauthProvider, getPlatformUserIdFromExtraConfig, getProxyUrlFromExtraConfig, getSub2ApiAuthFromExtraConfig, @@ -145,6 +146,24 @@ describe('accountExtraConfig', () => { })).toBe(true); }); + it('recognizes structured oauth provider columns even when extraConfig omits oauth.provider', () => { + const structuredOauthAccount = { + oauthProvider: 'codex', + accessToken: 'oauth-access-token', + apiToken: null, + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + email: 'oauth-user@example.com', + }, + }), + }; + + expect(hasOauthProvider(structuredOauthAccount)).toBe(true); + expect(supportsDirectAccountRoutingConnection(structuredOauthAccount)).toBe(true); + expect(requiresManagedAccountTokens(structuredOauthAccount)).toBe(false); + }); + it('parses stored sub2api subscription summary from extra config', () => { const extraConfig = mergeAccountExtraConfig(null, { sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({ diff --git a/src/server/services/accountExtraConfig.ts b/src/server/services/accountExtraConfig.ts index 46b9d27..5dc4976 100644 --- a/src/server/services/accountExtraConfig.ts +++ b/src/server/services/accountExtraConfig.ts @@ -40,11 +40,20 @@ type AccountExtraConfig = { }; type ExtraConfigInput = string | Record | null | undefined; +type OauthProviderCarrier = { + extraConfig?: ExtraConfigInput; + oauthProvider?: unknown; +}; +type OauthProviderInput = ExtraConfigInput | OauthProviderCarrier; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } +function isOauthProviderCarrier(value: unknown): value is OauthProviderCarrier { + return isRecord(value) && ('extraConfig' in value || 'oauthProvider' in value); +} + function parseExtraConfig(extraConfig?: ExtraConfigInput): AccountExtraConfig { if (!extraConfig) return {}; if (isRecord(extraConfig)) return extraConfig as AccountExtraConfig; @@ -142,14 +151,23 @@ export function getOauthProviderFromExtraConfig(extraConfig?: ExtraConfigInput): return normalizeNonEmptyString(parsed.oauth?.provider); } -export function hasOauthProvider(extraConfig?: ExtraConfigInput): boolean { - return !!getOauthProviderFromExtraConfig(extraConfig); +function getOauthProvider(input?: OauthProviderInput): string | undefined { + if (!isOauthProviderCarrier(input)) { + return getOauthProviderFromExtraConfig(input); + } + return normalizeNonEmptyString(input.oauthProvider) + ?? getOauthProviderFromExtraConfig(input.extraConfig); +} + +export function hasOauthProvider(input?: OauthProviderInput): boolean { + return !!getOauthProvider(input); } type DirectAccountRoutingInput = { accessToken?: string | null; apiToken?: string | null; extraConfig?: ExtraConfigInput; + oauthProvider?: string | null; }; function hasCredentialValue(value: string | null | undefined): boolean { @@ -158,7 +176,7 @@ function hasCredentialValue(value: string | null | undefined): boolean { export function supportsDirectAccountRoutingConnection(account: DirectAccountRoutingInput): boolean { const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig); - if (hasOauthProvider(account.extraConfig)) { + if (hasOauthProvider(account)) { return hasCredentialValue(account.accessToken) || hasCredentialValue(account.apiToken); } if (credentialMode === 'apikey') { @@ -173,7 +191,7 @@ export function supportsDirectAccountRoutingConnection(account: DirectAccountRou export function requiresManagedAccountTokens(account: DirectAccountRoutingInput): boolean { const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig); - if (hasOauthProvider(account.extraConfig)) return false; + if (hasOauthProvider(account)) return false; if (credentialMode === 'apikey') return false; if (credentialMode === 'session') return true; if (hasCredentialValue(account.apiToken) && !hasCredentialValue(account.accessToken)) return false; diff --git a/src/server/services/modelService.test.ts b/src/server/services/modelService.test.ts index e88d15e..bade903 100644 --- a/src/server/services/modelService.test.ts +++ b/src/server/services/modelService.test.ts @@ -199,6 +199,59 @@ describe('rebuildTokenRoutesFromAvailability', () => { expect(channels[0]?.manualOverride).toBe(false); }); + it('creates an exact route with an account-direct channel for oauth accounts stored via structured identity columns', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site-structured', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-structured@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-structured-123', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + email: 'codex-structured@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + latencyMs: 320, + checkedAt: '2026-04-01T00:00:00.000Z', + }).run(); + + const rebuild = await rebuildTokenRoutesFromAvailability(); + + expect(rebuild.models).toBe(1); + + const route = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.modelPattern, 'gpt-5.2-codex')) + .get(); + expect(route).toBeDefined(); + + const channels = await db.select().from(schema.routeChannels) + .where(and( + eq(schema.routeChannels.routeId, route!.id), + eq(schema.routeChannels.accountId, account.id), + )) + .all(); + + expect(channels).toHaveLength(1); + expect(channels[0]?.tokenId ?? null).toBeNull(); + expect(channels[0]?.manualOverride).toBe(false); + }); + it('removes stale exact routes and keeps wildcard routes on rebuild', async () => { const site = await db.insert(schema.sites).values({ name: 'site-1', diff --git a/src/server/services/proxyChannelCoordinator.test.ts b/src/server/services/proxyChannelCoordinator.test.ts index 0ef4720..dc9d237 100644 --- a/src/server/services/proxyChannelCoordinator.test.ts +++ b/src/server/services/proxyChannelCoordinator.test.ts @@ -64,6 +64,22 @@ describe('proxyChannelCoordinator', () => { expect(proxyChannelCoordinator.getStickyChannelId(key)).toBeNull(); }); + it('treats structured oauth providers as session-scoped even when extraConfig omits oauth.provider', () => { + const key = proxyChannelCoordinator.buildStickySessionKey({ + clientKind: 'codex', + sessionId: 'turn-oauth-structured', + requestedModel: 'gpt-5.2', + downstreamPath: '/v1/responses', + downstreamApiKeyId: 9, + }); + + proxyChannelCoordinator.bindStickyChannel(key, 42, { + oauthProvider: 'codex', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + }); + expect(proxyChannelCoordinator.getStickyChannelId(key)).toBe(42); + }); + it('queues requests behind the active lease and grants the next waiter after release', async () => { const first = await proxyChannelCoordinator.acquireChannelLease({ channelId: 11, diff --git a/src/server/services/proxyChannelCoordinator.ts b/src/server/services/proxyChannelCoordinator.ts index debab79..bf3be5f 100644 --- a/src/server/services/proxyChannelCoordinator.ts +++ b/src/server/services/proxyChannelCoordinator.ts @@ -38,6 +38,14 @@ export type AcquireProxyChannelLeaseResult = const stickySessionBindings = new Map(); const channelRuntimeStates = new Map(); let nextLeaseId = 1; +type SessionScopedChannelInput = + | string + | null + | undefined + | { + extraConfig?: string | null; + oauthProvider?: string | null; + }; function shouldUnrefTimer(timer: ReturnType | ReturnType) { if (typeof (timer as { unref?: () => void }).unref === 'function') { @@ -53,9 +61,14 @@ function cleanupExpiredStickyBindings(nowMs = Date.now()): void { } } -function isSessionScopedChannel(extraConfig?: string | null): boolean { - return getCredentialModeFromExtraConfig(extraConfig) === 'session' - || hasOauthProvider(extraConfig); +function getSessionScopedExtraConfig(input?: SessionScopedChannelInput): string | null | undefined { + if (typeof input === 'string' || input == null) return input; + return input.extraConfig; +} + +function isSessionScopedChannel(input?: SessionScopedChannelInput): boolean { + return getCredentialModeFromExtraConfig(getSessionScopedExtraConfig(input)) === 'session' + || hasOauthProvider(input); } function getStickySessionTtlMs(): number { @@ -74,8 +87,8 @@ function getChannelQueueWaitMs(): number { return Math.max(0, Math.trunc(config.proxySessionChannelQueueWaitMs || 0)); } -function getChannelConcurrencyLimit(extraConfig?: string | null): number { - if (!isSessionScopedChannel(extraConfig)) return 0; +function getChannelConcurrencyLimit(input?: SessionScopedChannelInput): number { + if (!isSessionScopedChannel(input)) return 0; return Math.max(0, Math.trunc(config.proxySessionChannelConcurrencyLimit || 0)); } @@ -141,9 +154,9 @@ class ProxyChannelCoordinator { return entry.channelId; } - bindStickyChannel(stickySessionKey: string | null | undefined, channelId: number, extraConfig?: string | null): void { + bindStickyChannel(stickySessionKey: string | null | undefined, channelId: number, accountIdentity?: SessionScopedChannelInput): void { if (!config.proxyStickySessionEnabled) return; - if (!isSessionScopedChannel(extraConfig)) return; + if (!isSessionScopedChannel(accountIdentity)) return; const normalizedKey = String(stickySessionKey || '').trim(); if (!normalizedKey || !Number.isFinite(channelId) || channelId <= 0) return; cleanupExpiredStickyBindings(); @@ -167,6 +180,7 @@ class ProxyChannelCoordinator { async acquireChannelLease(input: { channelId: number; accountExtraConfig?: string | null; + accountOauthProvider?: string | null; }): Promise { const channelId = Math.trunc(input.channelId || 0); if (channelId <= 0) { @@ -176,7 +190,10 @@ class ProxyChannelCoordinator { }; } - const concurrencyLimit = getChannelConcurrencyLimit(input.accountExtraConfig); + const concurrencyLimit = getChannelConcurrencyLimit({ + extraConfig: input.accountExtraConfig, + oauthProvider: input.accountOauthProvider, + }); if (concurrencyLimit <= 0) { return { status: 'acquired', @@ -288,8 +305,8 @@ export function resetProxyChannelCoordinatorState(): void { nextLeaseId = 1; } -export function isProxyChannelSessionScoped(extraConfig?: string | null): boolean { - return isSessionScopedChannel(extraConfig); +export function isProxyChannelSessionScoped(input?: SessionScopedChannelInput): boolean { + return isSessionScopedChannel(input); } export const proxyChannelCoordinator = new ProxyChannelCoordinator();