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

@ -2,11 +2,12 @@ export * from "./agent/index.js";
export * from "./models.js";
export * from "./providers/anthropic.js";
export * from "./providers/google.js";
export * from "./providers/google-cloud-code-assist.js";
export * from "./providers/google-gemini-cli.js";
export * from "./providers/openai-completions.js";
export * from "./providers/openai-responses.js";
export * from "./stream.js";
export * from "./types.js";
export * from "./utils/oauth/index.js";
export * from "./utils/overflow.js";
export * from "./utils/typebox-helpers.js";
export * from "./utils/validation.js";

View file

@ -2906,7 +2906,7 @@ export const MODELS = {
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"nvidia/nemotron-3-nano-30b-a3b:free": {
id: "nvidia/nemotron-3-nano-30b-a3b:free",
@ -3280,7 +3280,7 @@ export const MODELS = {
cacheWrite: 0,
},
contextWindow: 163840,
maxTokens: 163840,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"anthropic/claude-opus-4.5": {
id: "anthropic/claude-opus-4.5",
@ -3444,13 +3444,13 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.44999999999999996,
output: 2.35,
input: 0.39999999999999997,
output: 1.75,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 16384,
maxTokens: 65535,
} satisfies Model<"openai-completions">,
"amazon/nova-premier-v1": {
id: "amazon/nova-premier-v1",
@ -4600,13 +4600,13 @@ export const MODELS = {
reasoning: false,
input: ["text"],
cost: {
input: 0.06,
output: 0.25,
input: 0.07,
output: 0.27,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
contextWindow: 160000,
maxTokens: 32768,
} satisfies Model<"openai-completions">,
"qwen/qwen3-30b-a3b-instruct-2507": {
id: "qwen/qwen3-30b-a3b-instruct-2507",
@ -4640,7 +4640,7 @@ export const MODELS = {
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 131072,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"z-ai/glm-4.5-air:free": {
id: "z-ai/glm-4.5-air:free",
@ -4784,7 +4784,7 @@ export const MODELS = {
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
reasoning: true,
input: ["text"],
cost: {
input: 0.071,
@ -5271,23 +5271,6 @@ export const MODELS = {
contextWindow: 40960,
maxTokens: 40960,
} satisfies Model<"openai-completions">,
"qwen/qwen3-235b-a22b:free": {
id: "qwen/qwen3-235b-a22b:free",
name: "Qwen: Qwen3 235B A22B (free)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"qwen/qwen3-235b-a22b": {
id: "qwen/qwen3-235b-a22b",
name: "Qwen: Qwen3 235B A22B",
@ -6036,9 +6019,9 @@ export const MODELS = {
contextWindow: 32768,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"anthropic/claude-3.5-haiku-20241022": {
id: "anthropic/claude-3.5-haiku-20241022",
name: "Anthropic: Claude 3.5 Haiku (2024-10-22)",
"anthropic/claude-3.5-haiku": {
id: "anthropic/claude-3.5-haiku",
name: "Anthropic: Claude 3.5 Haiku",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
@ -6053,9 +6036,9 @@ export const MODELS = {
contextWindow: 200000,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"anthropic/claude-3.5-haiku": {
id: "anthropic/claude-3.5-haiku",
name: "Anthropic: Claude 3.5 Haiku",
"anthropic/claude-3.5-haiku-20241022": {
id: "anthropic/claude-3.5-haiku-20241022",
name: "Anthropic: Claude 3.5 Haiku (2024-10-22)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
@ -6291,23 +6274,6 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-8b-instruct": {
id: "meta-llama/llama-3.1-8b-instruct",
name: "Meta: Llama 3.1 8B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.02,
output: 0.03,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-405b-instruct": {
id: "meta-llama/llama-3.1-405b-instruct",
name: "Meta: Llama 3.1 405B Instruct",
@ -6325,6 +6291,23 @@ export const MODELS = {
contextWindow: 130815,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-8b-instruct": {
id: "meta-llama/llama-3.1-8b-instruct",
name: "Meta: Llama 3.1 8B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.02,
output: 0.03,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-70b-instruct": {
id: "meta-llama/llama-3.1-70b-instruct",
name: "Meta: Llama 3.1 70B Instruct",
@ -6819,13 +6802,66 @@ export const MODELS = {
maxTokens: 30000,
} satisfies Model<"openai-completions">,
},
"google-cloud-code-assist": {
"google-gemini-cli": {
"gemini-2.5-pro": {
id: "gemini-2.5-pro",
name: "Gemini 2.5 Pro (Cloud Code Assist)",
api: "google-gemini-cli",
provider: "google-gemini-cli",
baseUrl: "https://cloudcode-pa.googleapis.com",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65535,
} satisfies Model<"google-gemini-cli">,
"gemini-2.5-flash": {
id: "gemini-2.5-flash",
name: "Gemini 2.5 Flash (Cloud Code Assist)",
api: "google-gemini-cli",
provider: "google-gemini-cli",
baseUrl: "https://cloudcode-pa.googleapis.com",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65535,
} satisfies Model<"google-gemini-cli">,
"gemini-2.0-flash": {
id: "gemini-2.0-flash",
name: "Gemini 2.0 Flash (Cloud Code Assist)",
api: "google-gemini-cli",
provider: "google-gemini-cli",
baseUrl: "https://cloudcode-pa.googleapis.com",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 8192,
} satisfies Model<"google-gemini-cli">,
},
"google-antigravity": {
"gemini-3-pro-high": {
id: "gemini-3-pro-high",
name: "Gemini 3 Pro High (Cloud Code Assist)",
api: "google-cloud-code-assist",
provider: "google-cloud-code-assist",
baseUrl: "https://cloudcode-pa.googleapis.com",
name: "Gemini 3 Pro High (Antigravity)",
api: "google-gemini-cli",
provider: "google-antigravity",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
input: ["text", "image"],
cost: {
@ -6836,13 +6872,13 @@ export const MODELS = {
},
contextWindow: 1048576,
maxTokens: 65535,
} satisfies Model<"google-cloud-code-assist">,
} satisfies Model<"google-gemini-cli">,
"gemini-3-pro-low": {
id: "gemini-3-pro-low",
name: "Gemini 3 Pro Low (Cloud Code Assist)",
api: "google-cloud-code-assist",
provider: "google-cloud-code-assist",
baseUrl: "https://cloudcode-pa.googleapis.com",
name: "Gemini 3 Pro Low (Antigravity)",
api: "google-gemini-cli",
provider: "google-antigravity",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
input: ["text", "image"],
cost: {
@ -6853,14 +6889,14 @@ export const MODELS = {
},
contextWindow: 1048576,
maxTokens: 65535,
} satisfies Model<"google-cloud-code-assist">,
} satisfies Model<"google-gemini-cli">,
"gemini-3-flash": {
id: "gemini-3-flash",
name: "Gemini 3 Flash (Cloud Code Assist)",
api: "google-cloud-code-assist",
provider: "google-cloud-code-assist",
baseUrl: "https://cloudcode-pa.googleapis.com",
reasoning: false,
name: "Gemini 3 Flash (Antigravity)",
api: "google-gemini-cli",
provider: "google-antigravity",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
@ -6869,14 +6905,14 @@ export const MODELS = {
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65536,
} satisfies Model<"google-cloud-code-assist">,
maxTokens: 65535,
} satisfies Model<"google-gemini-cli">,
"claude-sonnet-4-5": {
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5 (Cloud Code Assist)",
api: "google-cloud-code-assist",
provider: "google-cloud-code-assist",
baseUrl: "https://cloudcode-pa.googleapis.com",
name: "Claude Sonnet 4.5 (Antigravity)",
api: "google-gemini-cli",
provider: "google-antigravity",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: false,
input: ["text", "image"],
cost: {
@ -6887,13 +6923,13 @@ export const MODELS = {
},
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"google-cloud-code-assist">,
} satisfies Model<"google-gemini-cli">,
"claude-sonnet-4-5-thinking": {
id: "claude-sonnet-4-5-thinking",
name: "Claude Sonnet 4.5 Thinking (Cloud Code Assist)",
api: "google-cloud-code-assist",
provider: "google-cloud-code-assist",
baseUrl: "https://cloudcode-pa.googleapis.com",
name: "Claude Sonnet 4.5 Thinking (Antigravity)",
api: "google-gemini-cli",
provider: "google-antigravity",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
input: ["text", "image"],
cost: {
@ -6904,13 +6940,13 @@ export const MODELS = {
},
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"google-cloud-code-assist">,
} satisfies Model<"google-gemini-cli">,
"claude-opus-4-5-thinking": {
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking (Cloud Code Assist)",
api: "google-cloud-code-assist",
provider: "google-cloud-code-assist",
baseUrl: "https://cloudcode-pa.googleapis.com",
name: "Claude Opus 4.5 Thinking (Antigravity)",
api: "google-gemini-cli",
provider: "google-antigravity",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
input: ["text", "image"],
cost: {
@ -6921,15 +6957,15 @@ export const MODELS = {
},
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"google-cloud-code-assist">,
} satisfies Model<"google-gemini-cli">,
"gpt-oss-120b-medium": {
id: "gpt-oss-120b-medium",
name: "GPT-OSS 120B Medium (Cloud Code Assist)",
api: "google-cloud-code-assist",
provider: "google-cloud-code-assist",
baseUrl: "https://cloudcode-pa.googleapis.com",
name: "GPT-OSS 120B Medium (Antigravity)",
api: "google-gemini-cli",
provider: "google-antigravity",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: false,
input: ["text", "image"],
input: ["text"],
cost: {
input: 0,
output: 0,
@ -6938,6 +6974,6 @@ export const MODELS = {
},
contextWindow: 131072,
maxTokens: 32768,
} satisfies Model<"google-cloud-code-assist">,
} satisfies Model<"google-gemini-cli">,
},
} as const;

View file

@ -1,5 +1,6 @@
/**
* Google Cloud Code Assist provider for Gemini CLI / Antigravity authentication.
* Google Gemini CLI / Antigravity provider.
* Shared implementation for both google-gemini-cli and google-antigravity providers.
* Uses the Cloud Code Assist API endpoint to access Gemini and Claude models.
*/
@ -20,7 +21,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { convertMessages, convertTools, mapStopReasonString, mapToolChoice } from "./google-shared.js";
export interface GoogleCloudCodeAssistOptions extends StreamOptions {
export interface GoogleGeminiCliOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
@ -29,11 +30,27 @@ export interface GoogleCloudCodeAssistOptions extends StreamOptions {
projectId?: string;
}
const ENDPOINT = "https://cloudcode-pa.googleapis.com";
const HEADERS = {
"User-Agent": "google-api-nodejs-client/9.15.1",
const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
// Headers for Gemini CLI (prod endpoint)
const GEMINI_CLI_HEADERS = {
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"X-Goog-Api-Client": "gl-node/22.17.0",
"Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
// Headers for Antigravity (sandbox endpoint) - requires specific User-Agent
const ANTIGRAVITY_HEADERS = {
"User-Agent": "antigravity/1.11.5 darwin/arm64",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
// Counter for generating unique tool call IDs
@ -92,10 +109,10 @@ interface CloudCodeAssistResponseChunk {
traceId?: string;
}
export const streamGoogleCloudCodeAssist: StreamFunction<"google-cloud-code-assist"> = (
model: Model<"google-cloud-code-assist">,
export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
model: Model<"google-gemini-cli">,
context: Context,
options?: GoogleCloudCodeAssistOptions,
options?: GoogleGeminiCliOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
@ -103,7 +120,7 @@ export const streamGoogleCloudCodeAssist: StreamFunction<"google-cloud-code-assi
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "google-cloud-code-assist" as Api,
api: "google-gemini-cli" as Api,
provider: model.provider,
model: model.id,
usage: {
@ -141,7 +158,12 @@ export const streamGoogleCloudCodeAssist: StreamFunction<"google-cloud-code-assi
}
const requestBody = buildRequest(model, context, projectId, options);
const url = `${ENDPOINT}/v1internal:streamGenerateContent?alt=sse`;
const endpoint = model.baseUrl || DEFAULT_ENDPOINT;
const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
// Use Antigravity headers for sandbox endpoint, otherwise Gemini CLI headers
const isAntigravity = endpoint.includes("sandbox.googleapis.com");
const headers = isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS;
const response = await fetch(url, {
method: "POST",
@ -149,7 +171,7 @@ export const streamGoogleCloudCodeAssist: StreamFunction<"google-cloud-code-assi
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
...HEADERS,
...headers,
},
body: JSON.stringify(requestBody),
signal: options?.signal,
@ -379,10 +401,10 @@ export const streamGoogleCloudCodeAssist: StreamFunction<"google-cloud-code-assi
};
function buildRequest(
model: Model<"google-cloud-code-assist">,
model: Model<"google-gemini-cli">,
context: Context,
projectId: string,
options: GoogleCloudCodeAssistOptions = {},
options: GoogleGeminiCliOptions = {},
): CloudCodeAssistRequest {
const contents = convertMessages(model, context);

View file

@ -7,7 +7,7 @@ import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { transformMessages } from "./transorm-messages.js";
type GoogleApiType = "google-generative-ai" | "google-cloud-code-assist";
type GoogleApiType = "google-generative-ai" | "google-gemini-cli";
/**
* Convert internal messages to Gemini Content[] format.
@ -48,14 +48,23 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
for (const block of msg.content) {
if (block.type === "text") {
// Skip empty text blocks - they can cause issues with some models (e.g. Claude via Antigravity)
if (!block.text || block.text.trim() === "") continue;
parts.push({ text: sanitizeSurrogates(block.text) });
} else if (block.type === "thinking") {
const thinkingPart: Part = {
thought: true,
thoughtSignature: block.thinkingSignature,
text: sanitizeSurrogates(block.thinking),
};
parts.push(thinkingPart);
// Thinking blocks require signatures for Claude via Antigravity.
// If signature is missing (e.g. from GPT-OSS), convert to regular text with delimiters.
if (block.thinkingSignature) {
parts.push({
thought: true,
text: sanitizeSurrogates(block.thinking),
thoughtSignature: block.thinkingSignature,
});
} else {
parts.push({
text: `<thinking>\n${sanitizeSurrogates(block.thinking)}\n</thinking>`,
});
}
} else if (block.type === "toolCall") {
const part: Part = {
functionCall: {
@ -112,10 +121,17 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
});
}
contents.push({
role: "user",
parts,
});
// Cloud Code Assist API requires all function responses to be in a single user turn.
// Check if the last content is already a user turn with function responses and merge.
const lastContent = contents[contents.length - 1];
if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) {
lastContent.parts.push(...parts);
} else {
contents.push({
role: "user",
parts,
});
}
}
}

View file

@ -30,6 +30,20 @@ import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { transformMessages } from "./transorm-messages.js";
/** Fast deterministic hash to shorten long strings */
function shortHash(str: string): string {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
}
// OpenAI Responses-specific options
export interface OpenAIResponsesOptions extends StreamOptions {
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
@ -401,6 +415,7 @@ function convertMessages(model: Model<"openai-responses">, context: Context): Re
});
}
let msgIndex = 0;
for (const msg of transformedMessages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
@ -444,12 +459,19 @@ function convertMessages(model: Model<"openai-responses">, context: Context): Re
}
} else if (block.type === "text") {
const textBlock = block as TextContent;
// OpenAI requires id to be max 64 characters
let msgId = textBlock.textSignature;
if (!msgId) {
msgId = "msg_" + msgIndex;
} else if (msgId.length > 64) {
msgId = "msg_" + shortHash(msgId);
}
output.push({
type: "message",
role: "assistant",
content: [{ type: "output_text", text: sanitizeSurrogates(textBlock.text), annotations: [] }],
status: "completed",
id: textBlock.textSignature || "msg_" + Math.random().toString(36).substring(2, 15),
id: msgId,
} satisfies ResponseOutputMessage);
// Do not submit toolcall blocks if the completion had an error (i.e. abort)
} else if (block.type === "toolCall" && msg.stopReason !== "error") {
@ -508,6 +530,7 @@ function convertMessages(model: Model<"openai-responses">, context: Context): Re
});
}
}
msgIndex++;
}
return messages;

View file

@ -2,10 +2,7 @@ import { ThinkingLevel } from "@google/genai";
import { supportsXhigh } from "./models.js";
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic.js";
import { type GoogleOptions, streamGoogle } from "./providers/google.js";
import {
type GoogleCloudCodeAssistOptions,
streamGoogleCloudCodeAssist,
} from "./providers/google-cloud-code-assist.js";
import { type GoogleGeminiCliOptions, streamGoogleGeminiCli } from "./providers/google-gemini-cli.js";
import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions.js";
import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses.js";
import type {
@ -19,6 +16,7 @@ import type {
ReasoningEffort,
SimpleStreamOptions,
} from "./types.js";
import { getOAuthApiKey, getOAuthProviderForModelProvider } from "./utils/oauth/index.js";
const apiKeys: Map<string, string> = new Map();
@ -28,6 +26,10 @@ export function setApiKey(provider: any, key: string): void {
apiKeys.set(provider, key);
}
/**
* Get API key from environment variables (sync).
* Does NOT check OAuth credentials - use getApiKeyAsync for that.
*/
export function getApiKey(provider: KnownProvider): string | undefined;
export function getApiKey(provider: string): string | undefined;
export function getApiKey(provider: any): string | undefined {
@ -56,6 +58,33 @@ export function getApiKey(provider: any): string | undefined {
return envVar ? process.env[envVar] : undefined;
}
/**
* Resolve API key from OAuth credentials or environment (async).
* Automatically refreshes expired OAuth tokens.
*
* Priority:
* 1. Explicitly set keys (via setApiKey)
* 2. OAuth credentials from ~/.pi/agent/oauth.json
* 3. Environment variables
*/
export async function resolveApiKey(provider: KnownProvider): Promise<string | undefined>;
export async function resolveApiKey(provider: string): Promise<string | undefined>;
export async function resolveApiKey(provider: any): Promise<string | undefined> {
// Check explicit keys first
const key = apiKeys.get(provider);
if (key) return key;
// Check OAuth credentials (auto-refresh if expired)
const oauthProvider = getOAuthProviderForModelProvider(provider);
if (oauthProvider) {
const oauthKey = await getOAuthApiKey(oauthProvider);
if (oauthKey) return oauthKey;
}
// Fall back to sync getApiKey for env vars
return getApiKey(provider);
}
export function stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
@ -81,11 +110,11 @@ export function stream<TApi extends Api>(
case "google-generative-ai":
return streamGoogle(model as Model<"google-generative-ai">, context, providerOptions);
case "google-cloud-code-assist":
return streamGoogleCloudCodeAssist(
model as Model<"google-cloud-code-assist">,
case "google-gemini-cli":
return streamGoogleGeminiCli(
model as Model<"google-gemini-cli">,
context,
providerOptions as GoogleCloudCodeAssistOptions,
providerOptions as GoogleGeminiCliOptions,
);
default: {
@ -207,10 +236,10 @@ function mapOptionsForApi<TApi extends Api>(
} satisfies GoogleOptions;
}
case "google-cloud-code-assist": {
case "google-gemini-cli": {
// Cloud Code Assist uses thinking budget tokens like Gemini 2.5
if (!options?.reasoning) {
return { ...base, thinking: { enabled: false } } satisfies GoogleCloudCodeAssistOptions;
return { ...base, thinking: { enabled: false } } satisfies GoogleGeminiCliOptions;
}
const effort = clampReasoning(options.reasoning)!;
@ -227,7 +256,7 @@ function mapOptionsForApi<TApi extends Api>(
enabled: true,
budgetTokens: budgets[effort],
},
} satisfies GoogleCloudCodeAssistOptions;
} satisfies GoogleGeminiCliOptions;
}
default: {

View file

@ -1,6 +1,6 @@
import type { AnthropicOptions } from "./providers/anthropic.js";
import type { GoogleOptions } from "./providers/google.js";
import type { GoogleCloudCodeAssistOptions } from "./providers/google-cloud-code-assist.js";
import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli.js";
import type { OpenAICompletionsOptions } from "./providers/openai-completions.js";
import type { OpenAIResponsesOptions } from "./providers/openai-responses.js";
import type { AssistantMessageEventStream } from "./utils/event-stream.js";
@ -12,14 +12,14 @@ export type Api =
| "openai-responses"
| "anthropic-messages"
| "google-generative-ai"
| "google-cloud-code-assist";
| "google-gemini-cli";
export interface ApiOptionsMap {
"anthropic-messages": AnthropicOptions;
"openai-completions": OpenAICompletionsOptions;
"openai-responses": OpenAIResponsesOptions;
"google-generative-ai": GoogleOptions;
"google-cloud-code-assist": GoogleCloudCodeAssistOptions;
"google-gemini-cli": GoogleGeminiCliOptions;
}
// Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys
@ -36,6 +36,8 @@ export type OptionsForApi<TApi extends Api> = ApiOptionsMap[TApi];
export type KnownProvider =
| "anthropic"
| "google"
| "google-gemini-cli"
| "google-antigravity"
| "openai"
| "github-copilot"
| "xai"

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