forked from boos/BoosAPI
refactor: consolidate single-source routing and protocol helpers
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -44,7 +44,7 @@ type ParsedSiteProxyInput = {
|
||||
proxyUrl: string | null;
|
||||
};
|
||||
|
||||
type SiteProxyConfigLike = {
|
||||
export type SiteProxyConfigLike = {
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
customHeaders?: string | null;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user