forked from boos/BoosAPI
feat: add multi-provider OAuth support for Claude and Gemini CLI
This commit is contained in:
@@ -25,7 +25,7 @@ vi.mock('../../services/platforms/index.js', () => ({
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('account token coverage refresh', () => {
|
||||
describe('account token coverage refresh', { timeout: 15_000 }, () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
|
||||
@@ -172,7 +172,7 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('marks codex oauth connection as proxy-capable but not checkin/balance-capable', async () => {
|
||||
it('marks codex oauth connection as direct-routed proxy-only connection without checkin/balance capabilities', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Codex Site',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
@@ -221,12 +221,12 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
||||
}>;
|
||||
const item = list.find((entry) => entry.id === account.id);
|
||||
expect(item?.credentialMode).toBe('session');
|
||||
expect(item?.capabilities).toMatchObject({
|
||||
canCheckin: false,
|
||||
canRefreshBalance: false,
|
||||
proxyOnly: false,
|
||||
expect(item?.capabilities).toMatchObject({
|
||||
canCheckin: false,
|
||||
canRefreshBalance: false,
|
||||
proxyOnly: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('stores managed refresh token for sub2api session account', async () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../serv
|
||||
import {
|
||||
getCredentialModeFromExtraConfig,
|
||||
guessPlatformUserIdFromUsername,
|
||||
hasOauthProvider,
|
||||
getSub2ApiAuthFromExtraConfig,
|
||||
mergeAccountExtraConfig,
|
||||
normalizeCredentialMode as normalizeCredentialModeInput,
|
||||
@@ -19,7 +20,6 @@ import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
|
||||
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
|
||||
import { getLocalDayRangeUtc } from '../../services/localTimeService.js';
|
||||
import { isCodexPlatform } from '../../services/oauth/codexAccount.js';
|
||||
import {
|
||||
buildRuntimeHealthForAccount,
|
||||
setAccountRuntimeHealth,
|
||||
@@ -82,11 +82,11 @@ function buildCapabilitiesFromCredentialMode(
|
||||
hasSessionToken: boolean,
|
||||
extraConfig?: string | null,
|
||||
): AccountCapabilities {
|
||||
if (isCodexPlatform(extraConfig)) {
|
||||
if (hasOauthProvider(extraConfig)) {
|
||||
return {
|
||||
canCheckin: false,
|
||||
canRefreshBalance: false,
|
||||
proxyOnly: false,
|
||||
proxyOnly: true,
|
||||
};
|
||||
}
|
||||
const sessionCapable = credentialMode === 'session'
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import Fastify from 'fastify';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { oauthRoutes } from './oauth.js';
|
||||
import { isPublicApiRoute } from '../../desktop.js';
|
||||
|
||||
describe('oauth route registration', () => {
|
||||
it('registers oauth routes in the main server entry', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/server/index.ts'), 'utf8');
|
||||
const apps: Array<Awaited<ReturnType<typeof Fastify>>> = [];
|
||||
|
||||
expect(source).toContain("import { oauthRoutes } from './routes/api/oauth.js';");
|
||||
expect(source).toContain('await app.register(oauthRoutes);');
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
it('treats oauth callback as a public API route', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/server/desktop.ts'), 'utf8');
|
||||
it('registers oauth routes on a Fastify instance at runtime', async () => {
|
||||
const app = Fastify();
|
||||
apps.push(app);
|
||||
await app.register(oauthRoutes);
|
||||
|
||||
expect(source).toContain("url.startsWith('/api/oauth/callback/')");
|
||||
const routes = app.printRoutes();
|
||||
expect(routes).toContain('providers (GET, HEAD)');
|
||||
expect(routes).toContain(':provider');
|
||||
expect(routes).toContain('sessions/');
|
||||
expect(routes).toContain(':state (GET, HEAD)');
|
||||
expect(routes).toContain('onnections (GET, HEAD)');
|
||||
expect(routes).toContain('allback/');
|
||||
});
|
||||
|
||||
it('treats oauth callback route as a public desktop API route', () => {
|
||||
expect(isPublicApiRoute('/api/oauth/callback/codex')).toBe(true);
|
||||
expect(isPublicApiRoute('/api/oauth/callback/claude')).toBe(true);
|
||||
expect(isPublicApiRoute('/api/oauth/providers')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,10 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-routes-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.CODEX_CLIENT_ID = 'test-codex-client-id';
|
||||
process.env.CLAUDE_CLIENT_ID = 'test-claude-client-id';
|
||||
process.env.GEMINI_CLI_CLIENT_ID = 'test-gemini-client-id';
|
||||
process.env.GEMINI_CLI_CLIENT_SECRET = 'test-gemini-client-secret';
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
@@ -52,9 +56,13 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.CODEX_CLIENT_ID;
|
||||
delete process.env.CLAUDE_CLIENT_ID;
|
||||
delete process.env.GEMINI_CLI_CLIENT_ID;
|
||||
delete process.env.GEMINI_CLI_CLIENT_SECRET;
|
||||
});
|
||||
|
||||
it('lists codex oauth provider metadata', async () => {
|
||||
it('lists multi-provider oauth metadata', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/oauth/providers',
|
||||
@@ -62,14 +70,27 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
providers: [
|
||||
providers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: 'codex',
|
||||
platform: 'codex',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
}),
|
||||
],
|
||||
expect.objectContaining({
|
||||
provider: 'claude',
|
||||
platform: 'claude',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
provider: 'gemini-cli',
|
||||
platform: 'gemini-cli',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: true,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,7 +114,7 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/);
|
||||
expect(startBody.authorizationUrl).toContain('https://auth.openai.com/oauth/authorize?');
|
||||
expect(startBody.authorizationUrl).toContain('client_id=');
|
||||
expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback'));
|
||||
expect(startBody.authorizationUrl).toContain(encodeURIComponent('https://metapi.example/api/oauth/callback/codex'));
|
||||
expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`);
|
||||
expect(startBody.authorizationUrl).toContain('code_challenge=');
|
||||
|
||||
@@ -109,6 +130,26 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the codex loopback callback for local origins', async () => {
|
||||
const startResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/oauth/providers/codex/start',
|
||||
headers: {
|
||||
host: 'localhost:4000',
|
||||
},
|
||||
});
|
||||
|
||||
expect(startResponse.statusCode).toBe(200);
|
||||
const startBody = startResponse.json() as {
|
||||
provider: string;
|
||||
state: string;
|
||||
authorizationUrl: string;
|
||||
};
|
||||
expect(startBody.provider).toBe('codex');
|
||||
expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback'));
|
||||
expect(startBody.authorizationUrl).not.toContain(encodeURIComponent('http://localhost:4000/api/oauth/callback/codex'));
|
||||
});
|
||||
|
||||
it('handles codex callback, creates oauth-backed account, and discovers plan models', async () => {
|
||||
const jwt = buildJwt({
|
||||
email: 'codex-user@example.com',
|
||||
@@ -163,6 +204,9 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
});
|
||||
expect(callbackResponse.statusCode).toBe(200);
|
||||
expect(callbackResponse.body).toContain('window.close()');
|
||||
expect(String(fetchMock.mock.calls[0]?.[1]?.body || '')).toContain(
|
||||
'redirect_uri=https%3A%2F%2Fmetapi.example%2Fapi%2Foauth%2Fcallback%2Fcodex',
|
||||
);
|
||||
|
||||
const sessionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
@@ -284,7 +328,12 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
url: '/api/oauth/connections',
|
||||
});
|
||||
expect(connectionsResponse.statusCode).toBe(200);
|
||||
expect(connectionsResponse.json()).toEqual({ items: [] });
|
||||
expect(connectionsResponse.json()).toMatchObject({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('lists oauth connection health metadata and supports deleting the connection', async () => {
|
||||
@@ -301,6 +350,8 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
accessToken: 'oauth-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-account-123',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
@@ -342,8 +393,8 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json()).toEqual({
|
||||
items: [
|
||||
expect(listResponse.json()).toMatchObject({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
accountId: account.id,
|
||||
provider: 'codex',
|
||||
@@ -353,7 +404,10 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
||||
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
|
||||
+147
-35
@@ -1,4 +1,5 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||
import {
|
||||
deleteOauthConnection,
|
||||
getOauthSessionStatus,
|
||||
@@ -9,7 +10,80 @@ import {
|
||||
startOauthRebindFlow,
|
||||
} from '../../services/oauth/service.js';
|
||||
|
||||
function resolveRequestOrigin(request: FastifyRequest): string {
|
||||
const limitOauthProviderRead = createRateLimitGuard({
|
||||
bucket: 'oauth-provider-read',
|
||||
max: 60,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
|
||||
const limitOauthStart = createRateLimitGuard({
|
||||
bucket: 'oauth-start',
|
||||
max: 20,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
|
||||
const limitOauthSessionRead = createRateLimitGuard({
|
||||
bucket: 'oauth-session-read',
|
||||
max: 120,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
|
||||
const limitOauthConnectionRead = createRateLimitGuard({
|
||||
bucket: 'oauth-connection-read',
|
||||
max: 60,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
|
||||
const limitOauthConnectionMutate = createRateLimitGuard({
|
||||
bucket: 'oauth-connection-mutate',
|
||||
max: 20,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
|
||||
const limitOauthCallback = createRateLimitGuard({
|
||||
bucket: 'oauth-callback',
|
||||
max: 30,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function renderCallbackPage(message: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>window.close();</script>
|
||||
${escapeHtml(message)}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function parseOptionalProjectId(value: unknown): string | undefined | null {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function resolveRequestOrigin(request: FastifyRequest): string | undefined {
|
||||
const forwardedProto = typeof request.headers['x-forwarded-proto'] === 'string'
|
||||
? request.headers['x-forwarded-proto'].split(',')[0]?.trim()
|
||||
: '';
|
||||
@@ -18,24 +92,37 @@ function resolveRequestOrigin(request: FastifyRequest): string {
|
||||
? request.headers['x-forwarded-host'].split(',')[0]?.trim()
|
||||
: '';
|
||||
const host = forwardedHost
|
||||
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '')
|
||||
|| 'localhost';
|
||||
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '');
|
||||
if (!host) return undefined;
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
export async function oauthRoutes(app: FastifyInstance) {
|
||||
app.get('/api/oauth/providers', async () => ({
|
||||
app.get('/api/oauth/providers', { preHandler: [limitOauthProviderRead] }, async () => ({
|
||||
providers: listOauthProviders(),
|
||||
}));
|
||||
|
||||
app.post<{ Params: { provider: string }; Body: { accountId?: number } }>(
|
||||
app.post<{ Params: { provider: string }; Body: { accountId?: number; projectId?: string } }>(
|
||||
'/api/oauth/providers/:provider/start',
|
||||
{ preHandler: [limitOauthStart] },
|
||||
async (request, reply) => {
|
||||
const rebindAccountId = request.body?.accountId === undefined
|
||||
? undefined
|
||||
: parsePositiveInteger(request.body.accountId);
|
||||
if (request.body?.accountId !== undefined && rebindAccountId === null) {
|
||||
return reply.code(400).send({ message: 'invalid account id' });
|
||||
}
|
||||
const projectId = parseOptionalProjectId(request.body?.projectId);
|
||||
if (request.body?.projectId !== undefined && projectId === null) {
|
||||
return reply.code(400).send({ message: 'invalid project id' });
|
||||
}
|
||||
|
||||
try {
|
||||
return startOauthProviderFlow({
|
||||
return await startOauthProviderFlow({
|
||||
provider: request.params.provider,
|
||||
redirectOrigin: resolveRequestOrigin(request),
|
||||
rebindAccountId: request.body?.accountId,
|
||||
rebindAccountId: rebindAccountId ?? undefined,
|
||||
projectId: projectId ?? undefined,
|
||||
requestOrigin: resolveRequestOrigin(request),
|
||||
});
|
||||
} catch (error: any) {
|
||||
return reply.code(404).send({ message: error?.message || 'oauth provider not found' });
|
||||
@@ -43,23 +130,52 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { state: string } }>('/api/oauth/sessions/:state', async (request, reply) => {
|
||||
const session = getOauthSessionStatus(request.params.state);
|
||||
if (!session) {
|
||||
return reply.code(404).send({ message: 'oauth session not found' });
|
||||
}
|
||||
return session;
|
||||
});
|
||||
app.get<{ Params: { state: string } }>(
|
||||
'/api/oauth/sessions/:state',
|
||||
{ preHandler: [limitOauthSessionRead] },
|
||||
async (request, reply) => {
|
||||
const session = getOauthSessionStatus(request.params.state);
|
||||
if (!session) {
|
||||
return reply.code(404).send({ message: 'oauth session not found' });
|
||||
}
|
||||
return session;
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/api/oauth/connections', async () => ({
|
||||
items: await listOauthConnections(),
|
||||
}));
|
||||
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||
'/api/oauth/connections',
|
||||
{ preHandler: [limitOauthConnectionRead] },
|
||||
async (request, reply) => {
|
||||
const limit = request.query.limit === undefined ? undefined : parsePositiveInteger(request.query.limit);
|
||||
const offset = request.query.offset === undefined
|
||||
? undefined
|
||||
: (() => {
|
||||
if (typeof request.query.offset !== 'string') return null;
|
||||
const parsed = Number.parseInt(request.query.offset.trim(), 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
})();
|
||||
if (request.query.limit !== undefined && limit === null) {
|
||||
return reply.code(400).send({ message: 'invalid limit' });
|
||||
}
|
||||
if (request.query.offset !== undefined && offset === null) {
|
||||
return reply.code(400).send({ message: 'invalid offset' });
|
||||
}
|
||||
return listOauthConnections({
|
||||
limit: limit ?? undefined,
|
||||
offset: offset ?? undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { accountId: string } }>(
|
||||
'/api/oauth/connections/:accountId/rebind',
|
||||
{ preHandler: [limitOauthConnectionMutate] },
|
||||
async (request, reply) => {
|
||||
const accountId = parsePositiveInteger(request.params.accountId);
|
||||
if (accountId === null) {
|
||||
return reply.code(400).send({ message: 'invalid account id' });
|
||||
}
|
||||
try {
|
||||
const accountId = Number.parseInt(request.params.accountId, 10);
|
||||
return await startOauthRebindFlow(accountId, resolveRequestOrigin(request));
|
||||
} catch (error: any) {
|
||||
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
|
||||
@@ -69,9 +185,13 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
|
||||
app.delete<{ Params: { accountId: string } }>(
|
||||
'/api/oauth/connections/:accountId',
|
||||
{ preHandler: [limitOauthConnectionMutate] },
|
||||
async (request, reply) => {
|
||||
const accountId = parsePositiveInteger(request.params.accountId);
|
||||
if (accountId === null) {
|
||||
return reply.code(400).send({ message: 'invalid account id' });
|
||||
}
|
||||
try {
|
||||
const accountId = Number.parseInt(request.params.accountId, 10);
|
||||
return await deleteOauthConnection(accountId);
|
||||
} catch (error: any) {
|
||||
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
|
||||
@@ -81,6 +201,7 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>(
|
||||
'/api/oauth/callback/:provider',
|
||||
{ preHandler: [limitOauthCallback] },
|
||||
async (request, reply) => {
|
||||
let message = 'OAuth callback received.';
|
||||
try {
|
||||
@@ -91,21 +212,12 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
error: request.query.error,
|
||||
});
|
||||
message = 'OAuth authorization succeeded. You can close this window.';
|
||||
} catch (error: any) {
|
||||
message = `OAuth authorization failed: ${error?.message || 'unknown error'}`;
|
||||
} catch {
|
||||
message = 'OAuth authorization failed. Return to metapi and review the server logs.';
|
||||
}
|
||||
|
||||
reply.type('text/html; charset=utf-8');
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>window.close();</script>
|
||||
${message}
|
||||
</body>
|
||||
</html>`;
|
||||
});
|
||||
reply.type('text/html; charset=utf-8');
|
||||
return renderCallbackPage(message);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
parseStoredUtcDateTime,
|
||||
toLocalDayKeyFromStoredUtc,
|
||||
} from '../../services/localTimeService.js';
|
||||
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||
|
||||
function parseBooleanFlag(raw?: string): boolean {
|
||||
if (!raw) return false;
|
||||
@@ -36,6 +37,11 @@ function parseBooleanFlag(raw?: string): boolean {
|
||||
|
||||
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
|
||||
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
|
||||
const limitModelTokenCandidatesRead = createRateLimitGuard({
|
||||
bucket: 'models-token-candidates-read',
|
||||
max: 30,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
|
||||
type ModelsMarketplaceCacheEntry = {
|
||||
expiresAt: number;
|
||||
@@ -970,7 +976,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/models/token-candidates', async () => {
|
||||
app.get('/api/models/token-candidates', { preHandler: [limitModelTokenCandidatesRead] }, async () => {
|
||||
const resolveTokenGroupLabel = (tokenGroup: string | null, tokenName: string | null): string | null => {
|
||||
const explicit = (tokenGroup || '').trim();
|
||||
if (explicit) return explicit;
|
||||
|
||||
Reference in New Issue
Block a user