Add Google Gemini CLI and Antigravity OAuth providers

- Add google-gemini-cli provider: free Gemini 2.0/2.5 via Cloud Code Assist
- Add google-antigravity provider: free Gemini 3, Claude, GPT-OSS via sandbox
- Move OAuth infrastructure from coding-agent to ai package
- Fix thinking signature handling for cross-model handoff
- Fix OpenAI message ID length limit (max 64 chars)
- Add GitHub Copilot overflow pattern detection
- Add OAuth provider tests for context overflow and streaming
This commit is contained in:
Mario Zechner 2025-12-20 18:21:32 +01:00
parent 3266cac0f1
commit c359023c3f
25 changed files with 1392 additions and 413 deletions

View file

@ -0,0 +1,130 @@
/**
* Anthropic OAuth flow (Claude Pro/Max)
*/
import { createHash, randomBytes } from "crypto";
import { type OAuthCredentials, saveOAuthCredentials } from "./storage.js";
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
const SCOPES = "org:create_api_key user:profile user:inference";
/**
* Generate PKCE code verifier and challenge
*/
function generatePKCE(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
/**
* Login with Anthropic OAuth (device code flow)
*
* @param onAuthUrl - Callback to handle the authorization URL (e.g., open browser)
* @param onPromptCode - Callback to prompt user for the authorization code
*/
export async function loginAnthropic(
onAuthUrl: (url: string) => void,
onPromptCode: () => Promise<string>,
): Promise<void> {
const { verifier, challenge } = generatePKCE();
// Build authorization URL
const authParams = new URLSearchParams({
code: "true",
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES,
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
});
const authUrl = `${AUTHORIZE_URL}?${authParams.toString()}`;
// Notify caller with URL to open
onAuthUrl(authUrl);
// Wait for user to paste authorization code (format: code#state)
const authCode = await onPromptCode();
const splits = authCode.split("#");
const code = splits[0];
const state = splits[1];
// Exchange code for tokens
const tokenResponse = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code: code,
state: state,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new Error(`Token exchange failed: ${error}`);
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
// Save credentials
const credentials: OAuthCredentials = {
type: "oauth",
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: expiresAt,
};
saveOAuthCredentials("anthropic", credentials);
}
/**
* Refresh Anthropic OAuth token
*/
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Anthropic token refresh failed: ${error}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
return {
type: "oauth",
refresh: data.refresh_token,
access: data.access_token,
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
};
}

View file

@ -0,0 +1,319 @@
/**
* GitHub Copilot OAuth flow
*/
import { getModels } from "../../models.js";
import { type OAuthCredentials, saveOAuthCredentials } from "./storage.js";
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const COPILOT_HEADERS = {
"User-Agent": "GitHubCopilotChat/0.35.0",
"Editor-Version": "vscode/1.107.0",
"Editor-Plugin-Version": "copilot-chat/0.35.0",
"Copilot-Integration-Id": "vscode-chat",
} as const;
type DeviceCodeResponse = {
device_code: string;
user_code: string;
verification_uri: string;
interval: number;
expires_in: number;
};
type DeviceTokenSuccessResponse = {
access_token: string;
token_type?: string;
scope?: string;
};
type DeviceTokenErrorResponse = {
error: string;
error_description?: string;
interval?: number;
};
export function normalizeDomain(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
try {
const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`);
return url.hostname;
} catch {
return null;
}
}
function getUrls(domain: string): {
deviceCodeUrl: string;
accessTokenUrl: string;
copilotTokenUrl: string;
} {
return {
deviceCodeUrl: `https://${domain}/login/device/code`,
accessTokenUrl: `https://${domain}/login/oauth/access_token`,
copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`,
};
}
/**
* Parse the proxy-ep from a Copilot token and convert to API base URL.
* Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;...
* Returns API URL like https://api.individual.githubcopilot.com
*/
export function getBaseUrlFromToken(token: string): string | null {
const match = token.match(/proxy-ep=([^;]+)/);
if (!match) return null;
const proxyHost = match[1];
// Convert proxy.xxx to api.xxx
const apiHost = proxyHost.replace(/^proxy\./, "api.");
return `https://${apiHost}`;
}
export function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string {
// If we have a token, extract the base URL from proxy-ep
if (token) {
const urlFromToken = getBaseUrlFromToken(token);
if (urlFromToken) return urlFromToken;
}
// Fallback for enterprise or if token parsing fails
if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`;
return "https://api.individual.githubcopilot.com";
}
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
const response = await fetch(url, init);
if (!response.ok) {
const text = await response.text();
throw new Error(`${response.status} ${response.statusText}: ${text}`);
}
return response.json();
}
async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
const urls = getUrls(domain);
const data = await fetchJson(urls.deviceCodeUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: JSON.stringify({
client_id: CLIENT_ID,
scope: "read:user",
}),
});
if (!data || typeof data !== "object") {
throw new Error("Invalid device code response");
}
const deviceCode = (data as Record<string, unknown>).device_code;
const userCode = (data as Record<string, unknown>).user_code;
const verificationUri = (data as Record<string, unknown>).verification_uri;
const interval = (data as Record<string, unknown>).interval;
const expiresIn = (data as Record<string, unknown>).expires_in;
if (
typeof deviceCode !== "string" ||
typeof userCode !== "string" ||
typeof verificationUri !== "string" ||
typeof interval !== "number" ||
typeof expiresIn !== "number"
) {
throw new Error("Invalid device code response fields");
}
return {
device_code: deviceCode,
user_code: userCode,
verification_uri: verificationUri,
interval,
expires_in: expiresIn,
};
}
async function pollForGitHubAccessToken(
domain: string,
deviceCode: string,
intervalSeconds: number,
expiresIn: number,
) {
const urls = getUrls(domain);
const deadline = Date.now() + expiresIn * 1000;
let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));
while (Date.now() < deadline) {
const raw = await fetchJson(urls.accessTokenUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") {
return (raw as DeviceTokenSuccessResponse).access_token;
}
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
const err = (raw as DeviceTokenErrorResponse).error;
if (err === "authorization_pending") {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
continue;
}
if (err === "slow_down") {
intervalMs += 5000;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
continue;
}
throw new Error(`Device flow failed: ${err}`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error("Device flow timed out");
}
/**
* Refresh GitHub Copilot token
*/
export async function refreshGitHubCopilotToken(
refreshToken: string,
enterpriseDomain?: string,
): Promise<OAuthCredentials> {
const domain = enterpriseDomain || "github.com";
const urls = getUrls(domain);
const raw = await fetchJson(urls.copilotTokenUrl, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${refreshToken}`,
...COPILOT_HEADERS,
},
});
if (!raw || typeof raw !== "object") {
throw new Error("Invalid Copilot token response");
}
const token = (raw as Record<string, unknown>).token;
const expiresAt = (raw as Record<string, unknown>).expires_at;
if (typeof token !== "string" || typeof expiresAt !== "number") {
throw new Error("Invalid Copilot token response fields");
}
return {
type: "oauth",
refresh: refreshToken,
access: token,
expires: expiresAt * 1000 - 5 * 60 * 1000,
enterpriseUrl: enterpriseDomain,
};
}
/**
* Enable a model for the user's GitHub Copilot account.
* This is required for some models (like Claude, Grok) before they can be used.
*/
export async function enableGitHubCopilotModel(
token: string,
modelId: string,
enterpriseDomain?: string,
): Promise<boolean> {
const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain);
const url = `${baseUrl}/models/${modelId}/policy`;
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...COPILOT_HEADERS,
"openai-intent": "chat-policy",
"x-interaction-type": "chat-policy",
},
body: JSON.stringify({ state: "enabled" }),
});
return response.ok;
} catch {
return false;
}
}
/**
* Enable all known GitHub Copilot models that may require policy acceptance.
* Called after successful login to ensure all models are available.
*/
export async function enableAllGitHubCopilotModels(
token: string,
enterpriseDomain?: string,
onProgress?: (model: string, success: boolean) => void,
): Promise<void> {
const models = getModels("github-copilot");
await Promise.all(
models.map(async (model) => {
const success = await enableGitHubCopilotModel(token, model.id, enterpriseDomain);
onProgress?.(model.id, success);
}),
);
}
/**
* Login with GitHub Copilot OAuth (device code flow)
*
* @param options.onAuth - Callback with URL and optional instructions (user code)
* @param options.onPrompt - Callback to prompt user for input
* @param options.onProgress - Optional progress callback
*/
export async function loginGitHubCopilot(options: {
onAuth: (url: string, instructions?: string) => void;
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
onProgress?: (message: string) => void;
}): Promise<OAuthCredentials> {
const input = await options.onPrompt({
message: "GitHub Enterprise URL/domain (blank for github.com)",
placeholder: "company.ghe.com",
allowEmpty: true,
});
const trimmed = input.trim();
const enterpriseDomain = normalizeDomain(input);
if (trimmed && !enterpriseDomain) {
throw new Error("Invalid GitHub Enterprise URL/domain");
}
const domain = enterpriseDomain || "github.com";
const device = await startDeviceFlow(domain);
options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`);
const githubAccessToken = await pollForGitHubAccessToken(
domain,
device.device_code,
device.interval,
device.expires_in,
);
const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined);
// Enable all models after successful login
options.onProgress?.("Enabling models...");
await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined);
// Save credentials
saveOAuthCredentials("github-copilot", credentials);
return credentials;
}

View file

@ -0,0 +1,342 @@
/**
* Antigravity OAuth flow (Gemini 3, Claude, GPT-OSS via Google Cloud)
* Uses different OAuth credentials than google-gemini-cli for access to additional models.
*/
import { createHash, randomBytes } from "crypto";
import { createServer, type Server } from "http";
import { type OAuthCredentials, saveOAuthCredentials } from "./storage.js";
// Antigravity OAuth credentials (different from Gemini CLI)
const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
const CLIENT_SECRET = "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";
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
// Antigravity requires additional scopes
const 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",
];
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
// Antigravity uses sandbox endpoint
const CODE_ASSIST_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
// Fallback project ID when discovery fails
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
export interface AntigravityCredentials extends OAuthCredentials {
projectId: string;
email?: string;
}
/**
* Generate PKCE code verifier and challenge
*/
function generatePKCE(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
/**
* Start a local HTTP server to receive the OAuth callback
*/
function startCallbackServer(): Promise<{ server: Server; getCode: () => Promise<{ code: string; state: string }> }> {
return new Promise((resolve, reject) => {
let codeResolve: (value: { code: string; state: string }) => void;
let codeReject: (error: Error) => void;
const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
codeResolve = res;
codeReject = rej;
});
const server = createServer((req, res) => {
const url = new URL(req.url || "", `http://localhost:51121`);
if (url.pathname === "/oauth-callback") {
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
if (error) {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
);
codeReject(new Error(`OAuth error: ${error}`));
return;
}
if (code && state) {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
);
codeResolve({ code, state });
} else {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
);
codeReject(new Error("Missing code or state in callback"));
}
} else {
res.writeHead(404);
res.end();
}
});
server.on("error", (err) => {
reject(err);
});
server.listen(51121, "127.0.0.1", () => {
resolve({
server,
getCode: () => codePromise,
});
});
});
}
interface LoadCodeAssistPayload {
cloudaicompanionProject?: string | { id?: string };
currentTier?: { id?: string };
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
}
/**
* Wait helper for onboarding retries
*/
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Discover or provision a project for the user
*/
async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise<string> {
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
// Try endpoints in order: prod first, then sandbox
const endpoints = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"];
onProgress?.("Checking for existing project...");
for (const endpoint of endpoints) {
try {
const loadResponse = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
});
if (loadResponse.ok) {
const data = (await loadResponse.json()) as LoadCodeAssistPayload;
// Handle both string and object formats
if (typeof data.cloudaicompanionProject === "string" && data.cloudaicompanionProject) {
return data.cloudaicompanionProject;
}
if (
data.cloudaicompanionProject &&
typeof data.cloudaicompanionProject === "object" &&
data.cloudaicompanionProject.id
) {
return data.cloudaicompanionProject.id;
}
}
} catch {
// Try next endpoint
}
}
// Use fallback project ID
onProgress?.("Using default project...");
return DEFAULT_PROJECT_ID;
}
/**
* Get user email from the access token
*/
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// Ignore errors, email is optional
}
return undefined;
}
/**
* Refresh Antigravity token
*/
export async function refreshAntigravityToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Antigravity token refresh failed: ${error}`);
}
const data = (await response.json()) as {
access_token: string;
expires_in: number;
refresh_token?: string;
};
return {
type: "oauth",
refresh: data.refresh_token || refreshToken,
access: data.access_token,
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
projectId,
};
}
/**
* Login with Antigravity OAuth
*
* @param onAuth - Callback with URL and optional instructions
* @param onProgress - Optional progress callback
*/
export async function loginAntigravity(
onAuth: (info: { url: string; instructions?: string }) => void,
onProgress?: (message: string) => void,
): Promise<AntigravityCredentials> {
const { verifier, challenge } = generatePKCE();
// Start local server for callback
onProgress?.("Starting local server for OAuth callback...");
const { server, getCode } = await startCallbackServer();
try {
// Build authorization URL
const authParams = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(" "),
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
access_type: "offline",
prompt: "consent",
});
const authUrl = `${AUTH_URL}?${authParams.toString()}`;
// Notify caller with URL to open
onAuth({
url: authUrl,
instructions: "Complete the sign-in in your browser. The callback will be captured automatically.",
});
// Wait for the callback
onProgress?.("Waiting for OAuth callback...");
const { code, state } = await getCode();
// Verify state matches
if (state !== verifier) {
throw new Error("OAuth state mismatch - possible CSRF attack");
}
// Exchange code for tokens
onProgress?.("Exchanging authorization code for tokens...");
const tokenResponse = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new Error(`Token exchange failed: ${error}`);
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!tokenData.refresh_token) {
throw new Error("No refresh token received. Please try again.");
}
// Get user email
onProgress?.("Getting user info...");
const email = await getUserEmail(tokenData.access_token);
// Discover project
const projectId = await discoverProject(tokenData.access_token, onProgress);
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
const credentials: AntigravityCredentials = {
type: "oauth",
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: expiresAt,
projectId,
email,
};
saveOAuthCredentials("google-antigravity", credentials);
return credentials;
} finally {
server.close();
}
}

View file

@ -0,0 +1,363 @@
/**
* Gemini CLI OAuth flow (Google Cloud Code Assist)
* Standard Gemini models only (gemini-2.0-flash, gemini-2.5-*)
*/
import { createHash, randomBytes } from "crypto";
import { createServer, type Server } from "http";
import { type OAuthCredentials, saveOAuthCredentials } from "./storage.js";
const CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
const CLIENT_SECRET = "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";
const REDIRECT_URI = "http://localhost:8085/oauth2callback";
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
];
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
export interface GoogleCloudCredentials extends OAuthCredentials {
projectId: string;
email?: string;
}
/**
* Generate PKCE code verifier and challenge
*/
function generatePKCE(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
/**
* Start a local HTTP server to receive the OAuth callback
*/
function startCallbackServer(): Promise<{ server: Server; getCode: () => Promise<{ code: string; state: string }> }> {
return new Promise((resolve, reject) => {
let codeResolve: (value: { code: string; state: string }) => void;
let codeReject: (error: Error) => void;
const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
codeResolve = res;
codeReject = rej;
});
const server = createServer((req, res) => {
const url = new URL(req.url || "", `http://localhost:8085`);
if (url.pathname === "/oauth2callback") {
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
if (error) {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
);
codeReject(new Error(`OAuth error: ${error}`));
return;
}
if (code && state) {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
);
codeResolve({ code, state });
} else {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
);
codeReject(new Error("Missing code or state in callback"));
}
} else {
res.writeHead(404);
res.end();
}
});
server.on("error", (err) => {
reject(err);
});
server.listen(8085, "127.0.0.1", () => {
resolve({
server,
getCode: () => codePromise,
});
});
});
}
interface LoadCodeAssistPayload {
cloudaicompanionProject?: string;
currentTier?: { id?: string };
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
}
interface OnboardUserPayload {
done?: boolean;
response?: {
cloudaicompanionProject?: { id?: string };
};
}
/**
* Wait helper for onboarding retries
*/
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Get default tier ID from allowed tiers
*/
function getDefaultTierId(allowedTiers?: Array<{ id?: string; isDefault?: boolean }>): string | undefined {
if (!allowedTiers || allowedTiers.length === 0) return undefined;
const defaultTier = allowedTiers.find((t) => t.isDefault);
return defaultTier?.id ?? allowedTiers[0]?.id;
}
/**
* Discover or provision a Google Cloud project for the user
*/
async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise<string> {
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "gl-node/22.17.0",
};
// Try to load existing project via loadCodeAssist
onProgress?.("Checking for existing Cloud Code Assist project...");
const loadResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
});
if (loadResponse.ok) {
const data = (await loadResponse.json()) as LoadCodeAssistPayload;
// If we have an existing project, use it
if (data.cloudaicompanionProject) {
return data.cloudaicompanionProject;
}
// Otherwise, try to onboard with the FREE tier
const tierId = getDefaultTierId(data.allowedTiers) ?? "FREE";
onProgress?.("Provisioning Cloud Code Assist project (this may take a moment)...");
// Onboard with retries (the API may take time to provision)
for (let attempt = 0; attempt < 10; attempt++) {
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
method: "POST",
headers,
body: JSON.stringify({
tierId,
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
});
if (onboardResponse.ok) {
const onboardData = (await onboardResponse.json()) as OnboardUserPayload;
const projectId = onboardData.response?.cloudaicompanionProject?.id;
if (onboardData.done && projectId) {
return projectId;
}
}
// Wait before retrying
if (attempt < 9) {
onProgress?.(`Waiting for project provisioning (attempt ${attempt + 2}/10)...`);
await wait(3000);
}
}
}
throw new Error(
"Could not discover or provision a Google Cloud project. " +
"Please ensure you have access to Google Cloud Code Assist (Gemini CLI).",
);
}
/**
* Get user email from the access token
*/
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// Ignore errors, email is optional
}
return undefined;
}
/**
* Refresh Google Cloud Code Assist token
*/
export async function refreshGoogleCloudToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Google Cloud token refresh failed: ${error}`);
}
const data = (await response.json()) as {
access_token: string;
expires_in: number;
refresh_token?: string;
};
return {
type: "oauth",
refresh: data.refresh_token || refreshToken,
access: data.access_token,
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
projectId,
};
}
/**
* Login with Gemini CLI (Google Cloud Code Assist) OAuth
*
* @param onAuth - Callback with URL and optional instructions
* @param onProgress - Optional progress callback
*/
export async function loginGeminiCli(
onAuth: (info: { url: string; instructions?: string }) => void,
onProgress?: (message: string) => void,
): Promise<GoogleCloudCredentials> {
const { verifier, challenge } = generatePKCE();
// Start local server for callback
onProgress?.("Starting local server for OAuth callback...");
const { server, getCode } = await startCallbackServer();
try {
// Build authorization URL
const authParams = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(" "),
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
access_type: "offline",
prompt: "consent",
});
const authUrl = `${AUTH_URL}?${authParams.toString()}`;
// Notify caller with URL to open
onAuth({
url: authUrl,
instructions: "Complete the sign-in in your browser. The callback will be captured automatically.",
});
// Wait for the callback
onProgress?.("Waiting for OAuth callback...");
const { code, state } = await getCode();
// Verify state matches
if (state !== verifier) {
throw new Error("OAuth state mismatch - possible CSRF attack");
}
// Exchange code for tokens
onProgress?.("Exchanging authorization code for tokens...");
const tokenResponse = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new Error(`Token exchange failed: ${error}`);
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!tokenData.refresh_token) {
throw new Error("No refresh token received. Please try again.");
}
// Get user email
onProgress?.("Getting user info...");
const email = await getUserEmail(tokenData.access_token);
// Discover project
const projectId = await discoverProject(tokenData.access_token, onProgress);
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
const credentials: GoogleCloudCredentials = {
type: "oauth",
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: expiresAt,
projectId,
email,
};
saveOAuthCredentials("google-gemini-cli", credentials);
return credentials;
} finally {
server.close();
}
}

View file

@ -0,0 +1,210 @@
/**
* OAuth credential management for AI providers.
*
* This module handles login, token refresh, and credential storage
* for OAuth-based providers:
* - Anthropic (Claude Pro/Max)
* - GitHub Copilot
* - Google Cloud Code Assist (Gemini CLI)
* - Antigravity (Gemini 3, Claude, GPT-OSS via Google Cloud)
*/
// Anthropic
export { loginAnthropic, refreshAnthropicToken } from "./anthropic.js";
// GitHub Copilot
export {
enableAllGitHubCopilotModels,
enableGitHubCopilotModel,
getBaseUrlFromToken,
getGitHubCopilotBaseUrl,
loginGitHubCopilot,
normalizeDomain,
refreshGitHubCopilotToken,
} from "./github-copilot.js";
// Google Antigravity
export {
type AntigravityCredentials,
loginAntigravity,
refreshAntigravityToken,
} from "./google-antigravity.js";
// Google Gemini CLI
export {
type GoogleCloudCredentials,
loginGeminiCli,
refreshGoogleCloudToken,
} from "./google-gemini-cli.js";
// Storage
export {
getOAuthPath,
hasOAuthCredentials,
listOAuthProviders,
loadOAuthCredentials,
loadOAuthStorage,
type OAuthCredentials,
type OAuthProvider,
type OAuthStorage,
removeOAuthCredentials,
saveOAuthCredentials,
} from "./storage.js";
// ============================================================================
// High-level API
// ============================================================================
import { refreshAnthropicToken } from "./anthropic.js";
import { refreshGitHubCopilotToken } from "./github-copilot.js";
import { refreshAntigravityToken } from "./google-antigravity.js";
import { refreshGoogleCloudToken } from "./google-gemini-cli.js";
import type { OAuthCredentials, OAuthProvider } from "./storage.js";
import { loadOAuthCredentials, removeOAuthCredentials, saveOAuthCredentials } from "./storage.js";
/**
* Refresh token for any OAuth provider.
* Saves the new credentials and returns the new access token.
*/
export async function refreshToken(provider: OAuthProvider): Promise<string> {
const credentials = loadOAuthCredentials(provider);
if (!credentials) {
throw new Error(`No OAuth credentials found for ${provider}`);
}
let newCredentials: OAuthCredentials;
switch (provider) {
case "anthropic":
newCredentials = await refreshAnthropicToken(credentials.refresh);
break;
case "github-copilot":
newCredentials = await refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl);
break;
case "google-gemini-cli":
if (!credentials.projectId) {
throw new Error("Google Cloud credentials missing projectId");
}
newCredentials = await refreshGoogleCloudToken(credentials.refresh, credentials.projectId);
break;
case "google-antigravity":
if (!credentials.projectId) {
throw new Error("Antigravity credentials missing projectId");
}
newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId);
break;
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}
saveOAuthCredentials(provider, newCredentials);
return newCredentials.access;
}
/**
* Get API key for a provider from OAuth credentials.
* Automatically refreshes expired tokens.
*
* For google-gemini-cli and antigravity, returns JSON-encoded { token, projectId }
*
* @returns API key string, or null if no credentials
*/
export async function getOAuthApiKey(provider: OAuthProvider): Promise<string | null> {
const credentials = loadOAuthCredentials(provider);
if (!credentials) {
return null;
}
// Providers that need projectId in the API key
const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
// Check if expired
if (Date.now() >= credentials.expires) {
try {
const newToken = await refreshToken(provider);
// For providers that need projectId, return JSON
if (needsProjectId) {
const refreshedCreds = loadOAuthCredentials(provider);
if (refreshedCreds?.projectId) {
return JSON.stringify({ token: newToken, projectId: refreshedCreds.projectId });
}
}
return newToken;
} catch (error) {
console.error(`Failed to refresh OAuth token for ${provider}:`, error);
removeOAuthCredentials(provider);
return null;
}
}
// For providers that need projectId, return JSON
if (needsProjectId) {
if (!credentials.projectId) {
return null;
}
return JSON.stringify({ token: credentials.access, projectId: credentials.projectId });
}
return credentials.access;
}
/**
* Map model provider to OAuth provider.
* Returns undefined if the provider doesn't use OAuth.
*/
export function getOAuthProviderForModelProvider(modelProvider: string): OAuthProvider | undefined {
const mapping: Record<string, OAuthProvider> = {
anthropic: "anthropic",
"github-copilot": "github-copilot",
"google-gemini-cli": "google-gemini-cli",
"google-antigravity": "google-antigravity",
};
return mapping[modelProvider];
}
// ============================================================================
// Login/Logout types for convenience
// ============================================================================
export type OAuthPrompt = {
message: string;
placeholder?: string;
allowEmpty?: boolean;
};
export type OAuthAuthInfo = {
url: string;
instructions?: string;
};
export interface OAuthProviderInfo {
id: OAuthProvider;
name: string;
available: boolean;
}
/**
* Get list of OAuth providers
*/
export function getOAuthProviders(): OAuthProviderInfo[] {
return [
{
id: "anthropic",
name: "Anthropic (Claude Pro/Max)",
available: true,
},
{
id: "github-copilot",
name: "GitHub Copilot",
available: true,
},
{
id: "google-gemini-cli",
name: "Google Cloud Code Assist (Gemini CLI)",
available: true,
},
{
id: "google-antigravity",
name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
available: true,
},
];
}

View file

@ -0,0 +1,108 @@
/**
* OAuth credential storage for ~/.pi/agent/oauth.json
*/
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join } from "path";
export interface OAuthCredentials {
type: "oauth";
refresh: string;
access: string;
expires: number;
enterpriseUrl?: string;
projectId?: string;
email?: string;
}
export interface OAuthStorage {
[provider: string]: OAuthCredentials;
}
export type OAuthProvider = "anthropic" | "github-copilot" | "google-gemini-cli" | "google-antigravity";
/**
* Get the path to the OAuth credentials file
*/
export function getOAuthPath(): string {
return join(homedir(), ".pi", "agent", "oauth.json");
}
/**
* Ensure the config directory exists
*/
function ensureConfigDir(): void {
const configDir = dirname(getOAuthPath());
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
}
/**
* Load all OAuth credentials from ~/.pi/agent/oauth.json
*/
export function loadOAuthStorage(): OAuthStorage {
const filePath = getOAuthPath();
if (!existsSync(filePath)) {
return {};
}
try {
const content = readFileSync(filePath, "utf-8");
return JSON.parse(content);
} catch {
return {};
}
}
/**
* Save all OAuth credentials to ~/.pi/agent/oauth.json
*/
function saveOAuthStorage(storage: OAuthStorage): void {
ensureConfigDir();
const filePath = getOAuthPath();
writeFileSync(filePath, JSON.stringify(storage, null, 2), "utf-8");
chmodSync(filePath, 0o600);
}
/**
* Load OAuth credentials for a specific provider
*/
export function loadOAuthCredentials(provider: string): OAuthCredentials | null {
const storage = loadOAuthStorage();
return storage[provider] || null;
}
/**
* Save OAuth credentials for a specific provider
*/
export function saveOAuthCredentials(provider: string, creds: OAuthCredentials): void {
const storage = loadOAuthStorage();
storage[provider] = creds;
saveOAuthStorage(storage);
}
/**
* Remove OAuth credentials for a specific provider
*/
export function removeOAuthCredentials(provider: string): void {
const storage = loadOAuthStorage();
delete storage[provider];
saveOAuthStorage(storage);
}
/**
* Check if OAuth credentials exist for a provider
*/
export function hasOAuthCredentials(provider: string): boolean {
return loadOAuthCredentials(provider) !== null;
}
/**
* List all providers with OAuth credentials
*/
export function listOAuthProviders(): string[] {
const storage = loadOAuthStorage();
return Object.keys(storage);
}

View file

@ -16,6 +16,7 @@ import type { AssistantMessage } from "../types.js";
* - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens"
* - llama.cpp: "the request exceeds the available context size, try increasing it"
* - LM Studio: "tokens to keep from the initial prompt is greater than the context length"
* - GitHub Copilot: "prompt token count of X exceeds the limit of Y"
* - Cerebras: Returns "400 status code (no body)" - handled separately below
* - Mistral: Returns "400 status code (no body)" - handled separately below
* - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow
@ -28,6 +29,7 @@ const OVERFLOW_PATTERNS = [
/maximum prompt length is \d+/i, // xAI (Grok)
/reduce the length of the messages/i, // Groq
/maximum context length is \d+ tokens/i, // OpenRouter (all backends)
/exceeds the limit of \d+/i, // GitHub Copilot
/exceeds the available context size/i, // llama.cpp server
/greater than the context length/i, // LM Studio
/context length exceeded/i, // Generic fallback