feat: expand gemini proxy compatibility matrix

This commit is contained in:
ciat-777
2026-03-17 21:22:23 +08:00
parent 07351b527f
commit 2e34f9033d
17 changed files with 1784 additions and 222 deletions
+42
View File
@@ -83,10 +83,52 @@ describe('oauth routes', { timeout: 15_000 }, () => {
loginType: 'oauth',
requiresProjectId: true,
}),
expect.objectContaining({
provider: 'antigravity',
platform: 'antigravity',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
}),
]),
});
});
it('starts an antigravity oauth session and returns provider metadata', async () => {
const startResponse = await app.inject({
method: 'POST',
url: '/api/oauth/providers/antigravity/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;
instructions?: {
redirectUri: string;
callbackPort: number;
callbackPath: string;
manualCallbackDelayMs: number;
};
};
expect(startBody.provider).toBe('antigravity');
expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/);
expect(startBody.authorizationUrl).toContain('https://accounts.google.com/o/oauth2/v2/auth?');
expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:51121/oauth-callback'));
expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`);
expect(startBody.instructions).toMatchObject({
redirectUri: 'http://localhost:51121/oauth-callback',
callbackPort: 51121,
callbackPath: '/oauth-callback',
manualCallbackDelayMs: 15000,
});
});
it('starts a codex oauth session and exposes pending status', async () => {
const startResponse = await app.inject({
method: 'POST',
+505
View File
@@ -4,6 +4,8 @@ import path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const fetchModelPricingCatalogMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
@@ -13,6 +15,8 @@ const invalidateTokenRouterCacheMock = vi.fn();
const authorizeDownstreamTokenMock = vi.fn();
const consumeManagedKeyRequestMock = vi.fn();
const isModelAllowedByPolicyOrAllowedRoutesMock = vi.fn();
const dbSelectAllMock = vi.fn();
const dbSelectGetMock = vi.fn();
const dbInsertValuesMock = vi.fn((_values?: unknown) => ({
run: () => undefined,
}));
@@ -20,10 +24,34 @@ const dbInsertMock = vi.fn((_table?: unknown) => ({
values: (values: unknown) => dbInsertValuesMock(values),
}));
function createDbSelectChain() {
return {
from() {
return this;
},
innerJoin() {
return this;
},
where() {
return this;
},
all: (...args: unknown[]) => dbSelectAllMock(...args),
get: (...args: unknown[]) => dbSelectGetMock(...args),
};
}
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
vi.mock('../../services/modelPricingService.js', () => ({
fetchModelPricingCatalog: (...args: unknown[]) => fetchModelPricingCatalogMock(...args),
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
@@ -43,10 +71,29 @@ vi.mock('../../services/downstreamApiKeyService.js', () => ({
vi.mock('../../db/index.js', () => ({
db: {
select: (..._args: unknown[]) => createDbSelectChain(),
insert: (arg: unknown) => dbInsertMock(arg),
},
schema: {
proxyLogs: {},
modelAvailability: {
modelName: Symbol('modelAvailability.modelName'),
accountId: Symbol('modelAvailability.accountId'),
available: Symbol('modelAvailability.available'),
},
accounts: {
id: Symbol('accounts.id'),
siteId: Symbol('accounts.siteId'),
status: Symbol('accounts.status'),
},
sites: {
id: Symbol('sites.id'),
status: Symbol('sites.status'),
},
tokenRoutes: {
displayName: Symbol('tokenRoutes.displayName'),
enabled: Symbol('tokenRoutes.enabled'),
},
},
}));
@@ -101,6 +148,8 @@ describe('gemini native proxy routes', () => {
beforeEach(() => {
fetchMock.mockReset();
fetchModelPricingCatalogMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
recordSuccessMock.mockReset();
@@ -111,6 +160,8 @@ describe('gemini native proxy routes', () => {
isModelAllowedByPolicyOrAllowedRoutesMock.mockReset();
dbInsertMock.mockClear();
dbInsertValuesMock.mockClear();
dbSelectAllMock.mockReset();
dbSelectGetMock.mockReset();
authorizeDownstreamTokenMock.mockResolvedValue({
ok: true,
@@ -118,6 +169,10 @@ describe('gemini native proxy routes', () => {
token: 'sk-managed-gemini',
policy: {},
});
fetchModelPricingCatalogMock.mockResolvedValue(null);
refreshModelsAndRebuildRoutesMock.mockResolvedValue(undefined);
dbSelectGetMock.mockResolvedValue(null);
dbSelectAllMock.mockResolvedValue([]);
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
@@ -256,6 +311,52 @@ describe('gemini native proxy routes', () => {
});
});
it('synthesizes /v1beta/models from locally available routed models for non-gemini upstreams', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 41, routeId: 22 },
site: { id: 77, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
account: { id: 37, username: 'openai-user@example.com' },
tokenName: 'default',
tokenValue: 'openai-access-token',
actualModel: 'gpt-4.1',
});
dbSelectAllMock
.mockResolvedValueOnce([
{ modelName: 'gpt-4.1' },
{ modelName: 'claude-sonnet-4-5-20250929' },
])
.mockResolvedValueOnce([
{ displayName: 'gemini-2.5-flash' },
]);
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()).toEqual({
models: [
{
name: 'models/claude-sonnet-4-5-20250929',
displayName: 'claude-sonnet-4-5-20250929',
},
{
name: 'models/gemini-2.5-flash',
displayName: 'gemini-2.5-flash',
},
{
name: 'models/gpt-4.1',
displayName: 'gpt-4.1',
},
],
});
});
it('forwards native generateContent requests through the gemini route group', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
candidates: [
@@ -405,6 +506,410 @@ describe('gemini native proxy routes', () => {
});
});
it('routes Gemini native generateContent requests to openai upstreams and serializes the response back to Gemini shape', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 41, routeId: 22 },
site: { id: 77, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
account: { id: 37, username: 'openai-user@example.com' },
tokenName: 'default',
tokenValue: 'openai-access-token',
actualModel: 'gpt-4.1',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-openai-1',
object: 'chat.completion',
created: 1_742_160_000,
model: 'gpt-4.1',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: 'hello from openai',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 4,
completion_tokens: 3,
total_tokens: 7,
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1beta/models/gemini-2.5-flash: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://api.openai.com/v1/chat/completions');
expect(requestInit.headers).toMatchObject({
Authorization: 'Bearer openai-access-token',
'Content-Type': 'application/json',
});
expect(JSON.parse(String(requestInit.body))).toEqual({
model: 'gpt-4.1',
stream: false,
messages: [
{
role: 'user',
content: 'hello',
},
],
});
expect(response.json()).toEqual({
responseId: 'chatcmpl-openai-1',
modelVersion: 'gpt-4.1',
candidates: [
{
index: 0,
content: {
role: 'model',
parts: [{ text: 'hello from openai' }],
},
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 4,
candidatesTokenCount: 3,
totalTokenCount: 7,
},
});
});
it('exposes GeminiCLI downstream generateContent endpoint and wraps the downstream response payload', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 42, routeId: 22 },
site: { id: 78, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
account: { id: 38, username: 'openai-user@example.com' },
tokenName: 'default',
tokenValue: 'openai-access-token',
actualModel: 'gpt-4.1',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-openai-2',
object: 'chat.completion',
created: 1_742_160_001,
model: 'gpt-4.1',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: 'hello from gemini cli downstream',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 6,
completion_tokens: 5,
total_tokens: 11,
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1internal:generateContent',
headers: {
authorization: 'Bearer sk-managed-gemini',
},
payload: {
model: 'gpt-4.1',
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://api.openai.com/v1/chat/completions');
expect(JSON.parse(String(requestInit.body))).toEqual({
model: 'gpt-4.1',
stream: false,
messages: [
{
role: 'user',
content: 'hello',
},
],
});
expect(response.json()).toEqual({
response: {
responseId: 'chatcmpl-openai-2',
modelVersion: 'gpt-4.1',
candidates: [
{
index: 0,
content: {
role: 'model',
parts: [{ text: 'hello from gemini cli downstream' }],
},
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 6,
candidatesTokenCount: 5,
totalTokenCount: 11,
},
},
});
});
it('routes Gemini native generateContent requests to antigravity upstreams through the internal content endpoint', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 43, routeId: 22 },
site: { id: 79, name: 'antigravity-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'antigravity' },
account: {
id: 39,
username: 'antigravity-user@example.com',
extraConfig: JSON.stringify({
oauth: {
provider: 'antigravity',
email: 'antigravity-user@example.com',
projectId: 'project-demo',
},
}),
},
tokenName: 'default',
tokenValue: 'antigravity-access-token',
actualModel: 'gemini-3-pro-preview',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
response: {
responseId: 'antigravity-response-1',
modelVersion: 'gemini-3-pro-preview',
candidates: [
{
content: {
role: 'model',
parts: [{ text: 'hello from antigravity' }],
},
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 8,
candidatesTokenCount: 4,
totalTokenCount: 12,
},
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1beta/models/gemini-3-pro-preview: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 antigravity-access-token',
'Content-Type': 'application/json',
});
expect(JSON.parse(String(requestInit.body))).toEqual({
project: 'project-demo',
model: 'gemini-3-pro-preview',
request: {
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
},
});
expect(response.json()).toEqual({
responseId: 'antigravity-response-1',
modelVersion: 'gemini-3-pro-preview',
candidates: [
{
index: 0,
content: {
role: 'model',
parts: [{ text: 'hello from antigravity' }],
},
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 8,
candidatesTokenCount: 4,
totalTokenCount: 12,
},
});
});
it('exposes GeminiCLI downstream streamGenerateContent endpoint and preserves GeminiCLI response envelopes', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 44, routeId: 22 },
site: { id: 80, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
account: {
id: 40,
username: 'gemini-cli-user@example.com',
extraConfig: JSON.stringify({
oauth: {
provider: 'gemini-cli',
email: 'gemini-cli-user@example.com',
projectId: 'project-demo',
},
}),
},
tokenName: 'default',
tokenValue: 'gemini-cli-access-token',
actualModel: 'gemini-2.5-pro',
});
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: {"response":{"responseId":"cli-stream-1","candidates":[{"content":{"role":"model","parts":[{"text":"hello from cli stream"}]},"finishReason":"STOP"}]}}\n\n'));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1internal:streamGenerateContent',
headers: {
authorization: 'Bearer sk-managed-gemini',
},
payload: {
model: 'gemini-2.5-pro',
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('data: {"response":{"responseId":"cli-stream-1"');
expect(response.body).toContain('hello from cli stream');
});
it('exposes GeminiCLI downstream countTokens endpoint', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 45, routeId: 22 },
site: { id: 81, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
account: {
id: 41,
username: 'gemini-cli-user@example.com',
extraConfig: JSON.stringify({
oauth: {
provider: 'gemini-cli',
email: 'gemini-cli-user@example.com',
projectId: 'project-demo',
},
}),
},
tokenName: 'default',
tokenValue: 'gemini-cli-access-token',
actualModel: 'gemini-2.5-pro',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
totalTokens: 13,
promptTokensDetails: [
{
modality: 'TEXT',
tokenCount: 13,
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1internal:countTokens',
headers: {
authorization: 'Bearer sk-managed-gemini',
},
payload: {
model: 'gemini-2.5-pro',
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:countTokens');
expect(JSON.parse(String(requestInit.body))).toEqual({
request: {
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
},
});
expect(response.json()).toEqual({
totalTokens: 13,
promptTokensDetails: [
{
modality: 'TEXT',
tokenCount: 13,
},
],
});
});
it('filters Gemini native model list by downstream managed-key policy', async () => {
authorizeDownstreamTokenMock.mockResolvedValue({
ok: true,
+481 -207
View File
@@ -1,6 +1,7 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { TextDecoder } from 'node:util';
import { fetch } from 'undici';
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
@@ -8,11 +9,20 @@ import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstream
import { tokenRouter } from '../../services/tokenRouter.js';
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
import { executeEndpointFlow } from './endpointFlow.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import {
buildUpstreamEndpointRequest,
resolveUpstreamEndpointCandidates,
} from './upstreamEndpoint.js';
import {
geminiGenerateContentTransformer,
} from '../../transformers/gemini/generate-content/index.js';
import { createChatEndpointStrategy } from '../../transformers/shared/chatEndpointStrategy.js';
import { normalizeUpstreamFinalResponse } from '../../transformers/shared/normalized.js';
import {
createGeminiCliStreamReader,
unwrapGeminiCliPayload,
@@ -45,6 +55,14 @@ function isGeminiCliPlatform(platform: unknown): boolean {
return String(platform || '').trim().toLowerCase() === 'gemini-cli';
}
function isAntigravityPlatform(platform: unknown): boolean {
return String(platform || '').trim().toLowerCase() === 'antigravity';
}
function isInternalGeminiPlatform(platform: unknown): boolean {
return isGeminiCliPlatform(platform) || isAntigravityPlatform(platform);
}
function buildGeminiCliActionPath(input: {
isStreamAction: boolean;
isCountTokensAction: boolean;
@@ -54,6 +72,17 @@ function buildGeminiCliActionPath(input: {
return '/v1internal:generateContent';
}
function isDirectGeminiFamilyPlatform(platform: unknown): boolean {
const normalized = String(platform || '').trim().toLowerCase();
return normalized === 'gemini' || normalized === 'gemini-cli' || normalized === 'antigravity';
}
function omitGeminiCliModelField(body: unknown): Record<string, unknown> {
if (!body || typeof body !== 'object' || Array.isArray(body)) return {};
const { model: _model, ...rest } = body as Record<string, unknown>;
return rest;
}
async function selectGeminiChannel(request: FastifyRequest) {
const policy = getDownstreamRoutingPolicy(request);
for (const candidate of GEMINI_MODEL_PROBES) {
@@ -128,6 +157,41 @@ async function filterGeminiListedModelsForPolicy(
};
}
async function readRouteAwareGeminiModels(request: FastifyRequest): Promise<Array<{ name: string; displayName: string }>> {
const policy = getDownstreamRoutingPolicy(request);
const rows = await db.select({ modelName: schema.modelAvailability.modelName })
.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(
eq(schema.modelAvailability.available, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
))
.all();
const routeAliases = await db.select({ displayName: schema.tokenRoutes.displayName })
.from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all();
const deduped = Array.from(new Set([
...rows.map((row) => String(row.modelName || '').trim()).filter(Boolean),
...routeAliases.map((row) => String(row.displayName || '').trim()).filter(Boolean),
])).sort();
const allowed: Array<{ name: string; displayName: string }> = [];
for (const modelName of deduped) {
if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue;
const decision = await tokenRouter.explainSelection?.(modelName, [], policy);
if (decision && typeof decision.selectedChannelId !== 'number') continue;
allowed.push({
name: `models/${modelName}`,
displayName: modelName,
});
}
return allowed;
}
async function logProxy(
selected: any,
modelRequested: string,
@@ -194,6 +258,15 @@ export async function geminiProxyRoute(app: FastifyInstance) {
excludeChannelIds.push(selected.channel.id);
try {
if (!isDirectGeminiFamilyPlatform(selected.site.platform)) {
let models = await readRouteAwareGeminiModels(request);
if (models.length <= 0) {
await refreshModelsAndRebuildRoutes();
models = await readRouteAwareGeminiModels(request);
}
return reply.code(200).send({ models });
}
if (isGeminiCliPlatform(selected.site.platform)) {
const filtered = await filterGeminiListedModelsForPolicy(
{ models: GEMINI_CLI_STATIC_MODELS },
@@ -243,13 +316,36 @@ export async function geminiProxyRoute(app: FastifyInstance) {
}
};
const generateContent = async (request: FastifyRequest, reply: FastifyReply) => {
const parsedPath = geminiGenerateContentTransformer.parseProxyRequestPath({
rawUrl: request.raw.url || request.url || '',
params: request.params as { geminiApiVersion?: string } | undefined,
});
const handleGenerateContent = async (
request: FastifyRequest,
reply: FastifyReply,
options?: {
downstreamProtocol?: 'gemini' | 'gemini-cli';
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
},
) => {
const downstreamProtocol = options?.downstreamProtocol || 'gemini';
const isGeminiCliDownstream = downstreamProtocol === 'gemini-cli';
const cliRequestedModel = isGeminiCliDownstream
? (typeof (request.body as Record<string, unknown> | null | undefined)?.model === 'string'
? String((request.body as Record<string, unknown>).model).trim()
: '')
: '';
const parsedPath = isGeminiCliDownstream
? {
apiVersion: 'v1beta',
modelActionPath: `models/${cliRequestedModel}:${options?.action || 'generateContent'}`,
isStreamAction: options?.action === 'streamGenerateContent',
requestedModel: cliRequestedModel,
}
: geminiGenerateContentTransformer.parseProxyRequestPath({
rawUrl: request.raw.url || request.url || '',
params: request.params as { geminiApiVersion?: string } | undefined,
});
const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath;
const isCountTokensAction = modelActionPath.endsWith(':countTokens');
const isCountTokensAction = isGeminiCliDownstream
? options?.action === 'countTokens'
: modelActionPath.endsWith(':countTokens');
if (!requestedModel) {
return reply.code(400).send({
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
@@ -274,114 +370,227 @@ export async function geminiProxyRoute(app: FastifyInstance) {
excludeChannelIds.push(selected.channel.id);
const body = geminiGenerateContentTransformer.inbound.normalizeRequest(
request.body || {},
selected.actualModel || requestedModel,
const actualModel = selected.actualModel || requestedModel;
const normalizedBody = geminiGenerateContentTransformer.inbound.normalizeRequest(
isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}),
actualModel,
);
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 = 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 isInternalGemini = isInternalGeminiPlatform(selected.site.platform);
const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform);
const startTime = Date.now();
try {
const targetUrl = isGeminiCli
? `${selected.site.url}${upstreamPath}`
: geminiGenerateContentTransformer.resolveActionUrl(
selected.site.url,
apiVersion,
actualModelAction,
selected.tokenValue,
query,
);
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;
lastContentType = contentType;
lastText = await upstream.text();
await tokenRouter.recordFailure?.(selected.channel.id);
await logProxy(
selected,
requestedModel,
'failed',
lastStatus,
Date.now() - startTime,
lastText,
retryCount,
downstreamPath,
upstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
let upstreamPath = '';
try {
return reply.code(lastStatus).send(JSON.parse(lastText));
} catch {
try {
if (isDirectGeminiFamily) {
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);
}
}
if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) {
reply.hijack();
reply.raw.statusCode = upstream.status;
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
const upstreamReader = upstream.body?.getReader();
const reader = isGeminiCliPlatform(selected.site.platform) && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
if (!reader) {
const actualModelAction = modelActionPath.replace(
/^models\/[^:]+/,
`models/${actualModel}`,
);
upstreamPath = isInternalGemini
? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction })
: resolveUpstreamPath(apiVersion, actualModelAction);
const query = new URLSearchParams(request.query as Record<string, string>).toString();
const requestBody = isInternalGemini
? (
isCountTokensAction
? { request: normalizedBody }
: wrapGeminiCliRequest({
modelName: actualModel,
projectId: oauth?.projectId || '',
request: normalizedBody as Record<string, unknown>,
})
)
: normalizedBody;
const requestHeaders = isInternalGemini
? {
'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 targetUrl = isInternalGemini
? `${selected.site.url}${upstreamPath}`
: geminiGenerateContentTransformer.resolveActionUrl(
selected.site.url,
apiVersion,
actualModelAction,
selected.tokenValue,
query,
);
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;
lastContentType = contentType;
lastText = await upstream.text();
await tokenRouter.recordFailure?.(selected.channel.id);
await logProxy(
selected,
requestedModel,
'failed',
lastStatus,
Date.now() - startTime,
lastText,
retryCount,
downstreamPath,
upstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
try {
return reply.code(lastStatus).send(JSON.parse(lastText));
} catch {
return reply.code(lastStatus).type(lastContentType).send(lastText);
}
}
if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) {
reply.hijack();
reply.raw.statusCode = upstream.status;
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
const upstreamReader = upstream.body?.getReader();
const reader = isInternalGemini && !isGeminiCliDownstream && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
if (!reader) {
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
);
reply.raw.end();
return;
}
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
const decoder = new TextDecoder();
let rest = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
const chunkText = decoder.decode(value, { stream: true });
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
aggregateState,
rest + chunkText,
);
rest = consumed.rest;
for (const line of consumed.lines) {
reply.raw.write(line);
}
}
const tail = decoder.decode();
if (tail) {
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
aggregateState,
rest + tail,
);
for (const line of consumed.lines) {
reply.raw.write(line);
}
}
} finally {
reader.releaseLock();
reply.raw.end();
}
const parsedUsage = parseProxyUsage(aggregateState);
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
);
return;
}
const text = await upstream.text();
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
let parsedUsage = EMPTY_PROXY_USAGE;
try {
const parsed = JSON.parse(text);
const unwrappedPayload = isInternalGemini && !isGeminiCliDownstream
? unwrapGeminiCliPayload(parsed)
: 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);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
);
return reply.code(upstream.status).send(
isGeminiCliDownstream && !isCountTokensAction
? { response: responsePayload }
: responsePayload,
);
} catch {
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await logProxy(
@@ -395,110 +604,158 @@ export async function geminiProxyRoute(app: FastifyInstance) {
downstreamPath,
upstreamPath,
);
reply.raw.end();
return;
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
}
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
const decoder = new TextDecoder();
let rest = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
const chunkText = decoder.decode(value, { stream: true });
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
aggregateState,
rest + chunkText,
);
rest = consumed.rest;
for (const line of consumed.lines) {
reply.raw.write(line);
}
}
const tail = decoder.decode();
if (tail) {
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
aggregateState,
rest + tail,
);
for (const line of consumed.lines) {
reply.raw.write(line);
}
}
} finally {
reader.releaseLock();
reply.raw.end();
}
const parsedUsage = parseProxyUsage(aggregateState);
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
);
return;
}
const text = await upstream.text();
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
let parsedUsage = EMPTY_PROXY_USAGE;
try {
const parsed = JSON.parse(text);
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);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
);
return reply.code(upstream.status).send(
responsePayload,
);
} catch {
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
);
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
if (isCountTokensAction) {
lastStatus = 501;
lastContentType = 'application/json';
lastText = JSON.stringify({
error: {
message: 'Gemini countTokens compatibility is not implemented for this upstream',
type: 'invalid_request_error',
},
});
return reply.code(lastStatus).type(lastContentType).send(lastText);
}
const openAiBody = geminiGenerateContentTransformer.compatibility.buildOpenAiBodyFromGeminiRequest({
body: normalizedBody as Record<string, unknown>,
modelName: actualModel,
stream: isStreamAction,
});
const endpointCandidates = await resolveUpstreamEndpointCandidates(
{
site: selected.site,
account: selected.account,
},
actualModel,
'openai',
requestedModel,
);
const buildEndpointRequest = (
endpoint: 'chat' | 'messages' | 'responses',
requestOptions: { forceNormalizeClaudeBody?: boolean } = {},
) => {
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName: actualModel,
stream: isStreamAction,
tokenValue: selected.tokenValue,
oauthProvider: oauth?.provider,
oauthProjectId: oauth?.projectId,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: openAiBody,
downstreamFormat: 'openai',
forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody,
downstreamHeaders: request.headers as Record<string, unknown>,
providerHeaders: buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
}),
});
return {
endpoint,
path: endpointRequest.path,
headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>,
};
};
const endpointStrategy = createChatEndpointStrategy({
downstreamFormat: 'openai',
endpointCandidates,
modelName: actualModel,
requestedModelHint: requestedModel,
sitePlatform: selected.site.platform,
isStream: isStreamAction,
buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
endpoint,
{ forceNormalizeClaudeBody },
),
dispatchRequest: async (compatibilityRequest, targetUrl) => fetch(
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
await withSiteProxyRequestInit(selected.site.url, {
method: 'POST',
headers: compatibilityRequest.headers,
body: JSON.stringify(compatibilityRequest.body),
}),
),
});
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
tryRecover: endpointStrategy.tryRecover,
shouldDowngrade: endpointStrategy.shouldDowngrade,
});
if (!endpointResult.ok) {
lastStatus = endpointResult.status;
lastContentType = 'application/json';
lastText = JSON.stringify({
error: {
message: endpointResult.errText,
type: 'upstream_error',
},
});
await tokenRouter.recordFailure?.(selected.channel.id);
await logProxy(
selected,
requestedModel,
'failed',
lastStatus,
Date.now() - startTime,
endpointResult.errText,
retryCount,
downstreamPath,
null,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
return reply.code(lastStatus).type(lastContentType).send(lastText);
}
upstreamPath = endpointResult.upstreamPath;
const upstream = endpointResult.upstream;
const rawText = await upstream.text();
let upstreamData: unknown = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {}
const parsedUsage = parseProxyUsage(upstreamData);
const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, actualModel, rawText);
const geminiResponse = geminiGenerateContentTransformer.compatibility.serializeNormalizedFinalToGemini({
normalized: normalizedFinal,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
);
return reply.code(upstream.status).send(
isGeminiCliDownstream
? { response: geminiResponse }
: geminiResponse,
);
} catch (error) {
lastStatus = 502;
lastContentType = 'application/json';
@@ -518,7 +775,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
error instanceof Error ? error.message : 'Gemini upstream request failed',
retryCount,
downstreamPath,
upstreamPath,
upstreamPath || null,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -529,8 +786,25 @@ export async function geminiProxyRoute(app: FastifyInstance) {
}
};
const generateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply);
const geminiCliGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
downstreamProtocol: 'gemini-cli',
action: 'generateContent',
});
const geminiCliStreamGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
downstreamProtocol: 'gemini-cli',
action: 'streamGenerateContent',
});
const geminiCliCountTokens = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
downstreamProtocol: 'gemini-cli',
action: 'countTokens',
});
app.get('/v1beta/models', listModels);
app.get('/gemini/:geminiApiVersion/models', listModels);
app.post('/v1beta/models/*', generateContent);
app.post('/gemini/:geminiApiVersion/models/*', generateContent);
app.post('/v1internal::generateContent', geminiCliGenerateContent);
app.post('/v1internal::streamGenerateContent', geminiCliStreamGenerateContent);
app.post('/v1internal::countTokens', geminiCliCountTokens);
}
@@ -415,6 +415,52 @@ describe('buildUpstreamEndpointRequest', () => {
});
});
it('builds antigravity native requests with the same internal Gemini envelope', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'chat',
modelName: 'gemini-3-pro-preview',
stream: false,
tokenValue: 'oauth-access-token',
oauthProvider: 'antigravity',
oauthProjectId: 'project-demo',
sitePlatform: 'antigravity',
siteUrl: 'https://cloudcode-pa.googleapis.com',
openaiBody: {
model: 'gemini-3-pro-preview',
messages: [
{ role: 'system', content: 'be concise' },
{ role: 'user', content: 'hello antigravity' },
],
},
downstreamFormat: 'openai',
providerHeaders: {
'User-Agent': 'google-api-nodejs-client/9.15.1',
'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
},
});
expect(request.path).toBe('/v1internal:generateContent');
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
expect(request.headers['User-Agent']).toContain('google-api-nodejs-client');
expect(request.headers['X-Goog-Api-Client']).toContain('google-cloud-sdk');
expect(request.body).toEqual({
project: 'project-demo',
model: 'gemini-3-pro-preview',
request: {
systemInstruction: {
role: 'user',
parts: [{ text: 'be concise' }],
},
contents: [
{
role: 'user',
parts: [{ text: 'hello antigravity' }],
},
],
},
});
});
it('uses bearer auth for claude oauth upstream requests', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'messages',
+14 -5
View File
@@ -332,6 +332,10 @@ function preferredEndpointOrder(
return ['chat'];
}
if (platform === 'antigravity') {
return ['chat'];
}
if (platform === 'openai') {
if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') {
// Some OpenAI-compatible gateways expose Claude natively via /v1/messages.
@@ -409,6 +413,7 @@ export async function resolveUpstreamEndpointCandidates(
? (() => {
if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[];
if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[];
if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') return ['chat'] as UpstreamEndpoint[];
if (preferMessagesForClaudeModel) return ['messages', 'responses', 'chat'] as UpstreamEndpoint[];
return ['responses', 'messages', 'chat'] as UpstreamEndpoint[];
})()
@@ -428,6 +433,8 @@ export async function resolveUpstreamEndpointCandidates(
&& preferMessagesForClaudeModel
&& sitePlatform !== 'openai'
&& sitePlatform !== 'gemini'
&& sitePlatform !== 'antigravity'
&& sitePlatform !== 'gemini-cli'
);
try {
@@ -532,6 +539,8 @@ export function buildUpstreamEndpointRequest(input: {
const isClaudeUpstream = sitePlatform === 'claude';
const isGeminiUpstream = sitePlatform === 'gemini';
const isGeminiCliUpstream = sitePlatform === 'gemini-cli';
const isAntigravityUpstream = sitePlatform === 'antigravity';
const isInternalGeminiUpstream = isGeminiCliUpstream || isAntigravityUpstream;
const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude';
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
@@ -562,7 +571,7 @@ export function buildUpstreamEndpointRequest(input: {
return '/responses';
}
if (sitePlatform === 'gemini-cli') {
if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') {
return input.stream
? '/v1internal:streamGenerateContent?alt=sse'
: '/v1internal:generateContent';
@@ -589,7 +598,7 @@ export function buildUpstreamEndpointRequest(input: {
const stripGeminiUnsupportedFields = (body: Record<string, unknown>) => {
const next = { ...body };
if (isGeminiUpstream || isGeminiCliUpstream) {
if (isGeminiUpstream || isInternalGeminiUpstream) {
for (const key of [
'frequency_penalty',
'presence_penalty',
@@ -606,9 +615,9 @@ export function buildUpstreamEndpointRequest(input: {
const openaiBody = stripGeminiUnsupportedFields(input.openaiBody);
if (isGeminiCliUpstream) {
if (isInternalGeminiUpstream) {
const projectId = asTrimmedString(input.oauthProjectId);
if (!projectId) {
if (isGeminiCliUpstream && !projectId) {
throw new Error('gemini-cli oauth project id missing');
}
const instructions = (
@@ -628,7 +637,7 @@ export function buildUpstreamEndpointRequest(input: {
headers,
body: wrapGeminiCliRequest({
modelName: input.modelName,
projectId,
projectId: projectId || '',
request: geminiRequest,
}) as Record<string, unknown>,
};