fix: recognize structured oauth accounts in routing (#369)
* fix: recognize structured oauth accounts in routing * fix: use structured oauth accounts in account list
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -40,11 +40,20 @@ type AccountExtraConfig = {
|
||||
};
|
||||
|
||||
type ExtraConfigInput = string | Record<string, unknown> | null | undefined;
|
||||
type OauthProviderCarrier = {
|
||||
extraConfig?: ExtraConfigInput;
|
||||
oauthProvider?: unknown;
|
||||
};
|
||||
type OauthProviderInput = ExtraConfigInput | OauthProviderCarrier;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -38,6 +38,14 @@ export type AcquireProxyChannelLeaseResult =
|
||||
const stickySessionBindings = new Map<string, StickyEntry>();
|
||||
const channelRuntimeStates = new Map<number, ChannelRuntimeState>();
|
||||
let nextLeaseId = 1;
|
||||
type SessionScopedChannelInput =
|
||||
| string
|
||||
| null
|
||||
| undefined
|
||||
| {
|
||||
extraConfig?: string | null;
|
||||
oauthProvider?: string | null;
|
||||
};
|
||||
|
||||
function shouldUnrefTimer(timer: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>) {
|
||||
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<AcquireProxyChannelLeaseResult> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user