forked from boos/BoosAPI
feat: expand gemini proxy compatibility matrix
This commit is contained in:
@@ -6,7 +6,7 @@ export const sites = sqliteTable('sites', {
|
||||
name: text('name').notNull(),
|
||||
url: text('url').notNull(),
|
||||
externalCheckinUrl: text('external_checkin_url'),
|
||||
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' | 'codex' | 'gemini-cli'
|
||||
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' | 'codex' | 'gemini-cli' | 'antigravity'
|
||||
proxyUrl: text('proxy_url'),
|
||||
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
|
||||
customHeaders: text('custom_headers'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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>,
|
||||
};
|
||||
|
||||
@@ -481,4 +481,63 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
expect(parsed.runtimeHealth?.state).toBe('unhealthy');
|
||||
});
|
||||
|
||||
it('discovers antigravity oauth models via direct credential routing', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
|
||||
token === 'antigravity-access-token'
|
||||
? ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929']
|
||||
: []
|
||||
));
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'antigravity-site',
|
||||
url: 'https://cloudcode-pa.googleapis.com',
|
||||
platform: 'antigravity',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'antigravity-user@example.com',
|
||||
accessToken: 'antigravity-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'antigravity',
|
||||
email: 'antigravity-user@example.com',
|
||||
projectId: 'project-demo',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
errorCode: null,
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: true,
|
||||
discoveredApiToken: false,
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'],
|
||||
});
|
||||
expect(getModelsMock).toHaveBeenCalledWith(
|
||||
'https://cloudcode-pa.googleapis.com',
|
||||
'antigravity-access-token',
|
||||
undefined,
|
||||
);
|
||||
|
||||
const rows = await db.select().from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, account.id))
|
||||
.all();
|
||||
expect(rows.map((row) => row.modelName).sort()).toEqual([
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'gemini-3-pro-preview',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { fetch } from 'undici';
|
||||
import type { OAuthProviderDefinition } from './providers.js';
|
||||
|
||||
export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity';
|
||||
export const ANTIGRAVITY_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
export const ANTIGRAVITY_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
export const ANTIGRAVITY_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
|
||||
export const ANTIGRAVITY_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
export const ANTIGRAVITY_CLIENT_SECRET = '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';
|
||||
export const ANTIGRAVITY_LOOPBACK_CALLBACK_PORT = 51121;
|
||||
export const ANTIGRAVITY_LOOPBACK_CALLBACK_PATH = '/oauth-callback';
|
||||
export const ANTIGRAVITY_LOOPBACK_REDIRECT_URI = `http://localhost:${ANTIGRAVITY_LOOPBACK_CALLBACK_PORT}${ANTIGRAVITY_LOOPBACK_CALLBACK_PATH}`;
|
||||
export const ANTIGRAVITY_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com';
|
||||
export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1';
|
||||
export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1';
|
||||
export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}';
|
||||
|
||||
const ANTIGRAVITY_SCOPES = [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/cclog',
|
||||
'https://www.googleapis.com/auth/experimentsandconfigs',
|
||||
];
|
||||
|
||||
type AntigravityOAuthTokenPayload = {
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
token_type?: unknown;
|
||||
expires_in?: unknown;
|
||||
scope?: unknown;
|
||||
expiry?: unknown;
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function parseExpiresAt(payload: AntigravityOAuthTokenPayload): number | undefined {
|
||||
if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) {
|
||||
return Date.now() + Math.trunc(payload.expires_in) * 1000;
|
||||
}
|
||||
if (typeof payload.expires_in === 'string') {
|
||||
const parsed = Number.parseInt(payload.expires_in.trim(), 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Date.now() + parsed * 1000;
|
||||
}
|
||||
}
|
||||
if (typeof payload.expiry === 'string') {
|
||||
const parsed = Date.parse(payload.expiry);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function postAntigravityToken(body: URLSearchParams) {
|
||||
const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `antigravity token exchange failed with status ${response.status}`);
|
||||
}
|
||||
const payload = await response.json() as AntigravityOAuthTokenPayload;
|
||||
const accessToken = asTrimmedString(payload.access_token);
|
||||
if (!accessToken) {
|
||||
throw new Error('antigravity token exchange response missing access token');
|
||||
}
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: asTrimmedString(payload.refresh_token),
|
||||
tokenExpiresAt: parseExpiresAt(payload),
|
||||
providerData: {
|
||||
tokenType: asTrimmedString(payload.token_type),
|
||||
scope: asTrimmedString(payload.scope),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAntigravityUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
const response = await fetch(ANTIGRAVITY_USERINFO_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const payload = await response.json() as { email?: unknown };
|
||||
return asTrimmedString(payload.email);
|
||||
}
|
||||
|
||||
async function fetchAntigravityProjectId(accessToken: string): Promise<string | undefined> {
|
||||
const response = await fetch(`${ANTIGRAVITY_UPSTREAM_BASE_URL}/v1internal:loadCodeAssist`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': ANTIGRAVITY_USER_AGENT,
|
||||
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
|
||||
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: 'ANTIGRAVITY',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const payload = await response.json() as {
|
||||
cloudaicompanionProject?: unknown;
|
||||
};
|
||||
if (typeof payload.cloudaicompanionProject === 'string') {
|
||||
return asTrimmedString(payload.cloudaicompanionProject);
|
||||
}
|
||||
if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject === 'object') {
|
||||
return asTrimmedString((payload.cloudaicompanionProject as { id?: unknown }).id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const antigravityOauthProvider: OAuthProviderDefinition = {
|
||||
metadata: {
|
||||
provider: ANTIGRAVITY_OAUTH_PROVIDER,
|
||||
label: 'Antigravity',
|
||||
platform: 'antigravity',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: false,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: false,
|
||||
},
|
||||
site: {
|
||||
name: 'Google Antigravity OAuth',
|
||||
url: ANTIGRAVITY_UPSTREAM_BASE_URL,
|
||||
platform: 'antigravity',
|
||||
},
|
||||
loopback: {
|
||||
host: '127.0.0.1',
|
||||
port: ANTIGRAVITY_LOOPBACK_CALLBACK_PORT,
|
||||
path: ANTIGRAVITY_LOOPBACK_CALLBACK_PATH,
|
||||
redirectUri: ANTIGRAVITY_LOOPBACK_REDIRECT_URI,
|
||||
},
|
||||
buildAuthorizationUrl: async ({ state, redirectUri }) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: ANTIGRAVITY_CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
scope: ANTIGRAVITY_SCOPES.join(' '),
|
||||
state,
|
||||
});
|
||||
return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`;
|
||||
},
|
||||
exchangeAuthorizationCode: async ({ code, redirectUri }) => {
|
||||
const token = await postAntigravityToken(new URLSearchParams({
|
||||
code,
|
||||
client_id: ANTIGRAVITY_CLIENT_ID,
|
||||
client_secret: ANTIGRAVITY_CLIENT_SECRET,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}));
|
||||
const email = await fetchAntigravityUserEmail(token.accessToken);
|
||||
const projectId = await fetchAntigravityProjectId(token.accessToken);
|
||||
return {
|
||||
...token,
|
||||
email,
|
||||
accountKey: email,
|
||||
accountId: email,
|
||||
projectId,
|
||||
};
|
||||
},
|
||||
refreshAccessToken: async ({ refreshToken, oauth }) => {
|
||||
const token = await postAntigravityToken(new URLSearchParams({
|
||||
client_id: ANTIGRAVITY_CLIENT_ID,
|
||||
client_secret: ANTIGRAVITY_CLIENT_SECRET,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
}));
|
||||
const email = await fetchAntigravityUserEmail(token.accessToken);
|
||||
return {
|
||||
...token,
|
||||
email,
|
||||
accountKey: email,
|
||||
accountId: email,
|
||||
projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken),
|
||||
};
|
||||
},
|
||||
buildProxyHeaders: () => ({
|
||||
'User-Agent': ANTIGRAVITY_USER_AGENT,
|
||||
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
|
||||
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
|
||||
}),
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { codexOauthProvider } from './codexProvider.js';
|
||||
import { claudeOauthProvider } from './claudeProvider.js';
|
||||
import { geminiCliOauthProvider } from './geminiCliProvider.js';
|
||||
import { antigravityOauthProvider } from './antigravityProvider.js';
|
||||
|
||||
export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli';
|
||||
export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli' | 'antigravity';
|
||||
|
||||
export type OAuthProviderMetadata = {
|
||||
provider: OAuthProviderId;
|
||||
@@ -85,6 +86,7 @@ const PROVIDERS: OAuthProviderDefinition[] = [
|
||||
codexOauthProvider,
|
||||
claudeOauthProvider,
|
||||
geminiCliOauthProvider,
|
||||
antigravityOauthProvider,
|
||||
];
|
||||
|
||||
const PROVIDER_BY_ID = new Map(PROVIDERS.map((provider) => [provider.metadata.provider, provider] as const));
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
|
||||
import {
|
||||
ANTIGRAVITY_CLIENT_METADATA,
|
||||
ANTIGRAVITY_GOOGLE_API_CLIENT,
|
||||
ANTIGRAVITY_UPSTREAM_BASE_URL,
|
||||
ANTIGRAVITY_USER_AGENT,
|
||||
} from '../oauth/antigravityProvider.js';
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return (baseUrl || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function extractAntigravityModelNames(payload: unknown): string[] {
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
const record = payload as { models?: unknown };
|
||||
if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) {
|
||||
return Object.keys(record.models).map((name) => name.trim()).filter(Boolean);
|
||||
}
|
||||
if (Array.isArray(record.models)) {
|
||||
return record.models.flatMap((item) => {
|
||||
if (typeof item === 'string') return item.trim() ? [item.trim()] : [];
|
||||
if (!item || typeof item !== 'object') return [];
|
||||
const id = typeof (item as { id?: unknown }).id === 'string'
|
||||
? (item as { id: string }).id.trim()
|
||||
: (typeof (item as { name?: unknown }).name === 'string'
|
||||
? (item as { name: string }).name.trim()
|
||||
: '');
|
||||
return id ? [id] : [];
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export class AntigravityAdapter extends BasePlatformAdapter {
|
||||
readonly platformName = 'antigravity';
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
const normalized = (url || '').toLowerCase();
|
||||
return normalized.includes('antigravity');
|
||||
}
|
||||
|
||||
override async login(_baseUrl: string, _username: string, _password: string) {
|
||||
return { success: false, message: 'login endpoint not supported' };
|
||||
}
|
||||
|
||||
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||
return { success: false, message: 'checkin endpoint not supported' };
|
||||
}
|
||||
|
||||
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
|
||||
return { balance: 0, used: 0, quota: 0 };
|
||||
}
|
||||
|
||||
async getModels(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||
try {
|
||||
const payload = await this.fetchJson<{ models?: unknown }>(
|
||||
`${normalizeBaseUrl(baseUrl || ANTIGRAVITY_UPSTREAM_BASE_URL)}/v1internal:fetchAvailableModels`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': ANTIGRAVITY_USER_AGENT,
|
||||
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
|
||||
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
return extractAntigravityModelNames(payload);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,11 @@ describe('getAdapter platform aliases', () => {
|
||||
expect(getAdapter('gemini')?.platformName).toBe('gemini');
|
||||
});
|
||||
|
||||
it('supports antigravity adapter aliases', () => {
|
||||
expect(getAdapter('antigravity')?.platformName).toBe('antigravity');
|
||||
expect(getAdapter('anti-gravity')?.platformName).toBe('antigravity');
|
||||
});
|
||||
|
||||
it('supports dedicated codex adapter and aliases', () => {
|
||||
expect(getAdapter('codex')?.platformName).toBe('codex');
|
||||
expect(getAdapter('chatgpt-codex')?.platformName).toBe('codex');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CodexAdapter } from './codex.js';
|
||||
import { ClaudeAdapter } from './claude.js';
|
||||
import { GeminiAdapter } from './gemini.js';
|
||||
import { GeminiCliAdapter } from './geminiCli.js';
|
||||
import { AntigravityAdapter } from './antigravity.js';
|
||||
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
||||
import { detectPlatformByTitle } from './titleHint.js';
|
||||
|
||||
@@ -21,6 +22,7 @@ const adapters: PlatformAdapter[] = [
|
||||
new ClaudeAdapter(),
|
||||
new GeminiAdapter(),
|
||||
new GeminiCliAdapter(),
|
||||
new AntigravityAdapter(),
|
||||
new CliProxyApiAdapter(),
|
||||
new AnyRouterAdapter(),
|
||||
new DoneHubAdapter(),
|
||||
@@ -60,6 +62,8 @@ const platformAliases: Record<string, string> = {
|
||||
claude: 'claude',
|
||||
gemini: 'gemini',
|
||||
'gemini-cli': 'gemini-cli',
|
||||
antigravity: 'antigravity',
|
||||
'anti-gravity': 'antigravity',
|
||||
google: 'gemini',
|
||||
// CLIProxyAPI aliases
|
||||
cliproxyapi: 'cliproxyapi',
|
||||
|
||||
@@ -44,10 +44,7 @@ function extractHtmlTitle(html: string): string {
|
||||
return match[1].replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatform | undefined> {
|
||||
const base = normalizeBaseUrl(url);
|
||||
if (!base) return undefined;
|
||||
|
||||
async function detectPlatformByTitleOnce(base: string): Promise<TitleHintPlatform | undefined> {
|
||||
try {
|
||||
const { fetch } = await import('undici');
|
||||
const res = await fetch(`${base}/`, {
|
||||
@@ -71,3 +68,16 @@ export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatf
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatform | undefined> {
|
||||
const base = normalizeBaseUrl(url);
|
||||
if (!base) return undefined;
|
||||
|
||||
const first = await detectPlatformByTitleOnce(base);
|
||||
if (first) return first;
|
||||
|
||||
// Under heavy parallel test load, local title probes can occasionally race
|
||||
// with just-started ephemeral HTTP servers. Retry once before giving up.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return detectPlatformByTitleOnce(base);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import type { NormalizedFinalResponse } from '../../shared/normalized.js';
|
||||
import { extractReasoningMetadataFromGeminiRequest } from './convert.js';
|
||||
|
||||
type GeminiRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is GeminiRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function parseJsonIfPossible(value: string): unknown {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { raw: value };
|
||||
}
|
||||
}
|
||||
|
||||
function buildDataUrl(part: GeminiRecord): string | null {
|
||||
const inlineData = isRecord(part.inlineData) ? part.inlineData : null;
|
||||
if (!inlineData) return null;
|
||||
const mimeType = asTrimmedString(inlineData.mime_type ?? inlineData.mimeType) || 'application/octet-stream';
|
||||
const data = asTrimmedString(inlineData.data);
|
||||
if (!data) return null;
|
||||
return `data:${mimeType};base64,${data}`;
|
||||
}
|
||||
|
||||
function toOpenAiContent(contentParts: GeminiRecord[]): string | Array<Record<string, unknown>> {
|
||||
const blocks: Array<Record<string, unknown>> = [];
|
||||
let textContent = '';
|
||||
|
||||
for (const part of contentParts) {
|
||||
if (typeof part.text === 'string' && part.text.length > 0) {
|
||||
if (part.thought === true) continue;
|
||||
textContent += part.text;
|
||||
continue;
|
||||
}
|
||||
|
||||
const dataUrl = buildDataUrl(part);
|
||||
if (dataUrl) {
|
||||
blocks.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: dataUrl },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (blocks.length <= 0) {
|
||||
return textContent;
|
||||
}
|
||||
|
||||
if (textContent) {
|
||||
blocks.unshift({
|
||||
type: 'text',
|
||||
text: textContent,
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function extractToolDeclarations(tools: unknown): Array<Record<string, unknown>> | undefined {
|
||||
if (!Array.isArray(tools)) return undefined;
|
||||
const next = tools.flatMap((tool) => {
|
||||
if (!isRecord(tool) || !Array.isArray(tool.functionDeclarations)) return [];
|
||||
return tool.functionDeclarations.flatMap((declaration) => {
|
||||
if (!isRecord(declaration)) return [];
|
||||
const name = asTrimmedString(declaration.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
...(asTrimmedString(declaration.description)
|
||||
? { description: asTrimmedString(declaration.description) }
|
||||
: {}),
|
||||
parameters: isRecord(declaration.parametersJsonSchema)
|
||||
? declaration.parametersJsonSchema
|
||||
: (isRecord(declaration.parameters) ? declaration.parameters : { type: 'object', properties: {} }),
|
||||
},
|
||||
}];
|
||||
});
|
||||
});
|
||||
return next.length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function extractToolChoice(toolConfig: unknown): string | undefined {
|
||||
const functionCallingConfig = (
|
||||
isRecord(toolConfig) && isRecord(toolConfig.functionCallingConfig)
|
||||
? toolConfig.functionCallingConfig
|
||||
: null
|
||||
);
|
||||
const mode = asTrimmedString(functionCallingConfig?.mode).toUpperCase();
|
||||
if (mode === 'NONE') return 'none';
|
||||
if (mode === 'ANY' || mode === 'VALIDATED') return 'required';
|
||||
if (mode === 'AUTO') return 'auto';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildGeminiMessages(body: GeminiRecord): Array<Record<string, unknown>> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (isRecord(body.systemInstruction) && Array.isArray(body.systemInstruction.parts)) {
|
||||
const content = toOpenAiContent(
|
||||
body.systemInstruction.parts.filter((part): part is GeminiRecord => isRecord(part)),
|
||||
);
|
||||
const hasContent = typeof content === 'string' ? content.length > 0 : content.length > 0;
|
||||
if (hasContent) {
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const contents = Array.isArray(body.contents) ? body.contents : [];
|
||||
for (const contentItem of contents) {
|
||||
if (!isRecord(contentItem)) continue;
|
||||
const role = asTrimmedString(contentItem.role) === 'model' ? 'assistant' : 'user';
|
||||
const parts = Array.isArray(contentItem.parts)
|
||||
? contentItem.parts.filter((part): part is GeminiRecord => isRecord(part))
|
||||
: [];
|
||||
|
||||
const toolCalls = parts
|
||||
.map((part, index) => {
|
||||
const functionCall = isRecord(part.functionCall) ? part.functionCall : null;
|
||||
const name = asTrimmedString(functionCall?.name);
|
||||
if (!functionCall || !name) return null;
|
||||
return {
|
||||
id: asTrimmedString(functionCall.id) || `call_${index}`,
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: JSON.stringify(functionCall.args ?? {}),
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => !!item);
|
||||
|
||||
const functionResponses = parts
|
||||
.map((part) => (isRecord(part.functionResponse) ? part.functionResponse : null))
|
||||
.filter((item): item is GeminiRecord => !!item);
|
||||
|
||||
if (functionResponses.length > 0) {
|
||||
for (let index = 0; index < functionResponses.length; index += 1) {
|
||||
const functionResponse = functionResponses[index] as GeminiRecord;
|
||||
const toolName = asTrimmedString(functionResponse.name) || `tool_${index}`;
|
||||
const toolResponse = functionResponse.response;
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolName,
|
||||
content: JSON.stringify(toolResponse ?? {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const openAiParts = parts.filter((part) => !part.functionCall && !part.functionResponse);
|
||||
const content = toOpenAiContent(openAiParts);
|
||||
const hasContent = typeof content === 'string' ? content.length > 0 : content.length > 0;
|
||||
if (!hasContent && toolCalls.length <= 0) continue;
|
||||
|
||||
const message: Record<string, unknown> = {
|
||||
role,
|
||||
};
|
||||
if (hasContent) {
|
||||
message.content = content;
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toolCalls;
|
||||
if (!hasContent) message.content = '';
|
||||
}
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function buildOpenAiBodyFromGeminiRequest(input: {
|
||||
body: GeminiRecord;
|
||||
modelName: string;
|
||||
stream: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const openAiBody: Record<string, unknown> = {
|
||||
model: input.modelName,
|
||||
stream: input.stream,
|
||||
messages: buildGeminiMessages(input.body),
|
||||
};
|
||||
|
||||
const generationConfig = isRecord(input.body.generationConfig) ? input.body.generationConfig : null;
|
||||
const maxTokens = Number(
|
||||
generationConfig?.maxOutputTokens
|
||||
?? input.body.max_output_tokens
|
||||
?? input.body.max_completion_tokens
|
||||
?? input.body.max_tokens
|
||||
?? 0,
|
||||
);
|
||||
if (Number.isFinite(maxTokens) && maxTokens > 0) {
|
||||
openAiBody.max_tokens = Math.trunc(maxTokens);
|
||||
}
|
||||
const temperature = Number(generationConfig?.temperature);
|
||||
if (Number.isFinite(temperature)) openAiBody.temperature = temperature;
|
||||
const topP = Number(generationConfig?.topP);
|
||||
if (Number.isFinite(topP)) openAiBody.top_p = topP;
|
||||
if (Array.isArray(generationConfig?.stopSequences) && generationConfig!.stopSequences.length > 0) {
|
||||
openAiBody.stop = generationConfig!.stopSequences;
|
||||
}
|
||||
|
||||
const tools = extractToolDeclarations(input.body.tools);
|
||||
if (tools) {
|
||||
openAiBody.tools = tools;
|
||||
}
|
||||
const toolChoice = extractToolChoice(input.body.toolConfig);
|
||||
if (toolChoice) {
|
||||
openAiBody.tool_choice = toolChoice;
|
||||
}
|
||||
|
||||
const reasoning = extractReasoningMetadataFromGeminiRequest(input.body);
|
||||
if (reasoning) {
|
||||
openAiBody.reasoning = {
|
||||
effort: reasoning.reasoningEffort,
|
||||
budget_tokens: reasoning.reasoningBudget,
|
||||
};
|
||||
openAiBody.reasoning_effort = reasoning.reasoningEffort;
|
||||
}
|
||||
|
||||
return openAiBody;
|
||||
}
|
||||
|
||||
function mapFinishReason(finishReason: string): string {
|
||||
const normalized = finishReason.trim().toLowerCase();
|
||||
if (!normalized) return 'STOP';
|
||||
if (normalized === 'stop' || normalized === 'completed' || normalized === 'tool_calls') return 'STOP';
|
||||
if (normalized === 'length' || normalized === 'max_tokens' || normalized === 'max_output_tokens') return 'MAX_TOKENS';
|
||||
if (normalized === 'content_filter' || normalized === 'safety') return 'SAFETY';
|
||||
return normalized.toUpperCase().replace(/[^A-Z0-9_]+/g, '_');
|
||||
}
|
||||
|
||||
function buildUsageMetadata(usage?: {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
}): Record<string, number> | undefined {
|
||||
if (!usage) return undefined;
|
||||
const metadata: Record<string, number> = {};
|
||||
if (typeof usage.promptTokens === 'number' && Number.isFinite(usage.promptTokens)) {
|
||||
metadata.promptTokenCount = usage.promptTokens;
|
||||
}
|
||||
if (typeof usage.completionTokens === 'number' && Number.isFinite(usage.completionTokens)) {
|
||||
metadata.candidatesTokenCount = usage.completionTokens;
|
||||
}
|
||||
if (typeof usage.totalTokens === 'number' && Number.isFinite(usage.totalTokens)) {
|
||||
metadata.totalTokenCount = usage.totalTokens;
|
||||
}
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
}
|
||||
|
||||
export function serializeNormalizedFinalToGemini(input: {
|
||||
normalized: NormalizedFinalResponse;
|
||||
usage?: {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
};
|
||||
}): Record<string, unknown> {
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (input.normalized.reasoningContent) {
|
||||
parts.push({
|
||||
text: input.normalized.reasoningContent,
|
||||
thought: true,
|
||||
});
|
||||
}
|
||||
if (input.normalized.content) {
|
||||
parts.push({
|
||||
text: input.normalized.content,
|
||||
});
|
||||
}
|
||||
for (const toolCall of input.normalized.toolCalls) {
|
||||
parts.push({
|
||||
functionCall: {
|
||||
...(toolCall.id ? { id: toolCall.id } : {}),
|
||||
name: toolCall.name,
|
||||
args: parseJsonIfPossible(toolCall.arguments),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
responseId: input.normalized.id || '',
|
||||
modelVersion: input.normalized.model || '',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
role: 'model',
|
||||
parts,
|
||||
},
|
||||
finishReason: mapFinishReason(input.normalized.finishReason),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const usageMetadata = buildUsageMetadata(input.usage);
|
||||
if (usageMetadata) {
|
||||
response.usageMetadata = usageMetadata;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -83,6 +83,7 @@ import { geminiGenerateContentStream } from './stream.js';
|
||||
import { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js';
|
||||
import { geminiGenerateContentUsage } from './usage.js';
|
||||
import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js';
|
||||
import { buildOpenAiBodyFromGeminiRequest, serializeNormalizedFinalToGemini } from './compatibility.js';
|
||||
|
||||
export const geminiGenerateContentTransformer = {
|
||||
protocol: 'gemini/generate-content' as const,
|
||||
@@ -98,6 +99,10 @@ export const geminiGenerateContentTransformer = {
|
||||
reasoningEffortToGeminiThinkingConfig,
|
||||
geminiThinkingConfigToReasoning,
|
||||
},
|
||||
compatibility: {
|
||||
buildOpenAiBodyFromGeminiRequest,
|
||||
serializeNormalizedFinalToGemini,
|
||||
},
|
||||
parseProxyRequestPath: parseGeminiProxyRequestPath,
|
||||
resolveProxyApiVersion: resolveGeminiProxyApiVersion,
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
@@ -114,4 +119,6 @@ export {
|
||||
geminiGenerateContentUsage,
|
||||
reasoningEffortToGeminiThinkingConfig,
|
||||
geminiThinkingConfigToReasoning,
|
||||
buildOpenAiBodyFromGeminiRequest,
|
||||
serializeNormalizedFinalToGemini,
|
||||
};
|
||||
|
||||
@@ -88,7 +88,7 @@ export type TransformerMetadata = {
|
||||
};
|
||||
|
||||
export type NormalizedRequest = {
|
||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
|
||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli';
|
||||
model: string;
|
||||
stream: boolean;
|
||||
rawBody: unknown;
|
||||
@@ -98,7 +98,7 @@ export type NormalizedRequest = {
|
||||
};
|
||||
|
||||
export type NormalizedResponseEnvelope = {
|
||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
|
||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli';
|
||||
model: string;
|
||||
final: import('./chatFormatsCore.js').NormalizedFinalResponse;
|
||||
usage?: unknown;
|
||||
@@ -107,7 +107,7 @@ export type NormalizedResponseEnvelope = {
|
||||
};
|
||||
|
||||
export type NormalizedStreamEnvelope = {
|
||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
|
||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli';
|
||||
model: string;
|
||||
event: import('./chatFormatsCore.js').NormalizedStreamEvent;
|
||||
metadata?: TransformerMetadata;
|
||||
|
||||
@@ -7,7 +7,8 @@ export type TransformerProtocol =
|
||||
| 'openai/chat'
|
||||
| 'anthropic/messages'
|
||||
| 'openai/responses'
|
||||
| 'gemini/generate-content';
|
||||
| 'gemini/generate-content'
|
||||
| 'gemini-cli/generate-content';
|
||||
|
||||
export type ProtocolRequestEnvelope<
|
||||
TProtocol extends TransformerProtocol = TransformerProtocol,
|
||||
|
||||
Reference in New Issue
Block a user