feat: expand gemini proxy compatibility matrix
This commit is contained in:
@@ -481,4 +481,63 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
expect(parsed.runtimeHealth?.state).toBe('unhealthy');
|
||||
});
|
||||
|
||||
it('discovers antigravity oauth models via direct credential routing', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
|
||||
token === 'antigravity-access-token'
|
||||
? ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929']
|
||||
: []
|
||||
));
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'antigravity-site',
|
||||
url: 'https://cloudcode-pa.googleapis.com',
|
||||
platform: 'antigravity',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'antigravity-user@example.com',
|
||||
accessToken: 'antigravity-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'antigravity',
|
||||
email: 'antigravity-user@example.com',
|
||||
projectId: 'project-demo',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
errorCode: null,
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: true,
|
||||
discoveredApiToken: false,
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'],
|
||||
});
|
||||
expect(getModelsMock).toHaveBeenCalledWith(
|
||||
'https://cloudcode-pa.googleapis.com',
|
||||
'antigravity-access-token',
|
||||
undefined,
|
||||
);
|
||||
|
||||
const rows = await db.select().from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, account.id))
|
||||
.all();
|
||||
expect(rows.map((row) => row.modelName).sort()).toEqual([
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'gemini-3-pro-preview',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { fetch } from 'undici';
|
||||
import type { OAuthProviderDefinition } from './providers.js';
|
||||
|
||||
export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity';
|
||||
export const ANTIGRAVITY_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
export const ANTIGRAVITY_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
export const ANTIGRAVITY_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
|
||||
export const ANTIGRAVITY_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
export const ANTIGRAVITY_CLIENT_SECRET = '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';
|
||||
export const ANTIGRAVITY_LOOPBACK_CALLBACK_PORT = 51121;
|
||||
export const ANTIGRAVITY_LOOPBACK_CALLBACK_PATH = '/oauth-callback';
|
||||
export const ANTIGRAVITY_LOOPBACK_REDIRECT_URI = `http://localhost:${ANTIGRAVITY_LOOPBACK_CALLBACK_PORT}${ANTIGRAVITY_LOOPBACK_CALLBACK_PATH}`;
|
||||
export const ANTIGRAVITY_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com';
|
||||
export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1';
|
||||
export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1';
|
||||
export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}';
|
||||
|
||||
const ANTIGRAVITY_SCOPES = [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/cclog',
|
||||
'https://www.googleapis.com/auth/experimentsandconfigs',
|
||||
];
|
||||
|
||||
type AntigravityOAuthTokenPayload = {
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
token_type?: unknown;
|
||||
expires_in?: unknown;
|
||||
scope?: unknown;
|
||||
expiry?: unknown;
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function parseExpiresAt(payload: AntigravityOAuthTokenPayload): number | undefined {
|
||||
if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) {
|
||||
return Date.now() + Math.trunc(payload.expires_in) * 1000;
|
||||
}
|
||||
if (typeof payload.expires_in === 'string') {
|
||||
const parsed = Number.parseInt(payload.expires_in.trim(), 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Date.now() + parsed * 1000;
|
||||
}
|
||||
}
|
||||
if (typeof payload.expiry === 'string') {
|
||||
const parsed = Date.parse(payload.expiry);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function postAntigravityToken(body: URLSearchParams) {
|
||||
const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `antigravity token exchange failed with status ${response.status}`);
|
||||
}
|
||||
const payload = await response.json() as AntigravityOAuthTokenPayload;
|
||||
const accessToken = asTrimmedString(payload.access_token);
|
||||
if (!accessToken) {
|
||||
throw new Error('antigravity token exchange response missing access token');
|
||||
}
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: asTrimmedString(payload.refresh_token),
|
||||
tokenExpiresAt: parseExpiresAt(payload),
|
||||
providerData: {
|
||||
tokenType: asTrimmedString(payload.token_type),
|
||||
scope: asTrimmedString(payload.scope),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAntigravityUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
const response = await fetch(ANTIGRAVITY_USERINFO_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const payload = await response.json() as { email?: unknown };
|
||||
return asTrimmedString(payload.email);
|
||||
}
|
||||
|
||||
async function fetchAntigravityProjectId(accessToken: string): Promise<string | undefined> {
|
||||
const response = await fetch(`${ANTIGRAVITY_UPSTREAM_BASE_URL}/v1internal:loadCodeAssist`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': ANTIGRAVITY_USER_AGENT,
|
||||
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
|
||||
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: 'ANTIGRAVITY',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const payload = await response.json() as {
|
||||
cloudaicompanionProject?: unknown;
|
||||
};
|
||||
if (typeof payload.cloudaicompanionProject === 'string') {
|
||||
return asTrimmedString(payload.cloudaicompanionProject);
|
||||
}
|
||||
if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject === 'object') {
|
||||
return asTrimmedString((payload.cloudaicompanionProject as { id?: unknown }).id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const antigravityOauthProvider: OAuthProviderDefinition = {
|
||||
metadata: {
|
||||
provider: ANTIGRAVITY_OAUTH_PROVIDER,
|
||||
label: 'Antigravity',
|
||||
platform: 'antigravity',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: false,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: false,
|
||||
},
|
||||
site: {
|
||||
name: 'Google Antigravity OAuth',
|
||||
url: ANTIGRAVITY_UPSTREAM_BASE_URL,
|
||||
platform: 'antigravity',
|
||||
},
|
||||
loopback: {
|
||||
host: '127.0.0.1',
|
||||
port: ANTIGRAVITY_LOOPBACK_CALLBACK_PORT,
|
||||
path: ANTIGRAVITY_LOOPBACK_CALLBACK_PATH,
|
||||
redirectUri: ANTIGRAVITY_LOOPBACK_REDIRECT_URI,
|
||||
},
|
||||
buildAuthorizationUrl: async ({ state, redirectUri }) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: ANTIGRAVITY_CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
scope: ANTIGRAVITY_SCOPES.join(' '),
|
||||
state,
|
||||
});
|
||||
return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`;
|
||||
},
|
||||
exchangeAuthorizationCode: async ({ code, redirectUri }) => {
|
||||
const token = await postAntigravityToken(new URLSearchParams({
|
||||
code,
|
||||
client_id: ANTIGRAVITY_CLIENT_ID,
|
||||
client_secret: ANTIGRAVITY_CLIENT_SECRET,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}));
|
||||
const email = await fetchAntigravityUserEmail(token.accessToken);
|
||||
const projectId = await fetchAntigravityProjectId(token.accessToken);
|
||||
return {
|
||||
...token,
|
||||
email,
|
||||
accountKey: email,
|
||||
accountId: email,
|
||||
projectId,
|
||||
};
|
||||
},
|
||||
refreshAccessToken: async ({ refreshToken, oauth }) => {
|
||||
const token = await postAntigravityToken(new URLSearchParams({
|
||||
client_id: ANTIGRAVITY_CLIENT_ID,
|
||||
client_secret: ANTIGRAVITY_CLIENT_SECRET,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
}));
|
||||
const email = await fetchAntigravityUserEmail(token.accessToken);
|
||||
return {
|
||||
...token,
|
||||
email,
|
||||
accountKey: email,
|
||||
accountId: email,
|
||||
projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken),
|
||||
};
|
||||
},
|
||||
buildProxyHeaders: () => ({
|
||||
'User-Agent': ANTIGRAVITY_USER_AGENT,
|
||||
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
|
||||
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
|
||||
}),
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { codexOauthProvider } from './codexProvider.js';
|
||||
import { claudeOauthProvider } from './claudeProvider.js';
|
||||
import { geminiCliOauthProvider } from './geminiCliProvider.js';
|
||||
import { antigravityOauthProvider } from './antigravityProvider.js';
|
||||
|
||||
export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli';
|
||||
export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli' | 'antigravity';
|
||||
|
||||
export type OAuthProviderMetadata = {
|
||||
provider: OAuthProviderId;
|
||||
@@ -85,6 +86,7 @@ const PROVIDERS: OAuthProviderDefinition[] = [
|
||||
codexOauthProvider,
|
||||
claudeOauthProvider,
|
||||
geminiCliOauthProvider,
|
||||
antigravityOauthProvider,
|
||||
];
|
||||
|
||||
const PROVIDER_BY_ID = new Map(PROVIDERS.map((provider) => [provider.metadata.provider, provider] as const));
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
|
||||
import {
|
||||
ANTIGRAVITY_CLIENT_METADATA,
|
||||
ANTIGRAVITY_GOOGLE_API_CLIENT,
|
||||
ANTIGRAVITY_UPSTREAM_BASE_URL,
|
||||
ANTIGRAVITY_USER_AGENT,
|
||||
} from '../oauth/antigravityProvider.js';
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return (baseUrl || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function extractAntigravityModelNames(payload: unknown): string[] {
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
const record = payload as { models?: unknown };
|
||||
if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) {
|
||||
return Object.keys(record.models).map((name) => name.trim()).filter(Boolean);
|
||||
}
|
||||
if (Array.isArray(record.models)) {
|
||||
return record.models.flatMap((item) => {
|
||||
if (typeof item === 'string') return item.trim() ? [item.trim()] : [];
|
||||
if (!item || typeof item !== 'object') return [];
|
||||
const id = typeof (item as { id?: unknown }).id === 'string'
|
||||
? (item as { id: string }).id.trim()
|
||||
: (typeof (item as { name?: unknown }).name === 'string'
|
||||
? (item as { name: string }).name.trim()
|
||||
: '');
|
||||
return id ? [id] : [];
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export class AntigravityAdapter extends BasePlatformAdapter {
|
||||
readonly platformName = 'antigravity';
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
const normalized = (url || '').toLowerCase();
|
||||
return normalized.includes('antigravity');
|
||||
}
|
||||
|
||||
override async login(_baseUrl: string, _username: string, _password: string) {
|
||||
return { success: false, message: 'login endpoint not supported' };
|
||||
}
|
||||
|
||||
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||
return { success: false, message: 'checkin endpoint not supported' };
|
||||
}
|
||||
|
||||
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
|
||||
return { balance: 0, used: 0, quota: 0 };
|
||||
}
|
||||
|
||||
async getModels(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||
try {
|
||||
const payload = await this.fetchJson<{ models?: unknown }>(
|
||||
`${normalizeBaseUrl(baseUrl || ANTIGRAVITY_UPSTREAM_BASE_URL)}/v1internal:fetchAvailableModels`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': ANTIGRAVITY_USER_AGENT,
|
||||
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
|
||||
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
return extractAntigravityModelNames(payload);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,11 @@ describe('getAdapter platform aliases', () => {
|
||||
expect(getAdapter('gemini')?.platformName).toBe('gemini');
|
||||
});
|
||||
|
||||
it('supports antigravity adapter aliases', () => {
|
||||
expect(getAdapter('antigravity')?.platformName).toBe('antigravity');
|
||||
expect(getAdapter('anti-gravity')?.platformName).toBe('antigravity');
|
||||
});
|
||||
|
||||
it('supports dedicated codex adapter and aliases', () => {
|
||||
expect(getAdapter('codex')?.platformName).toBe('codex');
|
||||
expect(getAdapter('chatgpt-codex')?.platformName).toBe('codex');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CodexAdapter } from './codex.js';
|
||||
import { ClaudeAdapter } from './claude.js';
|
||||
import { GeminiAdapter } from './gemini.js';
|
||||
import { GeminiCliAdapter } from './geminiCli.js';
|
||||
import { AntigravityAdapter } from './antigravity.js';
|
||||
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
||||
import { detectPlatformByTitle } from './titleHint.js';
|
||||
|
||||
@@ -21,6 +22,7 @@ const adapters: PlatformAdapter[] = [
|
||||
new ClaudeAdapter(),
|
||||
new GeminiAdapter(),
|
||||
new GeminiCliAdapter(),
|
||||
new AntigravityAdapter(),
|
||||
new CliProxyApiAdapter(),
|
||||
new AnyRouterAdapter(),
|
||||
new DoneHubAdapter(),
|
||||
@@ -60,6 +62,8 @@ const platformAliases: Record<string, string> = {
|
||||
claude: 'claude',
|
||||
gemini: 'gemini',
|
||||
'gemini-cli': 'gemini-cli',
|
||||
antigravity: 'antigravity',
|
||||
'anti-gravity': 'antigravity',
|
||||
google: 'gemini',
|
||||
// CLIProxyAPI aliases
|
||||
cliproxyapi: 'cliproxyapi',
|
||||
|
||||
@@ -44,10 +44,7 @@ function extractHtmlTitle(html: string): string {
|
||||
return match[1].replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatform | undefined> {
|
||||
const base = normalizeBaseUrl(url);
|
||||
if (!base) return undefined;
|
||||
|
||||
async function detectPlatformByTitleOnce(base: string): Promise<TitleHintPlatform | undefined> {
|
||||
try {
|
||||
const { fetch } = await import('undici');
|
||||
const res = await fetch(`${base}/`, {
|
||||
@@ -71,3 +68,16 @@ export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatf
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatform | undefined> {
|
||||
const base = normalizeBaseUrl(url);
|
||||
if (!base) return undefined;
|
||||
|
||||
const first = await detectPlatformByTitleOnce(base);
|
||||
if (first) return first;
|
||||
|
||||
// Under heavy parallel test load, local title probes can occasionally race
|
||||
// with just-started ephemeral HTTP servers. Retry once before giving up.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return detectPlatformByTitleOnce(base);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user