feat: add multi-provider OAuth support for Claude and Gemini CLI

This commit is contained in:
ciat-777
2026-03-17 13:36:44 +08:00
parent 4e3dc92083
commit d8e44eb805
53 changed files with 3618 additions and 719 deletions
@@ -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({
+3 -3
View File
@@ -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);
});
});
+62 -8
View File
@@ -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
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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);
},
);
}
+7 -1
View File
@@ -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;
@@ -0,0 +1,179 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
}),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
},
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/alertService.js', () => ({
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
}));
vi.mock('../../services/alertRules.js', () => ({
isTokenExpiredError: ({ status }: { status?: number }) => status === 401,
}));
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: async () => 0,
buildProxyBillingDetails: async () => null,
fetchModelPricingCatalog: async () => null,
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
}));
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
}));
describe('chat proxy codex oauth compatibility', () => {
let app: FastifyInstance;
const createSseResponse = (chunks: string[], status = 200) => {
const encoder = new TextEncoder();
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}), {
status,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
});
};
beforeAll(async () => {
const { claudeMessagesProxyRoute } = await import('./chat.js');
app = Fastify();
await app.register(claudeMessagesProxyRoute);
});
beforeEach(() => {
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' },
account: {
id: 33,
username: 'codex-user@example.com',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'team',
},
}),
},
tokenName: 'default',
tokenValue: 'oauth-access-token',
actualModel: 'gpt-5.4',
});
selectNextChannelMock.mockReturnValue(null);
});
afterAll(async () => {
await app.close();
});
it('strips codex-unsupported responses fields and aggregates the SSE result for /v1/messages callers', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_claude","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_claude","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_claude","delta":"pong from codex"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_claude","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
'data: [DONE]\n\n',
]));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'gpt-5.4',
max_tokens: 256,
messages: [{ role: 'user', content: 'hello codex' }],
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.store).toBe(false);
expect(forwardedBody.parallel_tool_calls).toBe(true);
expect(forwardedBody.include).toEqual(['reasoning.encrypted_content']);
expect(forwardedBody.max_output_tokens).toBeUndefined();
expect(forwardedBody.max_completion_tokens).toBeUndefined();
const body = response.json();
expect(body.type).toBe('message');
expect(body.role).toBe('assistant');
expect(body.model).toBe('gpt-5.4');
expect(body.content?.[0]?.type).toBe('text');
expect(body.content?.[0]?.text).toContain('pong from codex');
});
});
+79 -48
View File
@@ -33,9 +33,15 @@ import {
resolveOpenAiBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
buildCodexOauthProviderHeaders,
refreshCodexOauthAccessToken,
buildOauthProviderHeaders,
} from '../../services/oauth/service.js';
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
import {
createGeminiCliStreamReader,
unwrapGeminiCliPayload,
} from './geminiCliCompat.js';
const MAX_RETRIES = 2;
@@ -120,6 +126,8 @@ async function handleChatProxyRequest(
excludeChannelIds.push(selected.channel.id);
const modelName = selected.actualModel || requestedModel;
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
const endpointCandidates = [
...await resolveUpstreamEndpointCandidates(
{
@@ -135,25 +143,26 @@ async function handleChatProxyRequest(
),
];
const buildProviderHeaders = () => (
String(selected.site.platform || '').trim().toLowerCase() === 'codex'
? buildCodexOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
})
: {}
buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
})
);
const buildEndpointRequest = (
endpoint: 'chat' | 'messages' | 'responses',
options: { forceNormalizeClaudeBody?: boolean } = {},
) => {
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName,
stream: isStream,
tokenValue: selected.tokenValue,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: resolvedOpenAiBody,
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName,
stream: upstreamStream,
tokenValue: selected.tokenValue,
oauthProvider: oauth?.provider,
oauthProjectId: oauth?.projectId,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: resolvedOpenAiBody,
downstreamFormat,
claudeOriginalBody,
forceNormalizeClaudeBody: options.forceNormalizeClaudeBody,
@@ -173,7 +182,7 @@ async function handleChatProxyRequest(
modelName,
requestedModelHint: requestedModel,
sitePlatform: selected.site.platform,
isStream,
isStream: isStream || isCodexSite,
buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
endpoint,
{ forceNormalizeClaudeBody },
@@ -188,33 +197,37 @@ async function handleChatProxyRequest(
),
});
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
if (ctx.response.status === 401 && String(selected.site.platform || '').trim().toLowerCase() === 'codex') {
const refreshed = await refreshCodexOauthAccessToken(selected.account.id);
selected.tokenValue = refreshed.accessToken;
selected.account = {
...selected.account,
accessToken: refreshed.accessToken,
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
const refreshedResponse = await fetch(
refreshedTargetUrl,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: refreshedRequest.headers,
body: JSON.stringify(refreshedRequest.body),
}),
);
if (refreshedResponse.ok) {
return {
upstream: refreshedResponse,
upstreamPath: refreshedRequest.path,
if (ctx.response.status === 401 && oauth) {
try {
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
selected.tokenValue = refreshed.accessToken;
selected.account = {
...selected.account,
accessToken: refreshed.accessToken,
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
const refreshedResponse = await fetch(
refreshedTargetUrl,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: refreshedRequest.headers,
body: JSON.stringify(refreshedRequest.body),
}),
);
if (refreshedResponse.ok) {
return {
upstream: refreshedResponse,
upstreamPath: refreshedRequest.path,
};
}
ctx.request = refreshedRequest;
ctx.response = refreshedResponse;
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
} catch {
return endpointStrategy.tryRecover(ctx);
}
ctx.request = refreshedRequest;
ctx.response = refreshedResponse;
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
}
return endpointStrategy.tryRecover(ctx);
};
@@ -345,6 +358,9 @@ async function handleChatProxyRequest(
} catch {
fallbackData = fallbackText;
}
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
fallbackData = unwrapGeminiCliPayload(fallbackData);
}
streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
const latency = Date.now() - startTime;
@@ -395,7 +411,10 @@ async function handleChatProxyRequest(
return;
}
const reader = upstream.body?.getReader();
const upstreamReader = upstream.body?.getReader();
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
await streamSession.run(reader, reply.raw);
const latency = Date.now() - startTime;
@@ -446,12 +465,24 @@ async function handleChatProxyRequest(
return;
}
const rawText = await upstream.text();
let upstreamData: unknown = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
let rawText = '';
let upstreamData: unknown;
if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) {
const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName);
rawText = collected.rawText;
upstreamData = collected.payload;
} else {
rawText = await upstream.text();
upstreamData = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {
upstreamData = rawText;
}
}
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
upstreamData = unwrapGeminiCliPayload(upstreamData);
}
const latency = Date.now() - startTime;
+135
View File
@@ -210,6 +210,52 @@ describe('gemini native proxy routes', () => {
expect(secondUrl).toContain('key=gemini-key-2');
});
it('serves gemini-cli model list from local static catalog without upstream fetch', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 21, routeId: 22 },
site: { id: 55, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
account: {
id: 35,
username: 'gemini-cli-user@example.com',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'gemini-cli',
email: 'gemini-cli-user@example.com',
projectId: 'project-demo',
},
}),
},
tokenName: 'default',
tokenValue: 'oauth-access-token',
actualModel: 'gemini-2.5-pro',
});
explainSelectionMock.mockImplementation(async (modelName: string) => (
modelName === 'gemini-2.5-pro'
? { selectedChannelId: 21 }
: { selectedChannelId: undefined }
));
const response = await app.inject({
method: 'GET',
url: '/v1beta/models',
headers: {
authorization: 'Bearer sk-managed-gemini',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).not.toHaveBeenCalled();
expect(response.json()).toMatchObject({
models: expect.arrayContaining([
{
name: 'models/gemini-2.5-pro',
displayName: 'Gemini 2.5 Pro',
},
]),
});
});
it('forwards native generateContent requests through the gemini route group', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
candidates: [
@@ -270,6 +316,95 @@ describe('gemini native proxy routes', () => {
});
});
it('wraps gemini-cli native generateContent requests and unwraps the response payload', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 31, routeId: 22 },
site: { id: 66, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
account: {
id: 36,
username: 'gemini-cli-user@example.com',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'gemini-cli',
email: 'gemini-cli-user@example.com',
projectId: 'project-demo',
},
}),
},
tokenName: 'default',
tokenValue: 'oauth-access-token',
actualModel: 'gemini-2.5-pro',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
response: {
candidates: [
{
content: {
parts: [{ text: 'hello from gemini cli' }],
role: 'model',
},
finishReason: 'STOP',
},
],
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1beta/models/gemini-2.5-pro:generateContent',
headers: {
authorization: 'Bearer sk-managed-gemini',
},
payload: {
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
},
});
expect(response.statusCode).toBe(200);
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(targetUrl).toBe('https://cloudcode-pa.googleapis.com/v1internal:generateContent');
expect(requestInit.headers).toMatchObject({
Authorization: 'Bearer oauth-access-token',
});
expect((requestInit.headers as Record<string, string>)['User-Agent']).toContain('GeminiCLI/');
expect((requestInit.headers as Record<string, string>)['X-Goog-Api-Client']).toContain('google-genai-sdk/');
expect(JSON.parse(String(requestInit.body))).toEqual({
project: 'project-demo',
model: 'gemini-2.5-pro',
request: {
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
},
});
expect(response.json()).toEqual({
responseId: '',
modelVersion: '',
candidates: [
{
index: 0,
content: {
parts: [{ text: 'hello from gemini cli' }],
role: 'model',
},
finishReason: 'STOP',
},
],
});
});
it('filters Gemini native model list by downstream managed-key policy', async () => {
authorizeDownstreamTokenMock.mockResolvedValue({
ok: true,
+104 -19
View File
@@ -6,11 +6,18 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import {
geminiGenerateContentTransformer,
} from '../../transformers/gemini/generate-content/index.js';
import {
createGeminiCliStreamReader,
unwrapGeminiCliPayload,
wrapGeminiCliRequest,
} from './geminiCliCompat.js';
const MAX_RETRIES = 2;
const GEMINI_MODEL_PROBES = [
@@ -19,12 +26,34 @@ const GEMINI_MODEL_PROBES = [
'gemini-1.5-flash',
'gemini-pro',
];
const GEMINI_CLI_STATIC_MODELS = [
{ name: 'models/gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' },
{ name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' },
{ name: 'models/gemini-2.5-flash-lite', displayName: 'Gemini 2.5 Flash Lite' },
{ name: 'models/gemini-3-pro-preview', displayName: 'Gemini 3 Pro Preview' },
{ name: 'models/gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' },
{ name: 'models/gemini-3-flash-preview', displayName: 'Gemini 3 Flash Preview' },
{ name: 'models/gemini-3.1-flash-lite-preview', displayName: 'Gemini 3.1 Flash Lite Preview' },
];
const EMPTY_PROXY_USAGE = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
};
function isGeminiCliPlatform(platform: unknown): boolean {
return String(platform || '').trim().toLowerCase() === 'gemini-cli';
}
function buildGeminiCliActionPath(input: {
isStreamAction: boolean;
isCountTokensAction: boolean;
}) {
if (input.isCountTokensAction) return '/v1internal:countTokens';
if (input.isStreamAction) return '/v1internal:streamGenerateContent?alt=sse';
return '/v1internal:generateContent';
}
async function selectGeminiChannel(request: FastifyRequest) {
const policy = getDownstreamRoutingPolicy(request);
for (const candidate of GEMINI_MODEL_PROBES) {
@@ -165,6 +194,14 @@ export async function geminiProxyRoute(app: FastifyInstance) {
excludeChannelIds.push(selected.channel.id);
try {
if (isGeminiCliPlatform(selected.site.platform)) {
const filtered = await filterGeminiListedModelsForPolicy(
{ models: GEMINI_CLI_STATIC_MODELS },
request,
);
return reply.code(200).send(filtered);
}
const upstream = await fetch(
geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue),
{ method: 'GET' },
@@ -212,6 +249,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
params: request.params as { geminiApiVersion?: string } | undefined,
});
const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath;
const isCountTokensAction = modelActionPath.endsWith(':countTokens');
if (!requestedModel) {
return reply.code(400).send({
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
@@ -240,31 +278,72 @@ export async function geminiProxyRoute(app: FastifyInstance) {
request.body || {},
selected.actualModel || requestedModel,
);
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
const isGeminiCli = isGeminiCliPlatform(selected.site.platform);
if (isGeminiCli && !oauth?.projectId) {
lastStatus = 500;
lastContentType = 'application/json';
lastText = JSON.stringify({
error: {
message: 'Gemini CLI OAuth project is missing',
type: 'server_error',
},
});
await tokenRouter.recordFailure?.(selected.channel.id);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
return reply.code(lastStatus).type(lastContentType).send(lastText);
}
const actualModelAction = modelActionPath.replace(
/^models\/[^:]+/,
`models/${selected.actualModel || requestedModel}`,
);
const upstreamPath = resolveUpstreamPath(apiVersion, actualModelAction);
const upstreamPath = isGeminiCli
? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction })
: resolveUpstreamPath(apiVersion, actualModelAction);
const query = new URLSearchParams(request.query as Record<string, string>).toString();
const requestBody = isGeminiCli
? (
isCountTokensAction
? { request: body }
: wrapGeminiCliRequest({
modelName: selected.actualModel || requestedModel,
projectId: oauth?.projectId || '',
request: body as Record<string, unknown>,
})
)
: body;
const requestHeaders = isGeminiCli
? {
'Content-Type': 'application/json',
...(isStreamAction ? { Accept: 'text/event-stream' } : {}),
Authorization: `Bearer ${selected.tokenValue}`,
...buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
}),
}
: {
'Content-Type': 'application/json',
};
const startTime = Date.now();
try {
const upstream = await fetch(
geminiGenerateContentTransformer.resolveActionUrl(
const targetUrl = isGeminiCli
? `${selected.site.url}${upstreamPath}`
: geminiGenerateContentTransformer.resolveActionUrl(
selected.site.url,
apiVersion,
actualModelAction,
selected.tokenValue,
query,
),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
},
);
);
const upstream = await fetch(targetUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(requestBody),
});
const contentType = upstream.headers.get('content-type') || 'application/json';
if (!upstream.ok) {
lastStatus = upstream.status;
@@ -298,7 +377,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
reply.hijack();
reply.raw.statusCode = upstream.status;
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
const reader = upstream.body?.getReader();
const upstreamReader = upstream.body?.getReader();
const reader = isGeminiCliPlatform(selected.site.platform) && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
if (!reader) {
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
@@ -373,11 +455,14 @@ export async function geminiProxyRoute(app: FastifyInstance) {
let parsedUsage = EMPTY_PROXY_USAGE;
try {
const parsed = JSON.parse(text);
const responsePayload = geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
aggregateState,
parsed,
isStreamAction,
);
const unwrappedPayload = unwrapGeminiCliPayload(parsed);
const responsePayload = isCountTokensAction
? unwrappedPayload
: geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
aggregateState,
unwrappedPayload,
isStreamAction,
);
parsedUsage = parseProxyUsage(aggregateState);
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
+346
View File
@@ -0,0 +1,346 @@
import { TextDecoder, TextEncoder } from 'node:util';
import { resolveGeminiThinkingConfigFromRequest } from '../../transformers/gemini/generate-content/convert.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function parseDataUrl(url: string): { mimeType: string; data: string } | null {
if (!url.startsWith('data:')) return null;
const [, rest] = url.split('data:', 2);
const [meta, data] = rest.split(',', 2);
if (!meta || !data) return null;
const [mimeType] = meta.split(';', 1);
return {
mimeType: mimeType || 'application/octet-stream',
data,
};
}
function normalizeFunctionResponseResult(value: unknown): unknown {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
if (!trimmed) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function convertContentToGeminiParts(content: unknown): Array<Record<string, unknown>> {
if (typeof content === 'string') {
const trimmed = content.trim();
return trimmed ? [{ text: trimmed }] : [];
}
if (isRecord(content)) {
if (typeof content.text === 'string') {
const trimmed = content.text.trim();
return trimmed ? [{ text: trimmed }] : [];
}
return [];
}
if (!Array.isArray(content)) return [];
const parts: Array<Record<string, unknown>> = [];
for (const item of content) {
if (!isRecord(item)) continue;
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'text') {
const text = asTrimmedString(item.text);
if (text) parts.push({ text });
continue;
}
if (type === 'image_url') {
const imageUrl = asTrimmedString(item.image_url && isRecord(item.image_url) ? item.image_url.url : item.url);
const parsed = imageUrl ? parseDataUrl(imageUrl) : null;
if (parsed) {
parts.push({
inlineData: {
mime_type: parsed.mimeType,
data: parsed.data,
},
});
}
continue;
}
if (type === 'input_audio') {
const data = asTrimmedString(item.data);
if (data) {
parts.push({
inlineData: {
mime_type: 'audio/wav',
data,
},
});
}
}
}
return parts;
}
function buildGeminiTools(tools: unknown): Array<Record<string, unknown>> | undefined {
if (!Array.isArray(tools)) return undefined;
const declarations = tools
.filter((item) => isRecord(item))
.flatMap((item) => {
if (asTrimmedString(item.type) !== 'function' || !isRecord(item.function)) return [];
const fn = item.function as Record<string, unknown>;
const name = asTrimmedString(fn.name);
if (!name) return [];
return [{
name,
...(asTrimmedString(fn.description) ? { description: asTrimmedString(fn.description) } : {}),
parametersJsonSchema: isRecord(fn.parameters) ? fn.parameters : { type: 'object', properties: {} },
}];
});
if (declarations.length <= 0) return undefined;
return [{ functionDeclarations: declarations }];
}
function buildGeminiToolConfig(toolChoice: unknown): Record<string, unknown> | undefined {
if (typeof toolChoice === 'string') {
const normalized = toolChoice.trim().toLowerCase();
if (normalized === 'none') {
return { functionCallingConfig: { mode: 'NONE' } };
}
if (normalized === 'required') {
return { functionCallingConfig: { mode: 'ANY' } };
}
return { functionCallingConfig: { mode: 'AUTO' } };
}
return undefined;
}
export function buildGeminiGenerateContentRequestFromOpenAi(input: {
body: Record<string, unknown>;
modelName: string;
instructions?: string;
}) {
const request: Record<string, unknown> = {
contents: [],
};
const messages = Array.isArray(input.body.messages) ? input.body.messages : [];
const toolNameById = new Map<string, string>();
for (const message of messages) {
if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue;
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
for (const toolCall of toolCalls) {
if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue;
const id = asTrimmedString(toolCall.id);
const name = asTrimmedString(toolCall.function.name);
if (id && name) {
toolNameById.set(id, name);
}
}
}
const systemParts: Array<Record<string, unknown>> = [];
if (typeof input.instructions === 'string' && input.instructions.trim()) {
systemParts.push({ text: input.instructions.trim() });
}
for (const message of messages) {
if (!isRecord(message)) continue;
const role = asTrimmedString(message.role).toLowerCase();
if (role === 'system' || role === 'developer') {
systemParts.push(...convertContentToGeminiParts(message.content));
continue;
}
if (role === 'tool') {
const toolCallId = asTrimmedString(message.tool_call_id);
const name = toolNameById.get(toolCallId) || 'unknown';
const result = normalizeFunctionResponseResult(message.content);
request.contents = [
...(Array.isArray(request.contents) ? request.contents : []),
{
role: 'user',
parts: [{
functionResponse: {
name,
response: {
result,
},
},
}],
},
];
continue;
}
const parts = convertContentToGeminiParts(message.content);
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
for (const toolCall of toolCalls) {
if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue;
const name = asTrimmedString(toolCall.function.name);
if (!name) continue;
const rawArguments = toolCall.function.arguments;
let args: unknown = {};
if (typeof rawArguments === 'string' && rawArguments.trim()) {
try {
args = JSON.parse(rawArguments);
} catch {
args = { raw: rawArguments };
}
} else if (isRecord(rawArguments)) {
args = rawArguments;
}
parts.push({
functionCall: {
name,
args,
},
});
}
if (parts.length <= 0) continue;
request.contents = [
...(Array.isArray(request.contents) ? request.contents : []),
{
role: role === 'assistant' ? 'model' : 'user',
parts,
},
];
}
if (systemParts.length > 0) {
request.systemInstruction = {
role: 'user',
parts: systemParts,
};
}
const generationConfig: Record<string, unknown> = {};
const maxOutputTokens = Number(
input.body.max_output_tokens
?? input.body.max_completion_tokens
?? input.body.max_tokens
?? 0,
);
if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) {
generationConfig.maxOutputTokens = Math.trunc(maxOutputTokens);
}
const temperature = Number(input.body.temperature);
if (Number.isFinite(temperature)) generationConfig.temperature = temperature;
const topP = Number(input.body.top_p);
if (Number.isFinite(topP)) generationConfig.topP = topP;
const topK = Number(input.body.top_k);
if (Number.isFinite(topK)) generationConfig.topK = topK;
if (Array.isArray(input.body.stop) && input.body.stop.length > 0) {
generationConfig.stopSequences = input.body.stop.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
}
const thinkingConfig = resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
if (thinkingConfig) {
generationConfig.thinkingConfig = thinkingConfig;
}
if (Object.keys(generationConfig).length > 0) {
request.generationConfig = generationConfig;
}
const geminiTools = buildGeminiTools(input.body.tools);
if (geminiTools) {
request.tools = geminiTools;
}
const toolConfig = buildGeminiToolConfig(input.body.tool_choice);
if (toolConfig) {
request.toolConfig = toolConfig;
}
return request;
}
export function wrapGeminiCliRequest(input: {
modelName: string;
projectId: string;
request: Record<string, unknown>;
}) {
const { model, ...requestPayload } = input.request;
return {
project: input.projectId,
model: input.modelName,
request: requestPayload,
};
}
export function unwrapGeminiCliPayload<T>(payload: T): unknown {
if (!isRecord(payload)) return payload;
if (payload.response !== undefined) {
return payload.response;
}
return payload;
}
function rewriteGeminiCliSseEventBlock(block: string): string {
const lines = block.split(/\r?\n/g);
return lines.map((line) => {
if (!line.startsWith('data:')) return line;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') return line;
try {
const parsed = JSON.parse(data);
return `data: ${JSON.stringify(unwrapGeminiCliPayload(parsed))}`;
} catch {
return line;
}
}).join('\n');
}
export function createGeminiCliStreamReader(reader: {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(reason?: unknown): Promise<unknown>;
releaseLock(): void;
}) {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const outputQueue: Uint8Array[] = [];
let buffer = '';
let done = false;
async function fillQueue() {
while (outputQueue.length <= 0 && !done) {
const result = await reader.read();
if (result.done) {
done = true;
const tail = decoder.decode();
if (tail) buffer += tail;
if (buffer.trim()) {
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(buffer)}\n\n`));
buffer = '';
}
break;
}
if (!result.value) continue;
buffer += decoder.decode(result.value, { stream: true });
let separatorIndex = buffer.indexOf('\n\n');
while (separatorIndex >= 0) {
const block = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(block)}\n\n`));
separatorIndex = buffer.indexOf('\n\n');
}
}
}
return {
async read() {
await fillQueue();
if (outputQueue.length > 0) {
return { done: false, value: outputQueue.shift() };
}
return { done: true, value: undefined };
},
cancel(reason?: unknown) {
return reader.cancel(reason);
},
releaseLock() {
reader.releaseLock();
},
};
}
@@ -14,7 +14,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const refreshCodexAccessTokenMock = vi.fn();
const refreshOauthAccessTokenSingleflightMock = vi.fn();
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
@@ -61,13 +61,9 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../services/oauth/service.js', async () => {
const actual = await vi.importActual<typeof import('../../services/oauth/service.js')>('../../services/oauth/service.js');
return {
...actual,
refreshCodexOauthAccessToken: (...args: unknown[]) => refreshCodexAccessTokenMock(...args),
};
});
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
}));
vi.mock('../../db/index.js', () => ({
db: {
@@ -82,6 +78,21 @@ vi.mock('../../db/index.js', () => ({
describe('responses proxy codex oauth refresh', () => {
let app: FastifyInstance;
const createSseResponse = (chunks: string[], status = 200) => {
const encoder = new TextEncoder();
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}), {
status,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
});
};
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
app = Fastify();
@@ -98,7 +109,7 @@ describe('responses proxy codex oauth refresh', () => {
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
refreshCodexAccessTokenMock.mockReset();
refreshOauthAccessTokenSingleflightMock.mockReset();
dbInsertMock.mockClear();
selectChannelMock.mockReturnValue({
@@ -122,7 +133,7 @@ describe('responses proxy codex oauth refresh', () => {
actualModel: 'gpt-5.2-codex',
});
selectNextChannelMock.mockReturnValue(null);
refreshCodexAccessTokenMock.mockResolvedValue({
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
accessToken: 'fresh-access-token',
accountId: 33,
accountKey: 'chatgpt-account-123',
@@ -163,7 +174,7 @@ describe('responses proxy codex oauth refresh', () => {
});
expect(response.statusCode).toBe(200);
expect(refreshCodexAccessTokenMock).toHaveBeenCalledWith(33);
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
@@ -205,6 +216,7 @@ describe('responses proxy codex oauth refresh', () => {
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.input).toEqual([
{
type: 'message',
@@ -213,4 +225,82 @@ describe('responses proxy codex oauth refresh', () => {
},
]);
});
it('forces codex upstream responses requests to stream and aggregates the SSE payload for non-stream downstream callers', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_stream","delta":"pong"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello codex',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.store).toBe(false);
expect(response.json()).toMatchObject({
id: 'resp_codex_stream',
model: 'gpt-5.4',
status: 'completed',
output_text: 'pong',
usage: {
input_tokens: 3,
output_tokens: 1,
total_tokens: 4,
},
});
});
it('preserves codex-required instructions and store fields across responses compatibility retries', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: { message: 'upstream_error', type: 'upstream_error' },
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.2-codex',
input: 'hello codex',
metadata: { trace: 'compatibility-retry' },
},
});
expect(response.statusCode).toBe(400);
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2);
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
const firstBody = JSON.parse(firstOptions.body);
const secondBody = JSON.parse(secondOptions.body);
expect(firstBody.instructions).toBe('');
expect(firstBody.store).toBe(false);
expect(firstBody.stream).toBe(true);
expect(secondBody.instructions).toBe('');
expect(secondBody.store).toBe(false);
expect(secondBody.stream).toBe(true);
});
});
+71 -43
View File
@@ -28,9 +28,15 @@ import {
resolveResponsesBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
buildCodexOauthProviderHeaders,
refreshCodexOauthAccessToken,
buildOauthProviderHeaders,
} from '../../services/oauth/service.js';
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
import {
createGeminiCliStreamReader,
unwrapGeminiCliPayload,
} from './geminiCliCompat.js';
const MAX_RETRIES = 2;
@@ -180,6 +186,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
excludeChannelIds.push(selected.channel.id);
const modelName = selected.actualModel || requestedModel;
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
const owner = getProxyResourceOwner(request);
let normalizedResponsesBody: Record<string, unknown> = {
...requestEnvelope.parsed.normalizedBody,
@@ -233,19 +241,20 @@ export async function responsesProxyRoute(app: FastifyInstance) {
endpointCandidates.push('responses', 'chat', 'messages');
}
const buildProviderHeaders = () => (
String(selected.site.platform || '').trim().toLowerCase() === 'codex'
? buildCodexOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
})
: {}
buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
})
);
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName,
stream: isStream,
stream: upstreamStream,
tokenValue: selected.tokenValue,
oauthProvider: oauth?.provider,
oauthProjectId: oauth?.projectId,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: openAiBody,
@@ -267,7 +276,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
};
};
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
isStream,
isStream: isStream || isCodexSite,
requiresNativeResponsesFileUrl,
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
@@ -279,33 +288,37 @@ export async function responsesProxyRoute(app: FastifyInstance) {
),
});
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
if (ctx.response.status === 401 && String(selected.site.platform || '').trim().toLowerCase() === 'codex') {
const refreshed = await refreshCodexOauthAccessToken(selected.account.id);
selected.tokenValue = refreshed.accessToken;
selected.account = {
...selected.account,
accessToken: refreshed.accessToken,
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
const refreshedResponse = await fetch(
refreshedTargetUrl,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: refreshedRequest.headers,
body: JSON.stringify(refreshedRequest.body),
}),
);
if (refreshedResponse.ok) {
return {
upstream: refreshedResponse,
upstreamPath: refreshedRequest.path,
if (ctx.response.status === 401 && oauth) {
try {
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
selected.tokenValue = refreshed.accessToken;
selected.account = {
...selected.account,
accessToken: refreshed.accessToken,
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
const refreshedResponse = await fetch(
refreshedTargetUrl,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: refreshedRequest.headers,
body: JSON.stringify(refreshedRequest.body),
}),
);
if (refreshedResponse.ok) {
return {
upstream: refreshedResponse,
upstreamPath: refreshedRequest.path,
};
}
ctx.request = refreshedRequest;
ctx.response = refreshedResponse;
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
} catch {
return endpointStrategy.tryRecover(ctx);
}
ctx.request = refreshedRequest;
ctx.response = refreshedResponse;
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
}
return endpointStrategy.tryRecover(ctx);
};
@@ -386,8 +399,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } });
}
const upstream = endpointResult.upstream;
const successfulUpstreamPath = endpointResult.upstreamPath;
const upstream = endpointResult.upstream;
const successfulUpstreamPath = endpointResult.upstreamPath;
if (isStream) {
reply.hijack();
@@ -397,7 +410,10 @@ export async function responsesProxyRoute(app: FastifyInstance) {
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
const reader = upstream.body?.getReader();
const upstreamReader = upstream.body?.getReader();
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
let parsedUsage: UsageSummary = {
promptTokens: 0,
completionTokens: 0,
@@ -460,12 +476,24 @@ export async function responsesProxyRoute(app: FastifyInstance) {
return;
}
const rawText = await upstream.text();
let upstreamData: unknown = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
let rawText = '';
let upstreamData: unknown;
if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) {
const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName);
rawText = collected.rawText;
upstreamData = collected.payload;
} else {
rawText = await upstream.text();
upstreamData = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {
upstreamData = rawText;
}
}
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
upstreamData = unwrapGeminiCliPayload(upstreamData);
}
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(upstreamData);
@@ -0,0 +1,97 @@
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function parseResponsesSsePayload(data: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(data);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function getResponsesFailureMessage(payload: Record<string, unknown>): string {
if (isRecord(payload.error) && typeof payload.error.message === 'string' && payload.error.message.trim()) {
return payload.error.message.trim();
}
if (typeof payload.message === 'string' && payload.message.trim()) {
return payload.message.trim();
}
return 'upstream stream failed';
}
export async function collectResponsesFinalPayloadFromSse(
upstream: { text(): Promise<string> },
modelName: string,
): Promise<{ payload: Record<string, unknown>; rawText: string }> {
const rawText = await upstream.text();
const { events } = openAiResponsesTransformer.pullSseEvents(rawText);
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
const aggregateState = openAiResponsesTransformer.aggregator.createState(modelName);
let usage = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null as boolean | null,
};
let completedPayload: Record<string, unknown> | null = null;
const captureCompletedPayload = (lines: string[]) => {
if (completedPayload) return;
const parsed = openAiResponsesTransformer.pullSseEvents(lines.join(''));
for (const event of parsed.events) {
if (event.data === '[DONE]') continue;
const payload = parseResponsesSsePayload(event.data);
if (!payload) continue;
if (payload.type === 'response.failed') {
throw new Error(getResponsesFailureMessage(payload));
}
if (payload.type === 'response.completed' && isRecord(payload.response)) {
completedPayload = payload.response;
return;
}
}
};
for (const event of events) {
if (event.data === '[DONE]') continue;
const payload = parseResponsesSsePayload(event.data);
if (!payload) continue;
if (payload.type === 'response.failed') {
throw new Error(getResponsesFailureMessage(payload));
}
usage = mergeProxyUsage(usage, parseProxyUsage(payload));
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(payload, streamContext, modelName);
captureCompletedPayload(openAiResponsesTransformer.aggregator.serialize({
state: aggregateState,
streamContext,
event: normalizedEvent,
usage,
}));
}
if (!completedPayload) {
captureCompletedPayload(openAiResponsesTransformer.aggregator.complete(
aggregateState,
streamContext,
usage,
));
}
if (completedPayload) {
return {
payload: completedPayload,
rawText,
};
}
throw new Error('stream disconnected before response.completed');
}
@@ -340,6 +340,10 @@ describe('buildUpstreamEndpointRequest', () => {
openaiBody: {
model: 'gpt-5.2-codex',
messages: [{ role: 'user', content: 'hello codex' }],
temperature: 0.2,
top_p: 0.9,
user: 'drop-me',
service_tier: 'auto',
},
downstreamFormat: 'openai',
providerHeaders: {
@@ -352,6 +356,125 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
expect(request.headers.Originator).toBe('codex_cli_rs');
expect(request.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123');
expect(request.body.instructions).toBe('');
expect(request.body.stream).toBe(true);
expect(request.body.store).toBe(false);
expect(request.body.parallel_tool_calls).toBe(true);
expect(request.body.include).toEqual(['reasoning.encrypted_content']);
expect(request.body.max_output_tokens).toBeUndefined();
expect(request.body.temperature).toBeUndefined();
expect(request.body.top_p).toBeUndefined();
expect(request.body.user).toBeUndefined();
expect(request.body.service_tier).toBeUndefined();
});
it('builds gemini-cli native requests with project envelope and bearer headers', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gemini-2.5-pro',
stream: true,
tokenValue: 'oauth-access-token',
oauthProvider: 'gemini-cli',
oauthProjectId: 'project-demo',
sitePlatform: 'gemini-cli',
siteUrl: 'https://cloudcode-pa.googleapis.com',
openaiBody: {
model: 'gemini-2.5-pro',
messages: [
{ role: 'system', content: 'be concise' },
{ role: 'user', content: 'hello gemini cli' },
],
temperature: 0.4,
},
downstreamFormat: 'openai',
providerHeaders: {
'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)',
'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0',
},
});
expect(request.path).toBe('/v1internal:streamGenerateContent?alt=sse');
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
expect(request.headers['User-Agent']).toContain('GeminiCLI/');
expect(request.headers['X-Goog-Api-Client']).toContain('google-genai-sdk/');
expect(request.body.project).toBe('project-demo');
expect(request.body.model).toBe('gemini-2.5-pro');
expect(request.body.request).toMatchObject({
generationConfig: {
temperature: 0.4,
},
systemInstruction: {
role: 'user',
},
contents: [
{
role: 'user',
parts: [{ text: 'hello gemini cli' }],
},
],
});
});
it('uses bearer auth for claude oauth upstream requests', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'messages',
modelName: 'claude-opus-4-6',
stream: false,
tokenValue: 'oauth-access-token',
oauthProvider: 'claude',
sitePlatform: 'claude',
siteUrl: 'https://api.anthropic.com',
openaiBody: {
model: 'claude-opus-4-6',
messages: [{ role: 'user', content: 'hello claude oauth' }],
},
downstreamFormat: 'openai',
});
expect(request.path).toBe('/v1/messages');
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
expect(request.headers['x-api-key']).toBeUndefined();
});
it('converts system roles to developer in native codex responses bodies', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {},
downstreamFormat: 'responses',
responsesOriginalBody: {
model: 'gpt-5.4',
input: [
{
type: 'message',
role: 'system',
content: [{ type: 'input_text', text: 'be careful' }],
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello' }],
},
],
},
});
expect(request.body.input).toEqual([
{
type: 'message',
role: 'developer',
content: [{ type: 'input_text', text: 'be careful' }],
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello' }],
},
]);
});
it('normalizes downstream responses input string before forwarding upstream', () => {
+124 -4
View File
@@ -8,6 +8,10 @@ import {
convertOpenAiBodyToAnthropicMessagesBody,
sanitizeAnthropicMessagesBody,
} from '../../transformers/anthropic/messages/conversion.js';
import {
buildGeminiGenerateContentRequestFromOpenAi,
wrapGeminiCliRequest,
} from './geminiCliCompat.js';
export {
buildMinimalJsonHeadersForCompatibility,
isEndpointDispatchDeniedError,
@@ -198,6 +202,72 @@ function ensureCodexResponsesInstructions(
};
}
function ensureCodexResponsesStoreFalse(
body: Record<string, unknown>,
sitePlatform: string,
): Record<string, unknown> {
if (sitePlatform !== 'codex') return body;
if (body.store === false) return body;
return {
...body,
store: false,
};
}
function convertCodexSystemRoleToDeveloper(input: unknown): unknown {
if (!Array.isArray(input)) return input;
return input.map((item) => {
if (!isRecord(item)) return item;
if (asTrimmedString(item.type).toLowerCase() !== 'message') return item;
if (asTrimmedString(item.role).toLowerCase() !== 'system') return item;
return {
...item,
role: 'developer',
};
});
}
function applyCodexResponsesCompatibility(
body: Record<string, unknown>,
sitePlatform: string,
): Record<string, unknown> {
if (sitePlatform !== 'codex') return body;
const next: Record<string, unknown> = {
...body,
stream: true,
store: false,
parallel_tool_calls: true,
include: ['reasoning.encrypted_content'],
input: convertCodexSystemRoleToDeveloper(body.input),
};
if (typeof next.instructions !== 'string') {
next.instructions = '';
}
for (const key of [
'max_output_tokens',
'max_completion_tokens',
'temperature',
'top_p',
'truncation',
'user',
'context_management',
'previous_response_id',
'prompt_cache_retention',
'safety_identifier',
]) {
delete next[key];
}
if (asTrimmedString(next.service_tier).toLowerCase() !== 'priority') {
delete next.service_tier;
}
return next;
}
function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
const raw = asTrimmedString(value).toLowerCase();
@@ -258,6 +328,10 @@ function preferredEndpointOrder(
return ['chat'];
}
if (platform === 'gemini-cli') {
return ['chat'];
}
if (platform === 'openai') {
if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') {
// Some OpenAI-compatible gateways expose Claude natively via /v1/messages.
@@ -438,6 +512,8 @@ export function buildUpstreamEndpointRequest(input: {
modelName: string;
stream: boolean;
tokenValue: string;
oauthProvider?: string;
oauthProjectId?: string;
sitePlatform?: string;
siteUrl?: string;
openaiBody: Record<string, unknown>;
@@ -455,6 +531,8 @@ export function buildUpstreamEndpointRequest(input: {
const sitePlatform = normalizePlatformName(input.sitePlatform);
const isClaudeUpstream = sitePlatform === 'claude';
const isGeminiUpstream = sitePlatform === 'gemini';
const isGeminiCliUpstream = sitePlatform === 'gemini-cli';
const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude';
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase();
@@ -484,6 +562,12 @@ export function buildUpstreamEndpointRequest(input: {
return '/responses';
}
if (sitePlatform === 'gemini-cli') {
return input.stream
? '/v1internal:streamGenerateContent?alt=sse'
: '/v1internal:generateContent';
}
if (sitePlatform === 'claude') {
return '/v1/messages';
}
@@ -505,7 +589,7 @@ export function buildUpstreamEndpointRequest(input: {
const stripGeminiUnsupportedFields = (body: Record<string, unknown>) => {
const next = { ...body };
if (isGeminiUpstream) {
if (isGeminiUpstream || isGeminiCliUpstream) {
for (const key of [
'frequency_penalty',
'presence_penalty',
@@ -522,6 +606,34 @@ export function buildUpstreamEndpointRequest(input: {
const openaiBody = stripGeminiUnsupportedFields(input.openaiBody);
if (isGeminiCliUpstream) {
const projectId = asTrimmedString(input.oauthProjectId);
if (!projectId) {
throw new Error('gemini-cli oauth project id missing');
}
const instructions = (
input.downstreamFormat === 'responses'
&& typeof input.responsesOriginalBody?.instructions === 'string'
)
? input.responsesOriginalBody.instructions
: undefined;
const geminiRequest = buildGeminiGenerateContentRequestFromOpenAi({
body: openaiBody,
modelName: input.modelName,
instructions,
});
const headers = ensureStreamAcceptHeader(commonHeaders, input.stream);
return {
path: resolveEndpointPath(input.endpoint),
headers,
body: wrapGeminiCliRequest({
modelName: input.modelName,
projectId,
request: geminiRequest,
}) as Record<string, unknown>,
};
}
if (input.endpoint === 'messages') {
const claudeHeaders = input.downstreamFormat === 'claude'
? extractClaudePassthroughHeaders(input.downstreamHeaders)
@@ -562,8 +674,10 @@ export function buildUpstreamEndpointRequest(input: {
const headers = ensureStreamAcceptHeader({
...commonHeaders,
...claudeHeaders,
'x-api-key': input.tokenValue,
'anthropic-version': anthropicVersion,
...(isClaudeOauthUpstream
? { Authorization: `Bearer ${input.tokenValue}` }
: { 'x-api-key': input.tokenValue }),
}, input.stream);
return {
@@ -586,8 +700,14 @@ export function buildUpstreamEndpointRequest(input: {
}
: convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream)
);
const body = ensureCodexResponsesInstructions(
sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream),
const body = ensureCodexResponsesStoreFalse(
ensureCodexResponsesInstructions(
applyCodexResponsesCompatibility(
sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream),
sitePlatform,
),
sitePlatform,
),
sitePlatform,
);