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:
Cita
2026-04-01 17:15:22 +08:00
committed by GitHub
parent e2d9481b95
commit 21d92e1887
8 changed files with 209 additions and 30 deletions
@@ -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',
+6 -13
View File
@@ -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({
+22 -4
View File
@@ -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;
+53
View File
@@ -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,
+27 -10
View File
@@ -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();