refactor: consolidate single-source routing and protocol helpers

This commit is contained in:
Cita
2026-03-22 21:27:09 +08:00
parent 96e7614835
commit 9290e51ecc
64 changed files with 3571 additions and 2681 deletions
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const ensureDefaultTokenForAccountMock = vi.fn();
const syncTokensFromUpstreamMock = vi.fn();
const refreshBalanceMock = vi.fn();
const refreshModelsForAccountMock = vi.fn();
const rebuildTokenRoutesFromAvailabilityMock = vi.fn();
vi.mock('./accountTokenService.js', () => ({
ensureDefaultTokenForAccount: (...args: unknown[]) => ensureDefaultTokenForAccountMock(...args),
syncTokensFromUpstream: (...args: unknown[]) => syncTokensFromUpstreamMock(...args),
}));
vi.mock('./balanceService.js', () => ({
refreshBalance: (...args: unknown[]) => refreshBalanceMock(...args),
}));
vi.mock('./modelService.js', () => ({
refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args),
rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args),
}));
describe('accountMutationWorkflow', () => {
beforeEach(() => {
ensureDefaultTokenForAccountMock.mockReset();
syncTokensFromUpstreamMock.mockReset();
refreshBalanceMock.mockReset();
refreshModelsForAccountMock.mockReset();
rebuildTokenRoutesFromAvailabilityMock.mockReset();
});
it('can ensure a preferred token before syncing upstream tokens', async () => {
ensureDefaultTokenForAccountMock.mockResolvedValue(10);
syncTokensFromUpstreamMock.mockResolvedValue({ total: 2, created: 1, updated: 1 });
refreshBalanceMock.mockResolvedValue({ balance: 1 });
refreshModelsForAccountMock.mockResolvedValue({ accountId: 1, refreshed: true });
rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue({ createdRoutes: 1 });
const { convergeAccountMutation } = await import('./accountMutationWorkflow.js');
const upstreamTokens = [{ name: 'default', key: 'sk-upstream', enabled: true }];
const result = await convergeAccountMutation({
accountId: 1,
preferredApiToken: 'sk-preferred',
defaultTokenSource: 'manual',
ensurePreferredTokenBeforeSync: true,
upstreamTokens,
refreshBalance: true,
refreshModels: true,
rebuildRoutes: true,
});
expect(ensureDefaultTokenForAccountMock).toHaveBeenCalledWith(1, 'sk-preferred', {
name: 'default',
source: 'manual',
});
expect(syncTokensFromUpstreamMock).toHaveBeenCalledWith(1, upstreamTokens);
expect(refreshBalanceMock).toHaveBeenCalledWith(1);
expect(refreshModelsForAccountMock).toHaveBeenCalledWith(1);
expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1);
expect(ensureDefaultTokenForAccountMock.mock.invocationCallOrder[0]).toBeLessThan(
syncTokensFromUpstreamMock.mock.invocationCallOrder[0]!,
);
expect(result.defaultTokenId).toBe(10);
expect(result.tokenSync).toEqual({ total: 2, created: 1, updated: 1 });
expect(result.refreshedBalance).toBe(true);
expect(result.refreshedModels).toBe(true);
expect(result.rebuiltRoutes).toBe(true);
});
it('falls back to ensuring the preferred token when upstream tokens are absent', async () => {
ensureDefaultTokenForAccountMock.mockResolvedValue(22);
const { convergeAccountMutation } = await import('./accountMutationWorkflow.js');
const result = await convergeAccountMutation({
accountId: 2,
preferredApiToken: 'sk-fallback',
defaultTokenSource: 'sync',
});
expect(syncTokensFromUpstreamMock).not.toHaveBeenCalled();
expect(ensureDefaultTokenForAccountMock).toHaveBeenCalledWith(2, 'sk-fallback', {
name: 'default',
source: 'sync',
});
expect(result.defaultTokenId).toBe(22);
expect(result.tokenSync).toBeNull();
});
it('continues through later refresh steps when continueOnError is enabled', async () => {
refreshBalanceMock.mockRejectedValue(new Error('balance failed'));
refreshModelsForAccountMock.mockResolvedValue({ accountId: 3, refreshed: true });
rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue({ createdRoutes: 0 });
const { convergeAccountMutation } = await import('./accountMutationWorkflow.js');
const result = await convergeAccountMutation({
accountId: 3,
refreshBalance: true,
refreshModels: true,
rebuildRoutes: true,
continueOnError: true,
});
expect(result.refreshedBalance).toBe(false);
expect(result.refreshedModels).toBe(true);
expect(result.rebuiltRoutes).toBe(true);
expect(refreshModelsForAccountMock).toHaveBeenCalledWith(3);
expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1);
});
it('refreshes model coverage in batches and maps failures', async () => {
refreshModelsForAccountMock
.mockResolvedValueOnce({ accountId: 1, refreshed: true, status: 'success' })
.mockRejectedValueOnce(new Error('coverage failed'));
rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue({ createdRoutes: 2 });
const { refreshAccountCoverageBatch } = await import('./accountMutationWorkflow.js');
const result = await refreshAccountCoverageBatch({
accountIds: [1, 2],
batchSize: 1,
mapFailure: (accountId, errorMessage) => ({ accountId, errorMessage }),
});
expect(result.refresh).toEqual([
{ accountId: 1, refreshed: true, status: 'success' },
{ accountId: 2, errorMessage: 'coverage failed' },
]);
expect(result.rebuild).toEqual({
success: true,
result: { createdRoutes: 2 },
});
});
});
@@ -0,0 +1,172 @@
import { refreshBalance } from './balanceService.js';
import {
ensureDefaultTokenForAccount,
syncTokensFromUpstream,
} from './accountTokenService.js';
import {
rebuildTokenRoutesFromAvailability,
refreshModelsForAccount,
type ModelRefreshResult,
} from './modelService.js';
type UpstreamTokenLike = {
name?: string | null;
key?: string | null;
enabled?: boolean | null;
tokenGroup?: string | null;
};
export type CoverageBatchRebuildResult =
| { success: true; result: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
| { success: false; error: string };
export async function convergeAccountMutation(input: {
accountId: number;
preferredApiToken?: string | null;
defaultTokenSource?: string;
ensurePreferredTokenBeforeSync?: boolean;
upstreamTokens?: UpstreamTokenLike[];
refreshBalance?: boolean;
refreshModels?: boolean;
rebuildRoutes?: boolean;
continueOnError?: boolean;
}): Promise<{
defaultTokenId: number | null;
tokenSync: Awaited<ReturnType<typeof syncTokensFromUpstream>> | null;
refreshedBalance: boolean;
refreshedModels: boolean;
rebuiltRoutes: boolean;
balanceResult: Awaited<ReturnType<typeof refreshBalance>> | null;
modelRefreshResult: ModelRefreshResult | null;
rebuildResult: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> | null;
}> {
const result = {
defaultTokenId: null as number | null,
tokenSync: null as Awaited<ReturnType<typeof syncTokensFromUpstream>> | null,
refreshedBalance: false,
refreshedModels: false,
rebuiltRoutes: false,
balanceResult: null as Awaited<ReturnType<typeof refreshBalance>> | null,
modelRefreshResult: null as ModelRefreshResult | null,
rebuildResult: null as Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> | null,
};
const runStep = async <T>(fn: () => Promise<T>): Promise<T | null> => {
if (!input.continueOnError) return fn();
try {
return await fn();
} catch {
return null;
}
};
if (input.ensurePreferredTokenBeforeSync && input.preferredApiToken?.trim()) {
const defaultTokenId = await runStep(() => ensureDefaultTokenForAccount(
input.accountId,
input.preferredApiToken!,
{ name: 'default', source: input.defaultTokenSource || 'manual' },
));
if (defaultTokenId != null) {
result.defaultTokenId = defaultTokenId;
}
}
if ((input.upstreamTokens?.length || 0) > 0) {
const tokenSync = await runStep(() => syncTokensFromUpstream(input.accountId, input.upstreamTokens!));
if (tokenSync) {
result.tokenSync = tokenSync;
result.defaultTokenId = tokenSync.defaultTokenId ?? result.defaultTokenId;
}
} else if (!input.ensurePreferredTokenBeforeSync && input.preferredApiToken?.trim()) {
const defaultTokenId = await runStep(() => ensureDefaultTokenForAccount(
input.accountId,
input.preferredApiToken!,
{ name: 'default', source: input.defaultTokenSource || 'manual' },
));
if (defaultTokenId != null) {
result.defaultTokenId = defaultTokenId;
}
}
if (input.refreshBalance) {
const balanceResult = await runStep(() => refreshBalance(input.accountId));
if (balanceResult) {
result.balanceResult = balanceResult;
result.refreshedBalance = true;
}
}
if (input.refreshModels) {
const modelRefreshResult = await runStep(() => refreshModelsForAccount(input.accountId));
if (modelRefreshResult) {
result.modelRefreshResult = modelRefreshResult;
result.refreshedModels = true;
}
}
if (input.rebuildRoutes) {
const rebuildResult = await runStep(() => rebuildTokenRoutesFromAvailability());
if (rebuildResult) {
result.rebuildResult = rebuildResult;
result.rebuiltRoutes = true;
}
}
return result;
}
export async function refreshAccountCoverageBatch<TFailure>(input: {
accountIds: number[];
batchSize: number;
mapFailure: (accountId: number, errorMessage: string) => TFailure;
}): Promise<{
refresh: Array<ModelRefreshResult | TFailure>;
rebuild: CoverageBatchRebuildResult | null;
}> {
const uniqueAccountIds = Array.from(new Set(
input.accountIds.filter((id) => Number.isFinite(id) && id > 0),
));
if (uniqueAccountIds.length === 0) {
return { refresh: [], rebuild: null };
}
const refresh: Array<ModelRefreshResult | TFailure> = [];
for (let offset = 0; offset < uniqueAccountIds.length; offset += input.batchSize) {
const batch = uniqueAccountIds.slice(offset, offset + input.batchSize);
const settled = await Promise.allSettled(
batch.map(async (accountId) => refreshModelsForAccount(accountId)),
);
settled.forEach((entry, index) => {
if (entry.status === 'fulfilled') {
refresh.push(entry.value);
return;
}
const accountId = batch[index] || 0;
const errorMessage = entry.reason instanceof Error
? entry.reason.message
: String(entry.reason || 'coverage refresh failed');
refresh.push(input.mapFailure(accountId, errorMessage));
});
}
try {
return {
refresh,
rebuild: {
success: true,
result: await rebuildTokenRoutesFromAvailability(),
},
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed');
return {
refresh,
rebuild: {
success: false,
error: errorMessage,
},
};
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ import cron from 'node-cron';
import { db, schema } from '../db/index.js';
import { upsertSetting } from '../db/upsertSetting.js';
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
const BACKUP_VERSION = '2.1';
@@ -317,7 +317,7 @@ function normalizeLegacyQuota(raw: unknown): number {
}
function resolveImportedOauthColumns(row: Pick<AccountRow, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId' | 'extraConfig'>) {
const oauth = getOauthInfoFromExtraConfig(row.extraConfig);
const oauth = getOauthInfoFromAccount(row);
const oauthProvider = row.oauthProvider || oauth?.provider || null;
const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null;
const oauthProjectId = row.oauthProjectId || oauth?.projectId || null;
@@ -757,9 +757,9 @@ describe('refreshModelsForAccount credential discovery', () => {
.get();
const parsed = JSON.parse(latest!.extraConfig || '{}');
expect(parsed.oauth).toMatchObject({
provider: 'codex',
modelDiscoveryStatus: 'abnormal',
});
expect(parsed.oauth).not.toHaveProperty('provider');
expect(parsed.oauth.lastModelSyncError).toContain('HTTP 403');
expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
expect(parsed.runtimeHealth?.state).toBe('unhealthy');
@@ -990,12 +990,12 @@ describe('refreshModelsForAccount credential discovery', () => {
});
const parsed = JSON.parse(latest!.extraConfig || '{}');
expect(parsed.oauth).toMatchObject({
provider: 'gemini-cli',
email: 'gemini-refreshed-user@example.com',
refreshToken: 'gemini-refresh-token-next',
projectId: 'project-refresh-demo',
modelDiscoveryStatus: 'healthy',
});
expect(parsed.oauth).not.toHaveProperty('provider');
expect(parsed.oauth).not.toHaveProperty('projectId');
});
it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => {
+8 -8
View File
@@ -21,8 +21,8 @@ import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
import { getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
import { isCodexPlatform } from './oauth/codexAccount.js';
import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount } from './oauth/oauthAccount.js';
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js';
import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js';
import {
@@ -241,10 +241,10 @@ async function updateOauthModelDiscoveryState(input: {
lastModelSyncError?: string;
lastDiscoveredModels?: string[];
}) {
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
const oauth = getOauthInfoFromAccount(input.account);
if (!oauth) return input.account.extraConfig || null;
const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, {
oauth: buildOauthInfo(input.account.extraConfig, {
oauth: buildStoredOauthStateFromAccount(input.account, {
provider: oauth.provider,
modelDiscoveryStatus: input.status,
lastModelSyncAt: input.checkedAt,
@@ -267,7 +267,7 @@ async function discoverCodexModelsFromCloud(input: {
if (!accessToken) {
throw new Error('codex oauth access token missing');
}
const oauth = getCodexOauthInfoFromExtraConfig(input.account.extraConfig);
const oauth = getOauthInfoFromAccount(input.account);
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
@@ -321,7 +321,7 @@ async function validateGeminiCliOauthConnection(input: {
if (!accessToken) {
throw new Error('gemini cli oauth access token missing');
}
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
const oauth = getOauthInfoFromAccount(input.account);
const projectId = (oauth?.projectId || '').trim();
if (!projectId) {
throw new Error('gemini cli oauth project id missing');
@@ -357,7 +357,7 @@ async function discoverAntigravityModelsFromCloud(input: {
throw new Error('antigravity oauth access token missing');
}
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
const oauth = getOauthInfoFromAccount(input.account);
const requestBody = oauth?.projectId ? { project: oauth.projectId } : {};
let lastError = '';
@@ -508,7 +508,7 @@ export async function refreshModelsForAccount(
const account = row.accounts;
const site = row.sites;
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
const oauth = getOauthInfoFromAccount(account);
const adapter = getAdapter(site.platform);
const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig);
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import {
buildOauthInfoFromAccount,
buildStoredOauthStateFromAccount,
getOauthInfoFromAccount,
} from './oauthAccount.js';
describe('oauth account identity helpers', () => {
it('prefers structured oauth identity columns over extraConfig metadata', () => {
const oauth = getOauthInfoFromAccount({
oauthProvider: 'gemini-cli',
oauthAccountKey: 'structured-user@example.com',
oauthProjectId: 'structured-project',
extraConfig: JSON.stringify({
oauth: {
provider: 'codex',
accountKey: 'json-user',
projectId: 'json-project',
refreshToken: 'refresh-token',
quota: { status: 'supported' },
},
}),
});
expect(oauth).toEqual(expect.objectContaining({
provider: 'gemini-cli',
accountId: 'structured-user@example.com',
accountKey: 'structured-user@example.com',
projectId: 'structured-project',
refreshToken: 'refresh-token',
quota: { status: 'supported' },
}));
});
it('falls back to extraConfig metadata when structured columns are absent', () => {
const oauth = getOauthInfoFromAccount({
oauthProvider: null,
oauthAccountKey: null,
oauthProjectId: null,
extraConfig: JSON.stringify({
oauth: {
provider: 'codex',
accountKey: 'json-user',
projectId: 'json-project',
refreshToken: 'refresh-token',
},
}),
});
expect(oauth).toEqual(expect.objectContaining({
provider: 'codex',
accountId: 'json-user',
accountKey: 'json-user',
projectId: 'json-project',
refreshToken: 'refresh-token',
}));
});
it('reconstructs oauth runtime state from extraConfig even when identity fields are stripped', () => {
const oauth = getOauthInfoFromAccount({
oauthProvider: 'gemini-cli',
oauthAccountKey: 'structured-user@example.com',
oauthProjectId: 'structured-project',
extraConfig: JSON.stringify({
oauth: {
email: 'structured-user@example.com',
refreshToken: 'refresh-token',
modelDiscoveryStatus: 'healthy',
},
}),
});
expect(oauth).toEqual(expect.objectContaining({
provider: 'gemini-cli',
accountId: 'structured-user@example.com',
accountKey: 'structured-user@example.com',
projectId: 'structured-project',
email: 'structured-user@example.com',
refreshToken: 'refresh-token',
modelDiscoveryStatus: 'healthy',
}));
});
it('builds patched oauth state from structured identity columns and current runtime state', () => {
const oauth = buildOauthInfoFromAccount({
oauthProvider: 'codex',
oauthAccountKey: 'structured-account',
oauthProjectId: 'structured-project',
extraConfig: JSON.stringify({
oauth: {
provider: 'codex',
refreshToken: 'refresh-token',
modelDiscoveryStatus: 'healthy',
},
}),
}, {
quota: { status: 'supported' } as any,
});
expect(oauth).toEqual(expect.objectContaining({
provider: 'codex',
accountId: 'structured-account',
accountKey: 'structured-account',
projectId: 'structured-project',
refreshToken: 'refresh-token',
modelDiscoveryStatus: 'healthy',
quota: { status: 'supported' },
}));
});
it('strips oauth identity fields when preparing persisted extraConfig state', () => {
const oauth = buildStoredOauthStateFromAccount({
oauthProvider: 'codex',
oauthAccountKey: 'structured-account',
oauthProjectId: 'structured-project',
extraConfig: JSON.stringify({
oauth: {
provider: 'codex',
accountId: 'json-account',
accountKey: 'json-account',
projectId: 'json-project',
refreshToken: 'refresh-token',
modelDiscoveryStatus: 'healthy',
},
}),
}, {
quota: { status: 'supported' } as any,
});
expect(oauth).toEqual({
refreshToken: 'refresh-token',
modelDiscoveryStatus: 'healthy',
quota: { status: 'supported' },
});
});
});
+92 -4
View File
@@ -43,6 +43,15 @@ export type OauthInfo = {
lastDiscoveredModels?: string[];
};
export type StoredOauthState = Omit<OauthInfo, 'provider' | 'accountId' | 'accountKey' | 'projectId'>;
type OauthIdentityCarrier = {
extraConfig?: string | null;
oauthProvider?: string | null;
oauthAccountKey?: string | null;
oauthProjectId?: string | null;
};
function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig {
if (!extraConfig) return {};
try {
@@ -104,14 +113,12 @@ function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | und
return undefined;
}
export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
function parseStoredOauthState(extraConfig?: string | null): Partial<OauthInfo> | null {
const parsed = parseExtraConfig(extraConfig).oauth;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const provider = asTrimmedString(parsed.provider);
if (!provider) return null;
const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId);
return {
provider,
provider: asTrimmedString(parsed.provider),
accountId: asTrimmedString(parsed.accountId) || accountKey,
accountKey,
email: asTrimmedString(parsed.email),
@@ -129,6 +136,46 @@ export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthI
};
}
export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
const parsed = parseStoredOauthState(extraConfig);
const provider = parsed?.provider;
if (!provider) return null;
return {
provider,
accountId: parsed.accountId || parsed.accountKey,
accountKey: parsed.accountKey,
email: parsed.email,
planType: parsed.planType,
projectId: parsed.projectId,
tokenExpiresAt: parsed.tokenExpiresAt,
refreshToken: parsed.refreshToken,
idToken: parsed.idToken,
providerData: parsed.providerData,
quota: parsed.quota,
modelDiscoveryStatus: parsed.modelDiscoveryStatus,
lastModelSyncAt: parsed.lastModelSyncAt,
lastModelSyncError: parsed.lastModelSyncError,
lastDiscoveredModels: parsed.lastDiscoveredModels,
};
}
export function getOauthInfoFromAccount(account?: OauthIdentityCarrier | null): OauthInfo | null {
if (!account) return null;
const stored = parseStoredOauthState(account.extraConfig);
const provider = asTrimmedString(account.oauthProvider) || stored?.provider;
if (!provider) return null;
const structuredAccountKey = asTrimmedString(account.oauthAccountKey);
const accountKey = structuredAccountKey || stored?.accountKey || stored?.accountId;
const projectId = asTrimmedString(account.oauthProjectId) || stored?.projectId;
return {
...(stored || {}),
provider,
accountId: structuredAccountKey || stored?.accountId || accountKey,
accountKey,
projectId,
};
}
export function buildOauthInfo(
extraConfig?: string | null,
patch: Partial<OauthInfo> = {},
@@ -152,6 +199,47 @@ export function buildOauthInfo(
return next;
}
export function buildOauthInfoFromAccount(
account?: OauthIdentityCarrier | null,
patch: Partial<OauthInfo> = {},
): OauthInfo {
const provider = patch.provider || getOauthInfoFromAccount(account)?.provider;
if (!provider) {
throw new Error('oauth provider is required');
}
const current = getOauthInfoFromAccount(account);
const next: OauthInfo = {
provider,
...(current || {}),
...patch,
};
if (!next.accountKey && next.accountId) {
next.accountKey = next.accountId;
}
if (!next.accountId && next.accountKey) {
next.accountId = next.accountKey;
}
return next;
}
export function buildStoredOauthState(oauth: OauthInfo): StoredOauthState {
const {
provider: _provider,
accountId: _accountId,
accountKey: _accountKey,
projectId: _projectId,
...stored
} = oauth;
return stored;
}
export function buildStoredOauthStateFromAccount(
account?: OauthIdentityCarrier | null,
patch: Partial<OauthInfo> = {},
): StoredOauthState {
return buildStoredOauthState(buildOauthInfoFromAccount(account, patch));
}
export function isOauthProvider(
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
provider?: string,
+9 -5
View File
@@ -1,7 +1,11 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig, type OauthInfo } from './oauthAccount.js';
import {
buildStoredOauthStateFromAccount,
getOauthInfoFromAccount,
type OauthInfo,
} from './oauthAccount.js';
import type { OauthQuotaSnapshot, OauthQuotaWindowSnapshot } from './quotaTypes.js';
type CodexJwtClaims = {
@@ -192,12 +196,12 @@ async function persistQuotaSnapshot(accountId: number, snapshot: OauthQuotaSnaps
if (!account) {
throw new Error('oauth account not found');
}
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
const oauth = getOauthInfoFromAccount(account);
if (!oauth) {
throw new Error('account is not managed by oauth');
}
const nextExtraConfig = mergeAccountExtraConfig(account.extraConfig, {
oauth: buildOauthInfo(account.extraConfig, {
oauth: buildStoredOauthStateFromAccount(account, {
quota: snapshot,
}),
});
@@ -213,7 +217,7 @@ export async function refreshOauthQuotaSnapshot(accountId: number): Promise<Oaut
if (!account) {
throw new Error('oauth account not found');
}
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
const oauth = getOauthInfoFromAccount(account);
if (!oauth) {
throw new Error('account is not managed by oauth');
}
@@ -236,7 +240,7 @@ export async function recordOauthQuotaResetHint(input: {
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, input.accountId)).get();
if (!account) return null;
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
const oauth = getOauthInfoFromAccount(account);
if (!oauth || oauth.provider !== 'codex') return null;
const baseSnapshot = buildQuotaSnapshotFromOauthInfo({
+24 -10
View File
@@ -14,7 +14,13 @@ import {
listOAuthProviderDefinitions,
type OAuthProviderDefinition,
} from './providers.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
import {
buildOauthInfo,
buildOauthInfoFromAccount,
buildStoredOauthState,
buildStoredOauthStateFromAccount,
getOauthInfoFromAccount,
} from './oauthAccount.js';
import { buildCodexOauthInfo } from './codexAccount.js';
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
@@ -232,7 +238,7 @@ async function upsertOauthAccount(input: {
});
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, {
credentialMode: 'session',
oauth,
oauth: buildStoredOauthState(oauth),
});
if (existing) {
@@ -511,7 +517,7 @@ export async function listOauthConnections(options: {
}
const items = rows.map((row) => {
const oauth = getOauthInfoFromExtraConfig(row.accounts.extraConfig)!;
const oauth = getOauthInfoFromAccount(row.accounts)!;
const models = modelMap.get(row.accounts.id) || [];
const status = (
oauth.modelDiscoveryStatus === 'abnormal'
@@ -553,8 +559,8 @@ export async function deleteOauthConnection(accountId: number) {
if (!account) {
throw new Error('oauth account not found');
}
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth) {
const normalizedOauth = getOauthInfoFromAccount(account);
if (!normalizedOauth) {
throw new Error('account is not managed by oauth');
}
await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run();
@@ -574,7 +580,7 @@ export async function startOauthRebindFlow(accountId: number, requestOrigin?: st
if (!account) {
throw new Error('oauth account not found');
}
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
const oauth = getOauthInfoFromAccount(account);
if (!oauth) {
throw new Error('account is not managed by oauth');
}
@@ -587,10 +593,18 @@ export async function startOauthRebindFlow(accountId: number, requestOrigin?: st
}
export function buildOauthProviderHeaders(input: {
account?: {
extraConfig?: string | null;
oauthProvider?: string | null;
oauthAccountKey?: string | null;
oauthProjectId?: string | null;
} | null;
extraConfig?: string | null;
downstreamHeaders?: Record<string, unknown>;
}) {
const oauth = getOauthInfoFromExtraConfig(input.extraConfig);
const oauth = getOauthInfoFromAccount(input.account || {
extraConfig: input.extraConfig,
});
if (!oauth) return {};
const definition = getOAuthProviderDefinition(oauth.provider);
if (!definition?.buildProxyHeaders) return {};
@@ -619,7 +633,7 @@ export async function refreshOauthAccessToken(accountId: number) {
if (!account) {
throw new Error('oauth account not found');
}
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
const oauth = getOauthInfoFromAccount(account);
if (!oauth?.refreshToken) {
throw new Error('oauth refresh token missing');
}
@@ -635,7 +649,7 @@ export async function refreshOauthAccessToken(accountId: number) {
providerData: oauth.providerData,
},
});
const nextOauth = buildOauthInfo(account.extraConfig, {
const nextOauth = buildOauthInfoFromAccount(account, {
provider: oauth.provider,
accountId: refreshed.accountId || oauth.accountId,
accountKey: refreshed.accountKey || oauth.accountKey || refreshed.accountId || oauth.accountId,
@@ -652,7 +666,7 @@ export async function refreshOauthAccessToken(accountId: number) {
});
const extraConfig = mergeAccountExtraConfig(account.extraConfig, {
credentialMode: 'session',
oauth: nextOauth,
oauth: buildStoredOauthStateFromAccount(account, nextOauth),
});
await db.update(schema.accounts).set({
+9 -42
View File
@@ -1,20 +1,8 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
import { StandardApiProviderAdapterBase } from './standardApiProvider.js';
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function resolveClaudeModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (/\/v\d+(\.\d+)?$/i.test(normalized)) {
return `${normalized}/models`;
}
return `${normalized}/v1/models`;
}
export class ClaudeAdapter extends BasePlatformAdapter {
export class ClaudeAdapter extends StandardApiProviderAdapterBase {
readonly platformName = 'claude';
async detect(url: string): Promise<boolean> {
@@ -22,34 +10,13 @@ export class ClaudeAdapter extends BasePlatformAdapter {
return normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1');
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Official Anthropic API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
try {
const res = await this.fetchJson<any>(resolveClaudeModelsUrl(baseUrl), {
headers: {
'x-api-key': apiToken,
'anthropic-version': DEFAULT_ANTHROPIC_VERSION,
},
});
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
} catch {
return [];
}
return this.fetchModelsFromStandardEndpoint({
baseUrl,
headers: {
'x-api-key': apiToken,
'anthropic-version': DEFAULT_ANTHROPIC_VERSION,
},
});
}
}
+14 -37
View File
@@ -1,30 +1,27 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
import {
StandardApiProviderAdapterBase,
normalizePlatformBaseUrl,
resolveVersionedModelsUrl,
} from './standardApiProvider.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
export class CliProxyApiAdapter extends BasePlatformAdapter {
export class CliProxyApiAdapter extends StandardApiProviderAdapterBase {
readonly platformName = 'cliproxyapi';
protected override loginUnsupportedMessage = 'CLIProxyAPI does not support login';
protected override checkinUnsupportedMessage = 'CLIProxyAPI does not support checkin';
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
// Quick check: default CLIProxyAPI port
if (/:8317(\/|$)/.test(normalized)) {
return true;
}
// Quick check: common hostname keyword
if (normalized.includes('cliproxy')) {
return true;
}
// Probe management endpoint with strict signature checks.
// Do not trust bare 401/403 because many non-CLIProxy sites may return
// those statuses for unknown/protected paths.
try {
const base = normalizeBaseUrl(url);
const base = normalizePlatformBaseUrl(url);
const { fetch } = await import('undici');
const res = await fetch(`${base}/v0/management/openai-compatibility`, {
method: 'GET',
@@ -53,31 +50,11 @@ export class CliProxyApiAdapter extends BasePlatformAdapter {
}
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false as const, message: 'CLIProxyAPI does not support login' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'CLIProxyAPI does not support checkin' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
try {
const base = normalizeBaseUrl(baseUrl);
const res = await this.fetchJson<any>(`${base}/v1/models`, {
headers: { Authorization: `Bearer ${apiToken}` },
});
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
} catch {
return [];
}
return this.fetchModelsFromStandardEndpoint({
baseUrl,
headers: { Authorization: `Bearer ${apiToken}` },
resolveUrl: resolveVersionedModelsUrl,
});
}
}
+16 -40
View File
@@ -1,8 +1,4 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
import { StandardApiProviderAdapterBase, normalizePlatformBaseUrl } from './standardApiProvider.js';
function stripModelPrefix(name: string): string {
const trimmed = name.trim();
@@ -24,13 +20,13 @@ function isOpenAiCompatGeminiBase(baseUrl: string): boolean {
}
function resolveGeminiOpenAiModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
const normalized = normalizePlatformBaseUrl(baseUrl);
if (/\/models$/i.test(normalized)) return normalized;
return `${normalized}/models`;
}
function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string {
const normalized = normalizeBaseUrl(baseUrl);
const normalized = normalizePlatformBaseUrl(baseUrl);
const withVersion = /\/v\d+(?:beta)?(?:\/|$)/i.test(normalized)
? normalized
: `${normalized}/v1beta`;
@@ -41,7 +37,7 @@ function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string
return `${listBase}${separator}key=${encodeURIComponent(apiToken)}`;
}
export class GeminiAdapter extends BasePlatformAdapter {
export class GeminiAdapter extends StandardApiProviderAdapterBase {
readonly platformName: string = 'gemini';
async detect(url: string): Promise<boolean> {
@@ -53,34 +49,16 @@ export class GeminiAdapter extends BasePlatformAdapter {
);
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Gemini API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
const normalizedBase = normalizeBaseUrl(baseUrl);
const normalizedBase = normalizePlatformBaseUrl(baseUrl);
if (isOpenAiCompatGeminiBase(normalizedBase)) {
try {
const res = await this.fetchJson<any>(resolveGeminiOpenAiModelsUrl(normalizedBase), {
headers: { Authorization: `Bearer ${apiToken}` },
});
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
} catch {}
const openAiModels = await this.fetchModelsFromStandardEndpoint({
baseUrl: normalizedBase,
headers: { Authorization: `Bearer ${apiToken}` },
resolveUrl: resolveGeminiOpenAiModelsUrl,
});
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
}
try {
@@ -92,13 +70,11 @@ export class GeminiAdapter extends BasePlatformAdapter {
} catch {}
if (!isOpenAiCompatGeminiBase(normalizedBase)) {
try {
const res = await this.fetchJson<any>(`${normalizedBase}/v1beta/openai/models`, {
headers: { Authorization: `Bearer ${apiToken}` },
});
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
} catch {}
const openAiModels = await this.fetchModelsFromStandardEndpoint({
baseUrl: `${normalizedBase}/v1beta/openai`,
headers: { Authorization: `Bearer ${apiToken}` },
});
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
}
return [];
@@ -4,6 +4,7 @@ import { AddressInfo } from 'node:net';
import { OpenAiAdapter } from './openai.js';
import { ClaudeAdapter } from './claude.js';
import { GeminiAdapter } from './gemini.js';
import { CliProxyApiAdapter } from './cliproxyapi.js';
interface RequestSnapshot {
method: string;
@@ -52,6 +53,20 @@ describe('official llm upstream adapters', () => {
return;
}
if (req.url === '/cliproxy/v1/models') {
if (req.headers.authorization !== 'Bearer sk-cpa') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [{ id: 'gpt-5.4' }, { id: 'gpt-5.2-codex' }],
}));
return;
}
if (req.url?.startsWith('/gemini/v1beta/models')) {
const url = new URL(req.url, 'http://127.0.0.1');
if (url.searchParams.get('key') !== 'gemini-key') {
@@ -112,6 +127,12 @@ describe('official llm upstream adapters', () => {
expect(models).toEqual(['claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001']);
});
it('fetches models from cliproxy openai-compatible upstream', async () => {
const adapter = new CliProxyApiAdapter();
const models = await adapter.getModels(`${baseUrl}/cliproxy`, 'sk-cpa');
expect(models).toEqual(['gpt-5.4', 'gpt-5.2-codex']);
});
it('fetches models from gemini native endpoint and normalizes model names', async () => {
const adapter = new GeminiAdapter();
const models = await adapter.getModels(`${baseUrl}/gemini/v1beta`, 'gemini-key');
+6 -39
View File
@@ -1,18 +1,6 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
import { StandardApiProviderAdapterBase } from './standardApiProvider.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function resolveOpenAiModelsUrl(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (/\/v\d+(\.\d+)?$/i.test(normalized)) {
return `${normalized}/models`;
}
return `${normalized}/v1/models`;
}
export class OpenAiAdapter extends BasePlatformAdapter {
export class OpenAiAdapter extends StandardApiProviderAdapterBase {
readonly platformName = 'openai';
async detect(url: string): Promise<boolean> {
@@ -20,31 +8,10 @@ export class OpenAiAdapter extends BasePlatformAdapter {
return normalized.includes('api.openai.com');
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
// Official OpenAI API keys generally do not expose account balance via API.
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
try {
const res = await this.fetchJson<any>(resolveOpenAiModelsUrl(baseUrl), {
headers: { Authorization: `Bearer ${apiToken}` },
});
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
} catch {
return [];
}
return this.fetchModelsFromStandardEndpoint({
baseUrl,
headers: { Authorization: `Bearer ${apiToken}` },
});
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
StandardApiProviderAdapterBase,
normalizePlatformBaseUrl,
resolveVersionedModelsUrl,
} from './standardApiProvider.js';
class TestStandardApiProviderAdapter extends StandardApiProviderAdapterBase {
readonly platformName = 'test-standard';
async detect(_url: string): Promise<boolean> {
return false;
}
async getModels(_baseUrl: string, _token: string): Promise<string[]> {
return [];
}
}
describe('standardApiProvider helpers', () => {
it('normalizes provider base urls and appends /v1/models when needed', () => {
expect(normalizePlatformBaseUrl('https://api.example.com///')).toBe('https://api.example.com');
expect(resolveVersionedModelsUrl('https://api.example.com')).toBe('https://api.example.com/v1/models');
expect(resolveVersionedModelsUrl('https://api.example.com/v1')).toBe('https://api.example.com/v1/models');
expect(resolveVersionedModelsUrl('https://api.example.com/v1beta')).toBe('https://api.example.com/v1beta/models');
});
it('provides shared unsupported login/checkin and zero-balance defaults', async () => {
const adapter = new TestStandardApiProviderAdapter();
await expect(adapter.login('https://api.example.com', 'user', 'pass')).resolves.toEqual({
success: false,
message: 'login endpoint not supported',
});
await expect(adapter.getUserInfo('https://api.example.com', 'token')).resolves.toBe(null);
await expect(adapter.checkin('https://api.example.com', 'token')).resolves.toEqual({
success: false,
message: 'checkin endpoint not supported',
});
await expect(adapter.getBalance('https://api.example.com', 'token')).resolves.toEqual({
balance: 0,
used: 0,
quota: 0,
});
});
});
@@ -0,0 +1,73 @@
import {
BasePlatformAdapter,
type BalanceInfo,
type CheckinResult,
type UserInfo,
} from './base.js';
type FetchModelsOptions = {
baseUrl: string;
headers?: Record<string, string>;
resolveUrl?: (normalizedBaseUrl: string) => string;
mapResponse?: (payload: any) => unknown[];
};
export function normalizePlatformBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
export function resolveVersionedModelsUrl(baseUrl: string): string {
const normalized = normalizePlatformBaseUrl(baseUrl);
if (/\/v\d+(?:\.\d+)?(?:beta)?$/i.test(normalized)) {
return `${normalized}/models`;
}
return `${normalized}/v1/models`;
}
export abstract class StandardApiProviderAdapterBase extends BasePlatformAdapter {
protected loginUnsupportedMessage = 'login endpoint not supported';
protected checkinUnsupportedMessage = 'checkin endpoint not supported';
override async login(_baseUrl: string, _username: string, _password: string) {
return {
success: false as const,
message: this.loginUnsupportedMessage,
};
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
override async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return {
success: false,
message: this.checkinUnsupportedMessage,
};
}
override async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
return { balance: 0, used: 0, quota: 0 };
}
protected async fetchModelsFromStandardEndpoint(options: FetchModelsOptions): Promise<string[]> {
const normalizedBaseUrl = normalizePlatformBaseUrl(options.baseUrl);
const url = options.resolveUrl
? options.resolveUrl(normalizedBaseUrl)
: resolveVersionedModelsUrl(normalizedBaseUrl);
try {
const payload = await this.fetchJson<any>(url, {
headers: options.headers,
});
const rows = options.mapResponse
? options.mapResponse(payload)
: (payload?.data || []).map((item: any) => item?.id);
return rows
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
} catch {
return [];
}
}
}
@@ -76,4 +76,51 @@ describe('proxyInputFileResolver', () => {
},
});
});
it('exports generic inline local file resolution for route-level callers', async () => {
getProxyFileByPublicIdForOwnerMock.mockResolvedValue({
publicId: 'file-metapi-abc',
filename: 'notes.md',
mimeType: 'text/markdown',
contentBase64: Buffer.from('# hello').toString('base64'),
});
const { inlineLocalInputFileReferences } = await import('./proxyInputFileResolver.js');
await expect(inlineLocalInputFileReferences(
{
model: 'gpt-5',
messages: [
{
role: 'user',
content: [
{
type: 'file',
file: {
file_id: 'file-metapi-abc',
},
},
],
},
],
},
{ ownerType: 'managed_key', ownerId: '7' },
)).resolves.toEqual({
model: 'gpt-5',
messages: [
{
role: 'user',
content: [
{
type: 'file',
file: {
file_data: Buffer.from('# hello').toString('base64'),
filename: 'notes.md',
mime_type: 'text/markdown',
},
},
],
},
],
});
});
});
+33 -2
View File
@@ -1,8 +1,10 @@
import type { ProxyResourceOwner } from '../middleware/auth.js';
import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js';
import { getProxyFileByPublicIdForOwner } from './proxyFileStore.js';
import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js';
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
@@ -140,7 +142,6 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc
throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`);
}
return {
fileId: fileLike.fileId,
filename: fileLike.filename || stored.filename,
fileData: stored.contentBase64,
mimeType,
@@ -239,6 +240,36 @@ async function resolveResponsesMessageContent(content: unknown, owner: ProxyReso
}));
}
export async function inlineLocalInputFileReferences(
value: unknown,
owner: ProxyResourceOwner,
): Promise<unknown> {
if (Array.isArray(value)) {
return Promise.all(value.map((item) => inlineLocalInputFileReferences(item, owner)));
}
if (!isRecord(value)) return cloneJsonValue(value);
const fileLike = normalizeInputFileLike(value);
if (fileLike && shouldResolveInlineFileLike(fileLike)) {
const resolved = await resolveInputFileLike(fileLike, owner);
const type = asTrimmedString(value.type).toLowerCase();
if (type === 'file') {
return toOpenAiResolvedBlock(resolved);
}
if (type === 'input_file') {
return toResponsesResolvedBlock(resolved);
}
}
const entries = await Promise.all(
Object.entries(value).map(async ([key, entryValue]) => (
[key, await inlineLocalInputFileReferences(entryValue, owner)] as const
)),
);
return Object.fromEntries(entries);
}
export async function resolveOpenAiBodyInputFiles(
body: Record<string, unknown>,
owner: ProxyResourceOwner,
+1 -1
View File
@@ -44,7 +44,7 @@ type ParsedSiteProxyInput = {
proxyUrl: string | null;
};
type SiteProxyConfigLike = {
export type SiteProxyConfigLike = {
proxyUrl?: string | null;
useSystemProxy?: boolean | null;
customHeaders?: string | null;
+26 -170
View File
@@ -1,5 +1,4 @@
import { eq, inArray } from 'drizzle-orm';
import { minimatch } from 'minimatch';
import { db, schema } from '../db/index.js';
import { upsertSetting } from '../db/upsertSetting.js';
import { config } from '../config.js';
@@ -10,7 +9,19 @@ import {
} from './routeRoutingStrategy.js';
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
import { isUsableAccountToken } from './accountTokenService.js';
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
import {
isExactTokenRouteModelPattern,
isTokenRouteRegexPattern,
matchesTokenRouteModelPattern,
parseTokenRouteRegexPattern,
} from '../../shared/tokenRoutePatterns.js';
import {
normalizeTokenRouteMode,
type RouteDecision,
type RouteDecisionCandidate,
type RouteMode,
} from '../../shared/tokenRouteContract.js';
interface RouteMatch {
route: RouteRow;
@@ -61,7 +72,6 @@ const FAILURE_BACKOFF_BASE_SEC = 15;
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
const ROUND_ROBIN_FAILURE_THRESHOLD = 3;
const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const;
const MAX_ROUTE_REGEX_BODY_LENGTH = 256;
const SITE_RUNTIME_HEALTH_DECAY_HALF_LIFE_MS = 10 * 60 * 1000;
const SITE_RUNTIME_MIN_MULTIPLIER = 0.08;
const SITE_RUNTIME_LATENCY_BASELINE_MS = 2_500;
@@ -674,7 +684,6 @@ function filterSiteRuntimeBrokenCandidatesByModel(
};
}
type RouteMode = 'pattern' | 'explicit_group';
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
routeMode: RouteMode;
sourceRouteIds: number[];
@@ -848,33 +857,11 @@ export function filterRecentlyFailedCandidates<T extends { channel: FailureAware
return healthy.length > 0 ? healthy : candidates;
}
export interface RouteDecisionCandidate {
channelId: number;
accountId: number;
username: string;
siteName: string;
tokenName: string;
priority: number;
weight: number;
eligible: boolean;
recentlyFailed: boolean;
avoidedByRecentFailure: boolean;
probability: number;
reason: string;
}
export interface RouteDecisionExplanation {
requestedModel: string;
actualModel: string;
matched: boolean;
routeId?: number;
modelPattern?: string;
selectedChannelId?: number;
selectedAccountId?: number;
selectedLabel?: string;
summary: string[];
candidates: RouteDecisionCandidate[];
}
export type RouteDecisionExplanation = RouteDecision & {
routeId?: number;
modelPattern?: string;
selectedAccountId?: number;
};
const DEFAULT_DOWNSTREAM_POLICY: DownstreamRoutingPolicy = EMPTY_DOWNSTREAM_ROUTING_POLICY;
@@ -904,154 +891,23 @@ type CostSignal = {
};
export function isRegexModelPattern(pattern: string): boolean {
return pattern.trim().toLowerCase().startsWith('re:');
}
function readRegexQuantifierLength(pattern: string, startIndex: number): number {
const ch = pattern[startIndex];
if (ch === '*' || ch === '+' || ch === '?') return 1;
if (ch !== '{') return 0;
let index = startIndex + 1;
let sawDigit = false;
while (index < pattern.length && /\d/.test(pattern[index])) {
sawDigit = true;
index += 1;
}
if (!sawDigit) return 0;
if (pattern[index] === ',') {
index += 1;
while (index < pattern.length && /\d/.test(pattern[index])) {
index += 1;
}
}
if (pattern[index] !== '}') return 0;
return index - startIndex + 1;
}
function isSafeRegexPatternBody(body: string): boolean {
if (!body || body.length > MAX_ROUTE_REGEX_BODY_LENGTH) return false;
if (!/^[a-z0-9\s.^$|()[\]{}+*?\\:_/-]+$/i.test(body)) return false;
if (body.includes('(?=') || body.includes('(?!') || body.includes('(?<=') || body.includes('(?<!') || body.includes('(?<')) {
return false;
}
if (/(^|[^\\])\\[1-9]/.test(body)) {
return false;
}
const groupStack: Array<{ hasInnerQuantifier: boolean; hasAlternation: boolean }> = [];
let escaped = false;
let inCharClass = false;
for (let index = 0; index < body.length; index += 1) {
const ch = body[index];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (inCharClass) {
if (ch === ']') inCharClass = false;
continue;
}
if (ch === '[') {
inCharClass = true;
continue;
}
if (ch === '(') {
if (body[index + 1] === '?') {
if (body[index + 2] !== ':') {
return false;
}
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
index += 2;
continue;
}
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
continue;
}
if (ch === '|') {
if (groupStack.length > 0) {
groupStack[groupStack.length - 1].hasAlternation = true;
}
continue;
}
if (ch === ')') {
const group = groupStack.pop();
if (!group) return false;
const quantifierLength = readRegexQuantifierLength(body, index + 1);
if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) {
return false;
}
const parent = groupStack[groupStack.length - 1];
if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) {
parent.hasInnerQuantifier = true;
}
continue;
}
const quantifierLength = readRegexQuantifierLength(body, index);
if (quantifierLength > 0) {
if (groupStack.length > 0) {
groupStack[groupStack.length - 1].hasInnerQuantifier = true;
}
index += quantifierLength - 1;
}
}
return !escaped && !inCharClass && groupStack.length === 0;
return isTokenRouteRegexPattern(pattern);
}
export function parseRegexModelPattern(pattern: string): RegExp | null {
if (!isRegexModelPattern(pattern)) return null;
const body = pattern.trim().slice(3).trim();
if (!body) return null;
if (!isSafeRegexPatternBody(body)) return null;
try {
return new RegExp(body);
} catch {
return null;
}
}
return parseTokenRouteRegexPattern(pattern).regex;
}
export function matchesModelPattern(model: string, pattern: string): boolean {
const normalizedPattern = (pattern || '').trim();
if (!normalizedPattern) return false;
if (normalizedPattern === model) return true;
if (isRegexModelPattern(normalizedPattern)) {
const re = parseRegexModelPattern(normalizedPattern);
return !!re && re.test(model);
}
return minimatch(model, normalizedPattern);
return matchesTokenRouteModelPattern(model, pattern);
}
function isExactRouteModelPattern(pattern: string): boolean {
const normalizedPattern = (pattern || '').trim();
if (!normalizedPattern) return false;
if (isRegexModelPattern(normalizedPattern)) return false;
return !/[\*\?\[]/.test(normalizedPattern);
return isExactTokenRouteModelPattern(pattern);
}
function normalizeRouteMode(routeMode: string | null | undefined): RouteMode {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
return normalizeTokenRouteMode(routeMode);
}
function isExplicitGroupRoute(route: Pick<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): boolean {
@@ -2037,7 +1893,7 @@ export class TokenRouter {
return token ? token : null;
}
if (getOauthInfoFromExtraConfig(candidate.account.extraConfig)) {
if (getOauthInfoFromAccount(candidate.account)) {
const accessToken = candidate.account.accessToken?.trim();
if (accessToken) return accessToken;
return null;