Merge pull request #141 from cita-777/Oauth-Management
feat: expand gemini proxy compatibility matrix
This commit is contained in:
@@ -6,7 +6,7 @@ export const sites = sqliteTable('sites', {
|
|||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
url: text('url').notNull(),
|
url: text('url').notNull(),
|
||||||
externalCheckinUrl: text('external_checkin_url'),
|
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'),
|
proxyUrl: text('proxy_url'),
|
||||||
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
|
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
|
||||||
customHeaders: text('custom_headers'),
|
customHeaders: text('custom_headers'),
|
||||||
|
|||||||
@@ -83,10 +83,52 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
loginType: 'oauth',
|
loginType: 'oauth',
|
||||||
requiresProjectId: true,
|
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 () => {
|
it('starts a codex oauth session and exposes pending status', async () => {
|
||||||
const startResponse = await app.inject({
|
const startResponse = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ describe('chat proxy codex oauth compatibility', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const { claudeMessagesProxyRoute } = await import('./chat.js');
|
const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js');
|
||||||
app = Fastify();
|
app = Fastify();
|
||||||
|
await app.register(chatProxyRoute);
|
||||||
await app.register(claudeMessagesProxyRoute);
|
await app.register(claudeMessagesProxyRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,4 +177,72 @@ describe('chat proxy codex oauth compatibility', () => {
|
|||||||
expect(body.content?.[0]?.type).toBe('text');
|
expect(body.content?.[0]?.type).toBe('text');
|
||||||
expect(body.content?.[0]?.text).toContain('pong from codex');
|
expect(body.content?.[0]?.text).toContain('pong from codex');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('translates codex SSE into Claude messages stream events instead of leaking raw response events', async () => {
|
||||||
|
fetchMock.mockResolvedValue(createSseResponse([
|
||||||
|
'event: response.created\n',
|
||||||
|
'data: {"type":"response.created","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
|
||||||
|
'event: response.output_item.added\n',
|
||||||
|
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_claude_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
|
||||||
|
'event: response.content_part.added\n',
|
||||||
|
'data: {"type":"response.content_part.added","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":""}}\n\n',
|
||||||
|
'event: response.output_text.delta\n',
|
||||||
|
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_claude_stream","content_index":0,"delta":"pong from codex"}\n\n',
|
||||||
|
'event: response.content_part.done\n',
|
||||||
|
'data: {"type":"response.content_part.done","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":"pong from codex"}}\n\n',
|
||||||
|
'event: response.completed\n',
|
||||||
|
'data: {"type":"response.completed","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/messages',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
stream: true,
|
||||||
|
max_tokens: 256,
|
||||||
|
messages: [{ role: 'user', content: 'hello codex' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
|
expect(response.body).toContain('event: message_start');
|
||||||
|
expect(response.body).toContain('event: content_block_delta');
|
||||||
|
expect(response.body).toContain('pong from codex');
|
||||||
|
expect(response.body).not.toContain('event: response.created');
|
||||||
|
expect(response.body).not.toContain('"type":"response.output_text.delta"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates codex SSE into OpenAI chat completion chunks instead of leaking raw response events', async () => {
|
||||||
|
fetchMock.mockResolvedValue(createSseResponse([
|
||||||
|
'event: response.created\n',
|
||||||
|
'data: {"type":"response.created","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
|
||||||
|
'event: response.output_item.added\n',
|
||||||
|
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_openai_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
|
||||||
|
'event: response.output_text.delta\n',
|
||||||
|
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_openai_stream","delta":"pong from codex"}\n\n',
|
||||||
|
'event: response.completed\n',
|
||||||
|
'data: {"type":"response.completed","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/chat/completions',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
stream: true,
|
||||||
|
messages: [{ role: 'user', content: 'hello codex' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
|
expect(response.body).toContain('"object":"chat.completion.chunk"');
|
||||||
|
expect(response.body).toContain('pong from codex');
|
||||||
|
expect(response.body).not.toContain('event: response.created');
|
||||||
|
expect(response.body).not.toContain('"type":"response.output_text.delta"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import path from 'node:path';
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
|
const fetchModelPricingCatalogMock = vi.fn();
|
||||||
|
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||||
const selectChannelMock = vi.fn();
|
const selectChannelMock = vi.fn();
|
||||||
const selectNextChannelMock = vi.fn();
|
const selectNextChannelMock = vi.fn();
|
||||||
const recordSuccessMock = vi.fn();
|
const recordSuccessMock = vi.fn();
|
||||||
@@ -13,6 +15,8 @@ const invalidateTokenRouterCacheMock = vi.fn();
|
|||||||
const authorizeDownstreamTokenMock = vi.fn();
|
const authorizeDownstreamTokenMock = vi.fn();
|
||||||
const consumeManagedKeyRequestMock = vi.fn();
|
const consumeManagedKeyRequestMock = vi.fn();
|
||||||
const isModelAllowedByPolicyOrAllowedRoutesMock = vi.fn();
|
const isModelAllowedByPolicyOrAllowedRoutesMock = vi.fn();
|
||||||
|
const dbSelectAllMock = vi.fn();
|
||||||
|
const dbSelectGetMock = vi.fn();
|
||||||
const dbInsertValuesMock = vi.fn((_values?: unknown) => ({
|
const dbInsertValuesMock = vi.fn((_values?: unknown) => ({
|
||||||
run: () => undefined,
|
run: () => undefined,
|
||||||
}));
|
}));
|
||||||
@@ -20,10 +24,34 @@ const dbInsertMock = vi.fn((_table?: unknown) => ({
|
|||||||
values: (values: unknown) => dbInsertValuesMock(values),
|
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', () => ({
|
vi.mock('undici', () => ({
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
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', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||||
@@ -43,10 +71,29 @@ vi.mock('../../services/downstreamApiKeyService.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../db/index.js', () => ({
|
vi.mock('../../db/index.js', () => ({
|
||||||
db: {
|
db: {
|
||||||
|
select: (..._args: unknown[]) => createDbSelectChain(),
|
||||||
insert: (arg: unknown) => dbInsertMock(arg),
|
insert: (arg: unknown) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
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(() => {
|
beforeEach(() => {
|
||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
|
fetchModelPricingCatalogMock.mockReset();
|
||||||
|
refreshModelsAndRebuildRoutesMock.mockReset();
|
||||||
selectChannelMock.mockReset();
|
selectChannelMock.mockReset();
|
||||||
selectNextChannelMock.mockReset();
|
selectNextChannelMock.mockReset();
|
||||||
recordSuccessMock.mockReset();
|
recordSuccessMock.mockReset();
|
||||||
@@ -111,6 +160,8 @@ describe('gemini native proxy routes', () => {
|
|||||||
isModelAllowedByPolicyOrAllowedRoutesMock.mockReset();
|
isModelAllowedByPolicyOrAllowedRoutesMock.mockReset();
|
||||||
dbInsertMock.mockClear();
|
dbInsertMock.mockClear();
|
||||||
dbInsertValuesMock.mockClear();
|
dbInsertValuesMock.mockClear();
|
||||||
|
dbSelectAllMock.mockReset();
|
||||||
|
dbSelectGetMock.mockReset();
|
||||||
|
|
||||||
authorizeDownstreamTokenMock.mockResolvedValue({
|
authorizeDownstreamTokenMock.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -118,6 +169,10 @@ describe('gemini native proxy routes', () => {
|
|||||||
token: 'sk-managed-gemini',
|
token: 'sk-managed-gemini',
|
||||||
policy: {},
|
policy: {},
|
||||||
});
|
});
|
||||||
|
fetchModelPricingCatalogMock.mockResolvedValue(null);
|
||||||
|
refreshModelsAndRebuildRoutesMock.mockResolvedValue(undefined);
|
||||||
|
dbSelectGetMock.mockResolvedValue(null);
|
||||||
|
dbSelectAllMock.mockResolvedValue([]);
|
||||||
|
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
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 () => {
|
it('forwards native generateContent requests through the gemini route group', async () => {
|
||||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
candidates: [
|
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 () => {
|
it('filters Gemini native model list by downstream managed-key policy', async () => {
|
||||||
authorizeDownstreamTokenMock.mockResolvedValue({
|
authorizeDownstreamTokenMock.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
+481
-207
@@ -1,6 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
@@ -8,11 +9,20 @@ import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstream
|
|||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
||||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.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 { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||||
|
import { executeEndpointFlow } from './endpointFlow.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
|
import {
|
||||||
|
buildUpstreamEndpointRequest,
|
||||||
|
resolveUpstreamEndpointCandidates,
|
||||||
|
} from './upstreamEndpoint.js';
|
||||||
import {
|
import {
|
||||||
geminiGenerateContentTransformer,
|
geminiGenerateContentTransformer,
|
||||||
} from '../../transformers/gemini/generate-content/index.js';
|
} from '../../transformers/gemini/generate-content/index.js';
|
||||||
|
import { createChatEndpointStrategy } from '../../transformers/shared/chatEndpointStrategy.js';
|
||||||
|
import { normalizeUpstreamFinalResponse } from '../../transformers/shared/normalized.js';
|
||||||
import {
|
import {
|
||||||
createGeminiCliStreamReader,
|
createGeminiCliStreamReader,
|
||||||
unwrapGeminiCliPayload,
|
unwrapGeminiCliPayload,
|
||||||
@@ -45,6 +55,14 @@ function isGeminiCliPlatform(platform: unknown): boolean {
|
|||||||
return String(platform || '').trim().toLowerCase() === 'gemini-cli';
|
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: {
|
function buildGeminiCliActionPath(input: {
|
||||||
isStreamAction: boolean;
|
isStreamAction: boolean;
|
||||||
isCountTokensAction: boolean;
|
isCountTokensAction: boolean;
|
||||||
@@ -54,6 +72,17 @@ function buildGeminiCliActionPath(input: {
|
|||||||
return '/v1internal:generateContent';
|
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) {
|
async function selectGeminiChannel(request: FastifyRequest) {
|
||||||
const policy = getDownstreamRoutingPolicy(request);
|
const policy = getDownstreamRoutingPolicy(request);
|
||||||
for (const candidate of GEMINI_MODEL_PROBES) {
|
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(
|
async function logProxy(
|
||||||
selected: any,
|
selected: any,
|
||||||
modelRequested: string,
|
modelRequested: string,
|
||||||
@@ -194,6 +258,15 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
excludeChannelIds.push(selected.channel.id);
|
excludeChannelIds.push(selected.channel.id);
|
||||||
|
|
||||||
try {
|
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)) {
|
if (isGeminiCliPlatform(selected.site.platform)) {
|
||||||
const filtered = await filterGeminiListedModelsForPolicy(
|
const filtered = await filterGeminiListedModelsForPolicy(
|
||||||
{ models: GEMINI_CLI_STATIC_MODELS },
|
{ models: GEMINI_CLI_STATIC_MODELS },
|
||||||
@@ -243,13 +316,36 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateContent = async (request: FastifyRequest, reply: FastifyReply) => {
|
const handleGenerateContent = async (
|
||||||
const parsedPath = geminiGenerateContentTransformer.parseProxyRequestPath({
|
request: FastifyRequest,
|
||||||
rawUrl: request.raw.url || request.url || '',
|
reply: FastifyReply,
|
||||||
params: request.params as { geminiApiVersion?: string } | undefined,
|
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 { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath;
|
||||||
const isCountTokensAction = modelActionPath.endsWith(':countTokens');
|
const isCountTokensAction = isGeminiCliDownstream
|
||||||
|
? options?.action === 'countTokens'
|
||||||
|
: modelActionPath.endsWith(':countTokens');
|
||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
|
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);
|
excludeChannelIds.push(selected.channel.id);
|
||||||
|
|
||||||
const body = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
const actualModel = selected.actualModel || requestedModel;
|
||||||
request.body || {},
|
const normalizedBody = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||||
selected.actualModel || requestedModel,
|
isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}),
|
||||||
|
actualModel,
|
||||||
);
|
);
|
||||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||||
const isGeminiCli = isGeminiCliPlatform(selected.site.platform);
|
const isGeminiCli = isGeminiCliPlatform(selected.site.platform);
|
||||||
if (isGeminiCli && !oauth?.projectId) {
|
const isInternalGemini = isInternalGeminiPlatform(selected.site.platform);
|
||||||
lastStatus = 500;
|
const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform);
|
||||||
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 startTime = Date.now();
|
const startTime = Date.now();
|
||||||
try {
|
let upstreamPath = '';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return reply.code(lastStatus).send(JSON.parse(lastText));
|
if (isDirectGeminiFamily) {
|
||||||
} catch {
|
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);
|
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) {
|
const actualModelAction = modelActionPath.replace(
|
||||||
reply.hijack();
|
/^models\/[^:]+/,
|
||||||
reply.raw.statusCode = upstream.status;
|
`models/${actualModel}`,
|
||||||
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
|
);
|
||||||
const upstreamReader = upstream.body?.getReader();
|
upstreamPath = isInternalGemini
|
||||||
const reader = isGeminiCliPlatform(selected.site.platform) && upstreamReader
|
? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction })
|
||||||
? createGeminiCliStreamReader(upstreamReader)
|
: resolveUpstreamPath(apiVersion, actualModelAction);
|
||||||
: upstreamReader;
|
const query = new URLSearchParams(request.query as Record<string, string>).toString();
|
||||||
if (!reader) {
|
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;
|
const latency = Date.now() - startTime;
|
||||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||||
await logProxy(
|
await logProxy(
|
||||||
@@ -395,110 +604,158 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
);
|
);
|
||||||
reply.raw.end();
|
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
||||||
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();
|
if (isCountTokensAction) {
|
||||||
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
|
lastStatus = 501;
|
||||||
let parsedUsage = EMPTY_PROXY_USAGE;
|
lastContentType = 'application/json';
|
||||||
try {
|
lastText = JSON.stringify({
|
||||||
const parsed = JSON.parse(text);
|
error: {
|
||||||
const unwrappedPayload = unwrapGeminiCliPayload(parsed);
|
message: 'Gemini countTokens compatibility is not implemented for this upstream',
|
||||||
const responsePayload = isCountTokensAction
|
type: 'invalid_request_error',
|
||||||
? unwrappedPayload
|
},
|
||||||
: geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
|
});
|
||||||
aggregateState,
|
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
lastStatus = 502;
|
lastStatus = 502;
|
||||||
lastContentType = 'application/json';
|
lastContentType = 'application/json';
|
||||||
@@ -518,7 +775,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
error instanceof Error ? error.message : 'Gemini upstream request failed',
|
error instanceof Error ? error.message : 'Gemini upstream request failed',
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath || null,
|
||||||
);
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
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('/v1beta/models', listModels);
|
||||||
app.get('/gemini/:geminiApiVersion/models', listModels);
|
app.get('/gemini/:geminiApiVersion/models', listModels);
|
||||||
app.post('/v1beta/models/*', generateContent);
|
app.post('/v1beta/models/*', generateContent);
|
||||||
app.post('/gemini/:geminiApiVersion/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', () => {
|
it('uses bearer auth for claude oauth upstream requests', () => {
|
||||||
const request = buildUpstreamEndpointRequest({
|
const request = buildUpstreamEndpointRequest({
|
||||||
endpoint: 'messages',
|
endpoint: 'messages',
|
||||||
|
|||||||
@@ -332,6 +332,10 @@ function preferredEndpointOrder(
|
|||||||
return ['chat'];
|
return ['chat'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (platform === 'antigravity') {
|
||||||
|
return ['chat'];
|
||||||
|
}
|
||||||
|
|
||||||
if (platform === 'openai') {
|
if (platform === 'openai') {
|
||||||
if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') {
|
if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') {
|
||||||
// Some OpenAI-compatible gateways expose Claude natively via /v1/messages.
|
// 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 === 'claude') return ['messages'] as UpstreamEndpoint[];
|
||||||
if (sitePlatform === 'gemini') return ['responses', 'chat'] 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[];
|
if (preferMessagesForClaudeModel) return ['messages', 'responses', 'chat'] as UpstreamEndpoint[];
|
||||||
return ['responses', 'messages', 'chat'] as UpstreamEndpoint[];
|
return ['responses', 'messages', 'chat'] as UpstreamEndpoint[];
|
||||||
})()
|
})()
|
||||||
@@ -428,6 +433,8 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
&& preferMessagesForClaudeModel
|
&& preferMessagesForClaudeModel
|
||||||
&& sitePlatform !== 'openai'
|
&& sitePlatform !== 'openai'
|
||||||
&& sitePlatform !== 'gemini'
|
&& sitePlatform !== 'gemini'
|
||||||
|
&& sitePlatform !== 'antigravity'
|
||||||
|
&& sitePlatform !== 'gemini-cli'
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -532,6 +539,8 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
const isClaudeUpstream = sitePlatform === 'claude';
|
const isClaudeUpstream = sitePlatform === 'claude';
|
||||||
const isGeminiUpstream = sitePlatform === 'gemini';
|
const isGeminiUpstream = sitePlatform === 'gemini';
|
||||||
const isGeminiCliUpstream = sitePlatform === 'gemini-cli';
|
const isGeminiCliUpstream = sitePlatform === 'gemini-cli';
|
||||||
|
const isAntigravityUpstream = sitePlatform === 'antigravity';
|
||||||
|
const isInternalGeminiUpstream = isGeminiCliUpstream || isAntigravityUpstream;
|
||||||
const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude';
|
const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude';
|
||||||
|
|
||||||
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
|
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
|
||||||
@@ -562,7 +571,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
return '/responses';
|
return '/responses';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sitePlatform === 'gemini-cli') {
|
if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') {
|
||||||
return input.stream
|
return input.stream
|
||||||
? '/v1internal:streamGenerateContent?alt=sse'
|
? '/v1internal:streamGenerateContent?alt=sse'
|
||||||
: '/v1internal:generateContent';
|
: '/v1internal:generateContent';
|
||||||
@@ -589,7 +598,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
|
|
||||||
const stripGeminiUnsupportedFields = (body: Record<string, unknown>) => {
|
const stripGeminiUnsupportedFields = (body: Record<string, unknown>) => {
|
||||||
const next = { ...body };
|
const next = { ...body };
|
||||||
if (isGeminiUpstream || isGeminiCliUpstream) {
|
if (isGeminiUpstream || isInternalGeminiUpstream) {
|
||||||
for (const key of [
|
for (const key of [
|
||||||
'frequency_penalty',
|
'frequency_penalty',
|
||||||
'presence_penalty',
|
'presence_penalty',
|
||||||
@@ -606,9 +615,9 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
|
|
||||||
const openaiBody = stripGeminiUnsupportedFields(input.openaiBody);
|
const openaiBody = stripGeminiUnsupportedFields(input.openaiBody);
|
||||||
|
|
||||||
if (isGeminiCliUpstream) {
|
if (isInternalGeminiUpstream) {
|
||||||
const projectId = asTrimmedString(input.oauthProjectId);
|
const projectId = asTrimmedString(input.oauthProjectId);
|
||||||
if (!projectId) {
|
if (isGeminiCliUpstream && !projectId) {
|
||||||
throw new Error('gemini-cli oauth project id missing');
|
throw new Error('gemini-cli oauth project id missing');
|
||||||
}
|
}
|
||||||
const instructions = (
|
const instructions = (
|
||||||
@@ -628,7 +637,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
headers,
|
headers,
|
||||||
body: wrapGeminiCliRequest({
|
body: wrapGeminiCliRequest({
|
||||||
modelName: input.modelName,
|
modelName: input.modelName,
|
||||||
projectId,
|
projectId: projectId || '',
|
||||||
request: geminiRequest,
|
request: geminiRequest,
|
||||||
}) as Record<string, unknown>,
|
}) 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.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||||
expect(parsed.runtimeHealth?.state).toBe('unhealthy');
|
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 { codexOauthProvider } from './codexProvider.js';
|
||||||
import { claudeOauthProvider } from './claudeProvider.js';
|
import { claudeOauthProvider } from './claudeProvider.js';
|
||||||
import { geminiCliOauthProvider } from './geminiCliProvider.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 = {
|
export type OAuthProviderMetadata = {
|
||||||
provider: OAuthProviderId;
|
provider: OAuthProviderId;
|
||||||
@@ -85,6 +86,7 @@ const PROVIDERS: OAuthProviderDefinition[] = [
|
|||||||
codexOauthProvider,
|
codexOauthProvider,
|
||||||
claudeOauthProvider,
|
claudeOauthProvider,
|
||||||
geminiCliOauthProvider,
|
geminiCliOauthProvider,
|
||||||
|
antigravityOauthProvider,
|
||||||
];
|
];
|
||||||
|
|
||||||
const PROVIDER_BY_ID = new Map(PROVIDERS.map((provider) => [provider.metadata.provider, provider] as const));
|
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');
|
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', () => {
|
it('supports dedicated codex adapter and aliases', () => {
|
||||||
expect(getAdapter('codex')?.platformName).toBe('codex');
|
expect(getAdapter('codex')?.platformName).toBe('codex');
|
||||||
expect(getAdapter('chatgpt-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 { ClaudeAdapter } from './claude.js';
|
||||||
import { GeminiAdapter } from './gemini.js';
|
import { GeminiAdapter } from './gemini.js';
|
||||||
import { GeminiCliAdapter } from './geminiCli.js';
|
import { GeminiCliAdapter } from './geminiCli.js';
|
||||||
|
import { AntigravityAdapter } from './antigravity.js';
|
||||||
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
||||||
import { detectPlatformByTitle } from './titleHint.js';
|
import { detectPlatformByTitle } from './titleHint.js';
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ const adapters: PlatformAdapter[] = [
|
|||||||
new ClaudeAdapter(),
|
new ClaudeAdapter(),
|
||||||
new GeminiAdapter(),
|
new GeminiAdapter(),
|
||||||
new GeminiCliAdapter(),
|
new GeminiCliAdapter(),
|
||||||
|
new AntigravityAdapter(),
|
||||||
new CliProxyApiAdapter(),
|
new CliProxyApiAdapter(),
|
||||||
new AnyRouterAdapter(),
|
new AnyRouterAdapter(),
|
||||||
new DoneHubAdapter(),
|
new DoneHubAdapter(),
|
||||||
@@ -60,6 +62,8 @@ const platformAliases: Record<string, string> = {
|
|||||||
claude: 'claude',
|
claude: 'claude',
|
||||||
gemini: 'gemini',
|
gemini: 'gemini',
|
||||||
'gemini-cli': 'gemini-cli',
|
'gemini-cli': 'gemini-cli',
|
||||||
|
antigravity: 'antigravity',
|
||||||
|
'anti-gravity': 'antigravity',
|
||||||
google: 'gemini',
|
google: 'gemini',
|
||||||
// CLIProxyAPI aliases
|
// CLIProxyAPI aliases
|
||||||
cliproxyapi: 'cliproxyapi',
|
cliproxyapi: 'cliproxyapi',
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ function extractHtmlTitle(html: string): string {
|
|||||||
return match[1].replace(/\s+/g, ' ').trim();
|
return match[1].replace(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatform | undefined> {
|
async function detectPlatformByTitleOnce(base: string): Promise<TitleHintPlatform | undefined> {
|
||||||
const base = normalizeBaseUrl(url);
|
|
||||||
if (!base) return undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { fetch } = await import('undici');
|
const { fetch } = await import('undici');
|
||||||
const res = await fetch(`${base}/`, {
|
const res = await fetch(`${base}/`, {
|
||||||
@@ -71,3 +68,16 @@ export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatf
|
|||||||
return undefined;
|
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 { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js';
|
||||||
import { geminiGenerateContentUsage } from './usage.js';
|
import { geminiGenerateContentUsage } from './usage.js';
|
||||||
import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js';
|
import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js';
|
||||||
|
import { buildOpenAiBodyFromGeminiRequest, serializeNormalizedFinalToGemini } from './compatibility.js';
|
||||||
|
|
||||||
export const geminiGenerateContentTransformer = {
|
export const geminiGenerateContentTransformer = {
|
||||||
protocol: 'gemini/generate-content' as const,
|
protocol: 'gemini/generate-content' as const,
|
||||||
@@ -98,6 +99,10 @@ export const geminiGenerateContentTransformer = {
|
|||||||
reasoningEffortToGeminiThinkingConfig,
|
reasoningEffortToGeminiThinkingConfig,
|
||||||
geminiThinkingConfigToReasoning,
|
geminiThinkingConfigToReasoning,
|
||||||
},
|
},
|
||||||
|
compatibility: {
|
||||||
|
buildOpenAiBodyFromGeminiRequest,
|
||||||
|
serializeNormalizedFinalToGemini,
|
||||||
|
},
|
||||||
parseProxyRequestPath: parseGeminiProxyRequestPath,
|
parseProxyRequestPath: parseGeminiProxyRequestPath,
|
||||||
resolveProxyApiVersion: resolveGeminiProxyApiVersion,
|
resolveProxyApiVersion: resolveGeminiProxyApiVersion,
|
||||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||||
@@ -114,4 +119,6 @@ export {
|
|||||||
geminiGenerateContentUsage,
|
geminiGenerateContentUsage,
|
||||||
reasoningEffortToGeminiThinkingConfig,
|
reasoningEffortToGeminiThinkingConfig,
|
||||||
geminiThinkingConfigToReasoning,
|
geminiThinkingConfigToReasoning,
|
||||||
|
buildOpenAiBodyFromGeminiRequest,
|
||||||
|
serializeNormalizedFinalToGemini,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export type TransformerMetadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type NormalizedRequest = {
|
export type NormalizedRequest = {
|
||||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
|
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli';
|
||||||
model: string;
|
model: string;
|
||||||
stream: boolean;
|
stream: boolean;
|
||||||
rawBody: unknown;
|
rawBody: unknown;
|
||||||
@@ -98,7 +98,7 @@ export type NormalizedRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type NormalizedResponseEnvelope = {
|
export type NormalizedResponseEnvelope = {
|
||||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
|
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli';
|
||||||
model: string;
|
model: string;
|
||||||
final: import('./chatFormatsCore.js').NormalizedFinalResponse;
|
final: import('./chatFormatsCore.js').NormalizedFinalResponse;
|
||||||
usage?: unknown;
|
usage?: unknown;
|
||||||
@@ -107,7 +107,7 @@ export type NormalizedResponseEnvelope = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type NormalizedStreamEnvelope = {
|
export type NormalizedStreamEnvelope = {
|
||||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
|
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli';
|
||||||
model: string;
|
model: string;
|
||||||
event: import('./chatFormatsCore.js').NormalizedStreamEvent;
|
event: import('./chatFormatsCore.js').NormalizedStreamEvent;
|
||||||
metadata?: TransformerMetadata;
|
metadata?: TransformerMetadata;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export type TransformerProtocol =
|
|||||||
| 'openai/chat'
|
| 'openai/chat'
|
||||||
| 'anthropic/messages'
|
| 'anthropic/messages'
|
||||||
| 'openai/responses'
|
| 'openai/responses'
|
||||||
| 'gemini/generate-content';
|
| 'gemini/generate-content'
|
||||||
|
| 'gemini-cli/generate-content';
|
||||||
|
|
||||||
export type ProtocolRequestEnvelope<
|
export type ProtocolRequestEnvelope<
|
||||||
TProtocol extends TransformerProtocol = TransformerProtocol,
|
TProtocol extends TransformerProtocol = TransformerProtocol,
|
||||||
|
|||||||
@@ -374,6 +374,289 @@ describe('OAuthManagement page', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('explains oauth usage and disables unavailable providers', async () => {
|
||||||
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: 'codex',
|
||||||
|
label: 'Codex',
|
||||||
|
platform: 'codex',
|
||||||
|
enabled: false,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: false,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
apiMock.getOAuthConnections.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<OAuthManagement />
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
const text = collectText(root!.root);
|
||||||
|
expect(text).toContain('官方上游连接');
|
||||||
|
expect(text).toContain('CLI');
|
||||||
|
expect(text).toContain('下游密钥');
|
||||||
|
});
|
||||||
|
|
||||||
|
const startButton = root!.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& collectText(node).includes('当前不可用')
|
||||||
|
));
|
||||||
|
expect(startButton.props.disabled).toBe(true);
|
||||||
|
expect(collectText(root!.root)).toContain('当前环境未启用');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers renamed account title and still shows oauth email', async () => {
|
||||||
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: 'codex',
|
||||||
|
label: 'Codex',
|
||||||
|
platform: 'codex',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: false,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
apiMock.getOAuthConnections.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
accountId: 7,
|
||||||
|
provider: 'codex',
|
||||||
|
username: 'Juricek Team A',
|
||||||
|
email: 'juricek.chen@gmail.com',
|
||||||
|
accountKey: 'chatgpt-account-123',
|
||||||
|
planType: 'team',
|
||||||
|
modelCount: 11,
|
||||||
|
modelsPreview: ['gpt-5.4'],
|
||||||
|
status: 'healthy',
|
||||||
|
routeChannelCount: 11,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<OAuthManagement />
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
const text = collectText(root!.root);
|
||||||
|
expect(text).toContain('Juricek Team A');
|
||||||
|
expect(text).toContain('juricek.chen@gmail.com');
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows starting gemini oauth without entering a project id', async () => {
|
||||||
|
promptMock.mockReturnValueOnce('');
|
||||||
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
label: 'Gemini CLI',
|
||||||
|
platform: 'gemini-cli',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: true,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
apiMock.getOAuthConnections.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
apiMock.startOAuthProvider.mockResolvedValue({
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
state: 'gemini-state-123',
|
||||||
|
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-state-123',
|
||||||
|
instructions: {
|
||||||
|
redirectUri: 'http://localhost:8085/oauth2callback',
|
||||||
|
callbackPort: 8085,
|
||||||
|
callbackPath: '/oauth2callback',
|
||||||
|
manualCallbackDelayMs: 15000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiMock.getOAuthSession.mockResolvedValue({
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
state: 'gemini-state-123',
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<OAuthManagement />
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const startButton = root!.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('连接 Gemini CLI')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await startButton.props.onClick();
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(promptMock).toHaveBeenCalled();
|
||||||
|
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('gemini-cli', { projectId: undefined });
|
||||||
|
expect(openMock).toHaveBeenCalledWith(
|
||||||
|
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-state-123',
|
||||||
|
'oauth-gemini-cli',
|
||||||
|
expect.stringContaining('width=540'),
|
||||||
|
);
|
||||||
|
expect(collectText(root!.root)).not.toContain('Gemini CLI 连接需要 Project ID');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebinds gemini oauth without prompting for a project id again', async () => {
|
||||||
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
label: 'Gemini CLI',
|
||||||
|
platform: 'gemini-cli',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: true,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
apiMock.getOAuthConnections.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
accountId: 11,
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
email: 'gemini-user@example.com',
|
||||||
|
planType: 'cloud',
|
||||||
|
modelCount: 2,
|
||||||
|
modelsPreview: ['gemini-2.5-pro', 'gemini-2.5-flash'],
|
||||||
|
status: 'healthy',
|
||||||
|
projectId: 'project-demo',
|
||||||
|
routeChannelCount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
apiMock.rebindOAuthConnection.mockResolvedValue({
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
state: 'gemini-rebind-123',
|
||||||
|
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123',
|
||||||
|
instructions: {
|
||||||
|
redirectUri: 'http://localhost:8085/oauth2callback',
|
||||||
|
callbackPort: 8085,
|
||||||
|
callbackPath: '/oauth2callback',
|
||||||
|
manualCallbackDelayMs: 15000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiMock.getOAuthSession.mockResolvedValue({
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
state: 'gemini-rebind-123',
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<OAuthManagement />
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const rebindButton = root!.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('重新授权')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await rebindButton.props.onClick();
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(promptMock).not.toHaveBeenCalled();
|
||||||
|
expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11);
|
||||||
|
expect(openMock).toHaveBeenCalledWith(
|
||||||
|
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123',
|
||||||
|
'oauth-gemini-cli',
|
||||||
|
expect.stringContaining('width=540'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('shows oauth connection status metadata and allows deleting a connection', async () => {
|
it('shows oauth connection status metadata and allows deleting a connection', async () => {
|
||||||
apiMock.getOAuthProviders.mockResolvedValue({
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -34,6 +34,29 @@ function resolveConnectionStatusLabel(status?: string): string {
|
|||||||
return status === 'abnormal' ? '异常' : '正常';
|
return status === 'abnormal' ? '异常' : '正常';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: string | null | undefined): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProviderActionLabel(provider: OAuthProviderInfo, loading: boolean): string {
|
||||||
|
if (loading) return '启动中...';
|
||||||
|
if (!provider.enabled) return '当前不可用';
|
||||||
|
return `连接 ${provider.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConnectionPrimaryTitle(connection: OAuthConnectionInfo): string {
|
||||||
|
return asTrimmedString(connection.username)
|
||||||
|
|| asTrimmedString(connection.email)
|
||||||
|
|| asTrimmedString(connection.provider)
|
||||||
|
|| 'OAuth 连接';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConnectionEmailLabel(connection: OAuthConnectionInfo): string {
|
||||||
|
const email = asTrimmedString(connection.email);
|
||||||
|
if (!email) return '';
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCodeBlock(value: string) {
|
function renderCodeBlock(value: string) {
|
||||||
return (
|
return (
|
||||||
<code style={{
|
<code style={{
|
||||||
@@ -172,20 +195,21 @@ export default function OAuthManagement() {
|
|||||||
}, [activeSession]);
|
}, [activeSession]);
|
||||||
|
|
||||||
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
|
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
|
||||||
|
if (!provider.enabled) {
|
||||||
|
setSessionMessage(`${provider.label} 当前环境未启用`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
||||||
setActionLoadingKey(actionKey);
|
setActionLoadingKey(actionKey);
|
||||||
try {
|
try {
|
||||||
const projectId = provider.requiresProjectId
|
const shouldPromptForProjectId = provider.requiresProjectId && !accountId;
|
||||||
|
const projectId = shouldPromptForProjectId
|
||||||
? (() => {
|
? (() => {
|
||||||
if (typeof window === 'undefined' || typeof window.prompt !== 'function') return undefined;
|
if (typeof window === 'undefined' || typeof window.prompt !== 'function') return undefined;
|
||||||
const value = window.prompt('输入 Google Cloud Project ID');
|
const value = window.prompt('输入 Google Cloud Project ID(可选,留空则自动解析)');
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
})()
|
})()
|
||||||
: undefined;
|
: undefined;
|
||||||
if (provider.requiresProjectId && !projectId && !accountId) {
|
|
||||||
setSessionMessage('Gemini CLI 连接需要 Project ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const started = accountId
|
const started = accountId
|
||||||
? await api.rebindOAuthConnection(accountId)
|
? await api.rebindOAuthConnection(accountId)
|
||||||
: await api.startOAuthProvider(provider.provider, { projectId });
|
: await api.startOAuthProvider(provider.provider, { projectId });
|
||||||
@@ -245,7 +269,9 @@ export default function OAuthManagement() {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">OAuth 管理</div>
|
<div className="page-title">OAuth 管理</div>
|
||||||
<div className="page-subtitle">统一管理需要浏览器授权的上游连接,包括 Codex、Claude 和 Gemini CLI。</div>
|
<div className="page-subtitle">
|
||||||
|
统一管理需要浏览器授权的官方上游连接。授权完成后,可把 Codex、Claude、Gemini CLI 等 CLI / Web 登录态接入 metapi,继续通过统一路由、下游密钥、模型操练场和第三方客户端转发使用。
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -348,26 +374,33 @@ export default function OAuthManagement() {
|
|||||||
|
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权入口</div>
|
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权入口</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}>
|
||||||
|
适合把官方 CLI / Web 登录得到的账号统一接入 metapi,再供各种 CLI、SDK、下游密钥和模型操练场复用。
|
||||||
|
</div>
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
{providers.map((provider) => {
|
{providers.map((provider) => {
|
||||||
const actionKey = `start:${provider.provider}:0`;
|
const actionKey = `start:${provider.provider}:0`;
|
||||||
|
const disabled = !provider.enabled || actionLoadingKey === actionKey;
|
||||||
return (
|
return (
|
||||||
<div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
<div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div>
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
{provider.platform}
|
{provider.platform}
|
||||||
{provider.requiresProjectId ? ' · 需要 Project ID' : ''}
|
{provider.requiresProjectId ? ' · 可选 Project ID' : ''}
|
||||||
{provider.supportsNativeProxy ? ' · 原生代理' : ''}
|
{provider.supportsNativeProxy ? ' · 原生代理' : ''}
|
||||||
|
{!provider.enabled ? ' · 当前环境未启用' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => handleStart(provider)}
|
onClick={() => handleStart(provider)}
|
||||||
disabled={actionLoadingKey === actionKey}
|
disabled={disabled}
|
||||||
|
title={!provider.enabled ? `${provider.label} 当前环境未启用` : undefined}
|
||||||
|
style={!provider.enabled ? { opacity: 0.55, cursor: 'not-allowed' } : undefined}
|
||||||
>
|
>
|
||||||
{actionLoadingKey === actionKey ? '启动中...' : `连接 ${provider.label}`}
|
{resolveProviderActionLabel(provider, actionLoadingKey === actionKey)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -384,13 +417,20 @@ export default function OAuthManagement() {
|
|||||||
{connections.map((connection) => {
|
{connections.map((connection) => {
|
||||||
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
|
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
|
||||||
const deleteActionKey = `delete:${connection.accountId}`;
|
const deleteActionKey = `delete:${connection.accountId}`;
|
||||||
|
const primaryTitle = resolveConnectionPrimaryTitle(connection);
|
||||||
|
const emailLabel = resolveConnectionEmailLabel(connection);
|
||||||
return (
|
return (
|
||||||
<div key={connection.accountId} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 14 }}>
|
<div key={connection.accountId} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 14 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{connection.email || connection.username || connection.provider}</div>
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{primaryTitle}</div>
|
||||||
|
{emailLabel && emailLabel !== primaryTitle && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
|
授权邮箱: {emailLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
{connection.planType || 'unknown'} · {connection.modelCount} 个模型
|
{connection.provider} · {connection.planType || 'unknown'} · {connection.modelCount} 个模型
|
||||||
</div>
|
</div>
|
||||||
{connection.accountKey && (
|
{connection.accountKey && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user