feat: implement Codex OAuth flow with loopback callback server and management UI

- Add localCallbackServer for handling OAuth callback requests.
- Implement service functions for managing OAuth sessions and accounts.
- Create CodexAdapter for platform-specific interactions.
- Develop OAuthManagement page for managing OAuth connections and providers.
- Add tests for localCallbackServer and OAuthManagement components.
This commit is contained in:
ciat-777
2026-03-17 11:07:25 +08:00
parent c156e0c164
commit 575a15232b
40 changed files with 3219 additions and 32 deletions
@@ -172,6 +172,62 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
});
});
it('marks codex oauth connection as proxy-capable but not checkin/balance-capable', async () => {
const site = await db.insert(schema.sites).values({
name: 'Codex Site',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
checkinEnabled: false,
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'plus',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
checkedAt: '2026-03-16T12:00:00.000Z',
}).run();
const listResponse = await app.inject({
method: 'GET',
url: '/api/accounts',
});
expect(listResponse.statusCode).toBe(200);
const list = listResponse.json() as Array<{
id: number;
credentialMode?: string;
capabilities?: {
canCheckin?: boolean;
canRefreshBalance?: boolean;
proxyOnly?: boolean;
};
}>;
const item = list.find((entry) => entry.id === account.id);
expect(item?.credentialMode).toBe('session');
expect(item?.capabilities).toMatchObject({
canCheckin: false,
canRefreshBalance: false,
proxyOnly: false,
});
});
it('stores managed refresh token for sub2api session account', async () => {
verifyTokenMock.mockResolvedValueOnce({
tokenType: 'session',
+14 -3
View File
@@ -19,6 +19,7 @@ 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,
@@ -79,7 +80,15 @@ function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelec
function buildCapabilitiesFromCredentialMode(
credentialMode: AccountCredentialMode,
hasSessionToken: boolean,
extraConfig?: string | null,
): AccountCapabilities {
if (isCodexPlatform(extraConfig)) {
return {
canCheckin: false,
canRefreshBalance: false,
proxyOnly: false,
};
}
const sessionCapable = credentialMode === 'session'
? hasSessionToken
: (credentialMode === 'apikey' ? false : hasSessionToken);
@@ -92,7 +101,7 @@ function buildCapabilitiesFromCredentialMode(
function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelect): AccountCapabilities {
const credentialMode = resolveStoredCredentialMode(account);
return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken));
return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken), account.extraConfig);
}
function normalizeBatchIds(input: unknown): number[] {
@@ -410,6 +419,7 @@ export async function accountsRoutes(app: FastifyInstance) {
capabilities: buildCapabilitiesFromCredentialMode(
credentialMode,
hasSessionTokenValue(r.accounts.accessToken),
r.accounts.extraConfig,
),
todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000,
todayReward: Math.round(estimateRewardWithTodayIncomeFallback({
@@ -426,6 +436,7 @@ export async function accountsRoutes(app: FastifyInstance) {
sessionCapable: buildCapabilitiesFromCredentialMode(
credentialMode,
hasSessionTokenValue(r.accounts.accessToken),
r.accounts.extraConfig,
).canRefreshBalance,
hasDiscoveredModels: (modelCountByAccount[r.accounts.id] || 0) > 0,
}),
@@ -968,7 +979,7 @@ export async function accountsRoutes(app: FastifyInstance) {
account: latest,
tokenType: 'session',
credentialMode: 'session',
capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true),
capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true, null),
apiTokenFound: !!nextApiToken,
};
},
@@ -1138,7 +1149,7 @@ export async function accountsRoutes(app: FastifyInstance) {
const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode;
const capabilities = account
? buildCapabilitiesForAccount(account)
: buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session');
: buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session', null);
return {
...account,
tokenType,
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('oauth route registration', () => {
it('registers oauth routes in the main server entry', () => {
const source = readFileSync(resolve(process.cwd(), 'src/server/index.ts'), 'utf8');
expect(source).toContain("import { oauthRoutes } from './routes/api/oauth.js';");
expect(source).toContain('await app.register(oauthRoutes);');
});
it('treats oauth callback as a public API route', () => {
const source = readFileSync(resolve(process.cwd(), 'src/server/desktop.ts'), 'utf8');
expect(source).toContain("url.startsWith('/api/oauth/callback/')");
});
});
+369
View File
@@ -0,0 +1,369 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const fetchMock = vi.fn();
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
function buildJwt(payload: Record<string, unknown>) {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value))
.toString('base64url');
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`;
}
describe('oauth routes', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-routes-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./oauth.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.oauthRoutes);
});
beforeEach(async () => {
fetchMock.mockReset();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('lists codex oauth provider metadata', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/oauth/providers',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
providers: [
expect.objectContaining({
provider: 'codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
}),
],
});
});
it('starts a codex oauth session and exposes pending status', async () => {
const startResponse = await app.inject({
method: 'POST',
url: '/api/oauth/providers/codex/start',
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
},
});
expect(startResponse.statusCode).toBe(200);
const startBody = startResponse.json() as {
provider: string;
state: string;
authorizationUrl: string;
};
expect(startBody.provider).toBe('codex');
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(`state=${encodeURIComponent(startBody.state)}`);
expect(startBody.authorizationUrl).toContain('code_challenge=');
const sessionResponse = await app.inject({
method: 'GET',
url: `/api/oauth/sessions/${startBody.state}`,
});
expect(sessionResponse.statusCode).toBe(200);
expect(sessionResponse.json()).toMatchObject({
provider: 'codex',
state: startBody.state,
status: 'pending',
});
});
it('handles codex callback, creates oauth-backed account, and discovers plan models', async () => {
const jwt = buildJwt({
email: 'codex-user@example.com',
'https://api.openai.com/auth': {
chatgpt_account_id: 'chatgpt-account-123',
chatgpt_plan_type: 'plus',
},
});
fetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'oauth-access-token',
refresh_token: 'oauth-refresh-token',
id_token: jwt,
expires_in: 3600,
token_type: 'Bearer',
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
models: [
{ id: 'gpt-5.2-codex' },
{ id: 'gpt-5' },
{ id: 'gpt-5.4' },
],
}),
text: async () => JSON.stringify({ ok: true }),
});
const startResponse = await app.inject({
method: 'POST',
url: '/api/oauth/providers/codex/start',
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
},
});
const startBody = startResponse.json() as { state: string };
const callbackResponse = await app.inject({
method: 'GET',
url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-123`,
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
},
});
expect(callbackResponse.statusCode).toBe(200);
expect(callbackResponse.body).toContain('window.close()');
const sessionResponse = await app.inject({
method: 'GET',
url: `/api/oauth/sessions/${startBody.state}`,
});
expect(sessionResponse.statusCode).toBe(200);
const sessionBody = sessionResponse.json() as {
provider: string;
status: string;
accountId?: number;
siteId?: number;
};
expect(sessionBody).toMatchObject({
provider: 'codex',
status: 'success',
});
expect(sessionBody.accountId).toBeTypeOf('number');
expect(sessionBody.siteId).toBeTypeOf('number');
const sites = await db.select().from(schema.sites).all();
expect(sites).toHaveLength(1);
expect(sites[0]).toMatchObject({
platform: 'codex',
url: 'https://chatgpt.com/backend-api/codex',
status: 'active',
});
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toHaveLength(1);
expect(accounts[0]).toMatchObject({
siteId: sites[0]?.id,
username: 'codex-user@example.com',
accessToken: 'oauth-access-token',
checkinEnabled: false,
status: 'active',
});
expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'plus',
refreshToken: 'oauth-refresh-token',
idToken: jwt,
},
});
const models = await db.select().from(schema.modelAvailability).all();
const modelNames = models.map((row) => row.modelName);
expect(modelNames.sort()).toEqual(['gpt-5', 'gpt-5.2-codex', 'gpt-5.4']);
});
it('marks oauth session as error and avoids creating a connection when codex model discovery fails', async () => {
const jwt = buildJwt({
email: 'codex-fail@example.com',
'https://api.openai.com/auth': {
chatgpt_account_id: 'chatgpt-account-fail',
chatgpt_plan_type: 'team',
},
});
fetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'oauth-access-token',
refresh_token: 'oauth-refresh-token',
id_token: jwt,
expires_in: 3600,
token_type: 'Bearer',
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: false,
status: 403,
json: async () => ({ error: 'forbidden' }),
text: async () => 'forbidden',
});
const startResponse = await app.inject({
method: 'POST',
url: '/api/oauth/providers/codex/start',
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
},
});
const startBody = startResponse.json() as { state: string };
const callbackResponse = await app.inject({
method: 'GET',
url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-456`,
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
},
});
expect(callbackResponse.statusCode).toBe(200);
expect(callbackResponse.body).toContain('OAuth authorization failed');
const sessionResponse = await app.inject({
method: 'GET',
url: `/api/oauth/sessions/${startBody.state}`,
});
expect(sessionResponse.statusCode).toBe(200);
expect(sessionResponse.json()).toMatchObject({
provider: 'codex',
status: 'error',
error: expect.stringContaining('HTTP 403'),
});
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toEqual([]);
const connectionsResponse = await app.inject({
method: 'GET',
url: '/api/oauth/connections',
});
expect(connectionsResponse.statusCode).toBe(200);
expect(connectionsResponse.json()).toEqual({ items: [] });
});
it('lists oauth connection health metadata and supports deleting the connection', async () => {
const site = await db.insert(schema.sites).values({
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'team',
modelDiscoveryStatus: 'abnormal',
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
checkedAt: '2026-03-17T08:00:00.000Z',
}).run();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2-codex',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
}).run();
const listResponse = await app.inject({
method: 'GET',
url: '/api/oauth/connections',
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toEqual({
items: [
expect.objectContaining({
accountId: account.id,
provider: 'codex',
email: 'codex-user@example.com',
status: 'abnormal',
routeChannelCount: 1,
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
}),
],
});
const deleteResponse = await app.inject({
method: 'DELETE',
url: `/api/oauth/connections/${account.id}`,
});
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toEqual({ success: true });
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toEqual([]);
});
});
+111
View File
@@ -0,0 +1,111 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import {
deleteOauthConnection,
getOauthSessionStatus,
handleOauthCallback,
listOauthConnections,
listOauthProviders,
startOauthProviderFlow,
startOauthRebindFlow,
} from '../../services/oauth/service.js';
function resolveRequestOrigin(request: FastifyRequest): string {
const forwardedProto = typeof request.headers['x-forwarded-proto'] === 'string'
? request.headers['x-forwarded-proto'].split(',')[0]?.trim()
: '';
const protocol = forwardedProto || request.protocol || 'http';
const forwardedHost = typeof request.headers['x-forwarded-host'] === 'string'
? request.headers['x-forwarded-host'].split(',')[0]?.trim()
: '';
const host = forwardedHost
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '')
|| 'localhost';
return `${protocol}://${host}`;
}
export async function oauthRoutes(app: FastifyInstance) {
app.get('/api/oauth/providers', async () => ({
providers: listOauthProviders(),
}));
app.post<{ Params: { provider: string }; Body: { accountId?: number } }>(
'/api/oauth/providers/:provider/start',
async (request, reply) => {
try {
return startOauthProviderFlow({
provider: request.params.provider,
redirectOrigin: resolveRequestOrigin(request),
rebindAccountId: request.body?.accountId,
});
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth provider not found' });
}
},
);
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('/api/oauth/connections', async () => ({
items: await listOauthConnections(),
}));
app.post<{ Params: { accountId: string } }>(
'/api/oauth/connections/:accountId/rebind',
async (request, reply) => {
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' });
}
},
);
app.delete<{ Params: { accountId: string } }>(
'/api/oauth/connections/:accountId',
async (request, reply) => {
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' });
}
},
);
app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>(
'/api/oauth/callback/:provider',
async (request, reply) => {
let message = 'OAuth callback received.';
try {
await handleOauthCallback({
provider: request.params.provider,
state: String(request.query.state || ''),
code: request.query.code,
error: request.query.error,
});
message = 'OAuth authorization succeeded. You can close this window.';
} catch (error: any) {
message = `OAuth authorization failed: ${error?.message || 'unknown error'}`;
}
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>`;
});
}
@@ -137,4 +137,57 @@ describe('search routes', () => {
],
});
});
it('includes oauth direct-account model availability in model search results', async () => {
const site = await db.insert(schema.sites).values({
name: 'codex site',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
extraConfig: mergeAccountExtraConfig(null, {
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'team',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/search',
payload: {
query: 'gpt-5.2',
limit: 20,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
models: [
expect.objectContaining({
name: 'gpt-5.2-codex',
accountCount: 1,
tokenCount: 0,
siteCount: 1,
}),
],
});
});
});
+30 -1
View File
@@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, like, desc, eq, or } from 'drizzle-orm';
import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import { getCredentialModeFromExtraConfig, supportsDirectAccountRoutingConnection } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
function hasSessionTokenValue(value: string | null | undefined): boolean {
@@ -141,6 +141,25 @@ export async function searchRoutes(app: FastifyInstance) {
)
.limit(perCategory * 20)
.all();
const directAccountModelRows = await db.select({
modelName: schema.modelAvailability.modelName,
accountId: schema.accounts.id,
siteId: schema.sites.id,
accounts: schema.accounts,
})
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
like(schema.modelAvailability.modelName, q),
eq(schema.modelAvailability.available, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.limit(perCategory * 20)
.all();
const modelAgg = new Map<string, { tokenIds: Set<number>; accountIds: Set<number>; siteIds: Set<number> }>();
for (const row of modelRows) {
@@ -153,6 +172,16 @@ export async function searchRoutes(app: FastifyInstance) {
agg.accountIds.add(row.accountId);
agg.siteIds.add(row.siteId);
}
for (const row of directAccountModelRows) {
if (!supportsDirectAccountRoutingConnection(row.accounts)) continue;
const key = row.modelName;
if (!modelAgg.has(key)) {
modelAgg.set(key, { tokenIds: new Set(), accountIds: new Set(), siteIds: new Set() });
}
const agg = modelAgg.get(key)!;
agg.accountIds.add(row.accountId);
agg.siteIds.add(row.siteId);
}
const models = Array.from(modelAgg.entries())
.map(([name, agg]) => ({
@@ -157,6 +157,52 @@ describe('/api/models/token-candidates', () => {
expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined();
});
it('does not report oauth direct connections as missing account tokens', async () => {
const site = await db.insert(schema.sites).values({
name: 'codex-site',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'team',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
modelsWithoutToken: Record<string, Array<{ accountId: number }>>;
modelsMissingTokenGroups: Record<string, Array<{ accountId: number }>>;
};
expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined();
expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined();
});
it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-b',
+5 -10
View File
@@ -17,7 +17,7 @@ import {
parseProxyLogBillingDetails,
withProxyLogSelectFields,
} from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
import {
formatLocalDateTime,
@@ -34,12 +34,6 @@ function parseBooleanFlag(raw?: string): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
function isApiKeyConnection(account: { accessToken?: string | null; extraConfig?: string | null }): boolean {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
@@ -1012,6 +1006,7 @@ export async function statsRoutes(app: FastifyInstance) {
siteId: schema.sites.id,
siteName: schema.sites.name,
accessToken: schema.accounts.accessToken,
apiToken: schema.accounts.apiToken,
extraConfig: schema.accounts.extraConfig,
})
.from(schema.modelAvailability)
@@ -1090,7 +1085,7 @@ export async function statsRoutes(app: FastifyInstance) {
}
for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
if (!requiresManagedAccountTokens(row)) continue;
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`;
@@ -1107,7 +1102,7 @@ export async function statsRoutes(app: FastifyInstance) {
const accountIdsForGroupHints = new Set(
availableModelRows
.filter((row) => !isApiKeyConnection(row))
.filter((row) => requiresManagedAccountTokens(row))
.map((row) => row.accountId),
);
const requiredGroupsByAccountModel = new Map<string, Map<string, string>>();
@@ -1169,7 +1164,7 @@ export async function statsRoutes(app: FastifyInstance) {
}
for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
if (!requiresManagedAccountTokens(row)) continue;
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`;