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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -105,9 +105,9 @@ function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelec
|
||||
function buildCapabilitiesFromCredentialMode(
|
||||
credentialMode: AccountCredentialMode,
|
||||
hasSessionToken: boolean,
|
||||
extraConfig?: string | null,
|
||||
oauthIdentity?: string | null | Pick<typeof schema.accounts.$inferSelect, 'extraConfig' | 'oauthProvider'>,
|
||||
): 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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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