1
0
forked from boos/BoosAPI

Merge pull request #141 from cita-777/Oauth-Management

feat: expand gemini proxy compatibility matrix
This commit is contained in:
Cita
2026-03-17 21:30:41 +08:00
committed by GitHub
20 changed files with 2189 additions and 235 deletions
+1 -1
View File
@@ -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'),
+42
View File
@@ -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"');
});
}); });
+505
View File
@@ -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
View File
@@ -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',
+14 -5
View File
@@ -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,
}),
};
+3 -1
View File
@@ -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');
+4
View File
@@ -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',
+14 -4
View File
@@ -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,
}; };
+3 -3
View File
@@ -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,
+283
View File
@@ -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: [
+52 -12
View File
@@ -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 }}>