mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 17:00:59 +00:00
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:
parent
3266cac0f1
commit
c359023c3f
25 changed files with 1392 additions and 413 deletions
|
|
@ -2,12 +2,20 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Google Gemini CLI OAuth provider**: Access Gemini 2.0/2.5 models for free via Google Cloud Code Assist. Login with `/login` and select "Google Gemini CLI". Uses your Google account with rate limits.
|
||||
|
||||
- **Google Antigravity OAuth provider**: Access Gemini 3, Claude (sonnet/opus thinking models), and GPT-OSS models for free via Google's Antigravity sandbox. Login with `/login` and select "Antigravity". Uses your Google account with rate limits.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Connection errors not retried**: Added "connection error" to the list of retryable errors so Anthropic connection drops trigger auto-retry instead of silently failing. ([#252](https://github.com/badlogic/pi-mono/issues/252))
|
||||
|
||||
- **Thinking level not clamped on model switch**: Fixed TUI showing xhigh thinking level after switching to a model that doesn't support it. Thinking level is now automatically clamped to model capabilities. ([#253](https://github.com/badlogic/pi-mono/issues/253))
|
||||
|
||||
- **Cross-model thinking handoff**: Fixed error when switching between models with different thinking signature formats (e.g., GPT-OSS to Claude thinking models via Antigravity). Thinking blocks without signatures are now converted to text with `<thinking>` delimiters.
|
||||
|
||||
## [0.24.5] - 2025-12-20
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -117,25 +117,30 @@ Set the environment variable for your provider:
|
|||
| OpenRouter | `OPENROUTER_API_KEY` |
|
||||
| ZAI | `ZAI_API_KEY` |
|
||||
|
||||
**Anthropic OAuth (Claude Pro/Max):**
|
||||
**OAuth Providers:**
|
||||
|
||||
Use `/login` to authenticate with subscription-based or free-tier providers:
|
||||
|
||||
| Provider | Models | Cost |
|
||||
|----------|--------|------|
|
||||
| Anthropic (Claude Pro/Max) | Claude models via your subscription | Subscription |
|
||||
| GitHub Copilot | GPT-4o, Claude, Gemini via Copilot subscription | Subscription |
|
||||
| Google Gemini CLI | Gemini 2.0/2.5 models | Free (Google account) |
|
||||
| Google Antigravity | Gemini 3, Claude, GPT-OSS | Free (Google account) |
|
||||
|
||||
```bash
|
||||
pi
|
||||
/login # Select "Anthropic (Claude Pro/Max)", authorize in browser
|
||||
/login # Select provider, authorize in browser
|
||||
```
|
||||
|
||||
Tokens stored in `~/.pi/agent/oauth.json`. Use `/logout` to clear.
|
||||
**GitHub Copilot notes:**
|
||||
- Press Enter for github.com, or enter your GitHub Enterprise Server domain
|
||||
- If you get "model not supported" error, enable it in VS Code: Copilot Chat → model selector → select model → "Enable"
|
||||
|
||||
**GitHub Copilot OAuth:**
|
||||
|
||||
```bash
|
||||
pi
|
||||
/login # Select "GitHub Copilot", authorize in browser
|
||||
```
|
||||
|
||||
Press Enter to use github.com, or enter your GitHub Enterprise Server domain (e.g., `github.mycompany.com`).
|
||||
|
||||
If you get "The requested model is not supported" error, enable the model in VS Code: Copilot Chat → model selector → select model → "Enable".
|
||||
**Google providers notes:**
|
||||
- Gemini CLI uses the production Cloud Code Assist endpoint (standard Gemini models)
|
||||
- Antigravity uses a sandbox endpoint with access to Gemini 3, Claude (sonnet/opus thinking), and GPT-OSS models
|
||||
- Both are free with any Google account, subject to rate limits
|
||||
|
||||
Tokens stored in `~/.pi/agent/oauth.json`. Use `/logout` to clear.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
import { type Api, getApiKey, getModels, getProviders, type KnownProvider, type Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
type Api,
|
||||
getApiKey,
|
||||
getGitHubCopilotBaseUrl,
|
||||
getModels,
|
||||
getProviders,
|
||||
type KnownProvider,
|
||||
loadOAuthCredentials,
|
||||
type Model,
|
||||
normalizeDomain,
|
||||
refreshGitHubCopilotToken,
|
||||
removeOAuthCredentials,
|
||||
saveOAuthCredentials,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import AjvModule from "ajv";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { getModelsPath } from "../config.js";
|
||||
import { getGitHubCopilotBaseUrl, normalizeDomain, refreshGitHubCopilotToken } from "./oauth/github-copilot.js";
|
||||
import { getOAuthToken, refreshToken, type SupportedOAuthProvider } from "./oauth/index.js";
|
||||
import { loadOAuthCredentials, removeOAuthCredentials, saveOAuthCredentials } from "./oauth/storage.js";
|
||||
import { getOAuthToken, type OAuthProvider, refreshToken } from "./oauth/index.js";
|
||||
|
||||
// Handle both default and named exports
|
||||
const Ajv = (AjvModule as any).default || AjvModule;
|
||||
|
|
@ -312,9 +323,10 @@ export async function getApiKeyForModel(model: Model<Api>): Promise<string | und
|
|||
return githubToken;
|
||||
}
|
||||
|
||||
// For Google Cloud Code Assist, check OAuth and encode projectId with token
|
||||
if (model.provider === "google-cloud-code-assist") {
|
||||
const credentials = loadOAuthCredentials("google-cloud-code-assist");
|
||||
// For Google Gemini CLI and Antigravity, check OAuth and encode projectId with token
|
||||
if (model.provider === "google-gemini-cli" || model.provider === "google-antigravity") {
|
||||
const oauthProvider = model.provider as "google-gemini-cli" | "google-antigravity";
|
||||
const credentials = loadOAuthCredentials(oauthProvider);
|
||||
if (!credentials) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -322,13 +334,13 @@ export async function getApiKeyForModel(model: Model<Api>): Promise<string | und
|
|||
// Check if token is expired
|
||||
if (Date.now() >= credentials.expires) {
|
||||
try {
|
||||
await refreshToken("google-cloud-code-assist");
|
||||
const refreshedCreds = loadOAuthCredentials("google-cloud-code-assist");
|
||||
await refreshToken(oauthProvider);
|
||||
const refreshedCreds = loadOAuthCredentials(oauthProvider);
|
||||
if (refreshedCreds?.projectId) {
|
||||
return JSON.stringify({ token: refreshedCreds.access, projectId: refreshedCreds.projectId });
|
||||
}
|
||||
} catch {
|
||||
removeOAuthCredentials("google-cloud-code-assist");
|
||||
removeOAuthCredentials(oauthProvider);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -395,10 +407,11 @@ export function findModel(provider: string, modelId: string): { model: Model<Api
|
|||
* Mapping from model provider to OAuth provider ID.
|
||||
* Only providers that support OAuth are listed here.
|
||||
*/
|
||||
const providerToOAuthProvider: Record<string, SupportedOAuthProvider> = {
|
||||
const providerToOAuthProvider: Record<string, OAuthProvider> = {
|
||||
anthropic: "anthropic",
|
||||
"github-copilot": "github-copilot",
|
||||
"google-cloud-code-assist": "google-cloud-code-assist",
|
||||
"google-gemini-cli": "google-gemini-cli",
|
||||
"google-antigravity": "google-antigravity",
|
||||
};
|
||||
|
||||
// Cache for OAuth status per provider (avoids file reads on every render)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
|||
anthropic: "claude-sonnet-4-5",
|
||||
openai: "gpt-5.1-codex",
|
||||
google: "gemini-2.5-pro",
|
||||
"google-gemini-cli": "gemini-2.5-pro",
|
||||
"google-antigravity": "gemini-3-pro-high",
|
||||
"github-copilot": "gpt-4o",
|
||||
openrouter: "openai/gpt-5.1-codex",
|
||||
xai: "grok-4-fast-non-reasoning",
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
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)
|
||||
*/
|
||||
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 using refresh token
|
||||
*/
|
||||
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
|
||||
const tokenResponse = 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 (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.text();
|
||||
throw new Error(`Token refresh 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;
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
refresh: tokenData.refresh_token,
|
||||
access: tokenData.access_token,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
import { getModels } from "@mariozechner/pi-ai";
|
||||
import type { OAuthCredentials } 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");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
const expires = expiresAt * 1000 - 5 * 60 * 1000;
|
||||
return {
|
||||
type: "oauth",
|
||||
refresh: refreshToken,
|
||||
access: token,
|
||||
expires,
|
||||
enterpriseUrl: enterpriseDomain,
|
||||
} satisfies OAuthCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
|
@ -1,373 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with Google Cloud OAuth
|
||||
*/
|
||||
export async function loginGoogleCloud(
|
||||
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-cloud-code-assist", credentials);
|
||||
|
||||
return credentials;
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Google Cloud OAuth token using refresh token
|
||||
*/
|
||||
export async function refreshGoogleCloudToken(
|
||||
refreshToken: string,
|
||||
existingProjectId?: string,
|
||||
): Promise<GoogleCloudCredentials> {
|
||||
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,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.text();
|
||||
throw new Error(`Token refresh failed: ${error}`);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string; // May or may not be returned
|
||||
};
|
||||
|
||||
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
|
||||
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
|
||||
|
||||
// Get user email
|
||||
const email = await getUserEmail(tokenData.access_token);
|
||||
|
||||
// Use existing project ID or discover new one
|
||||
let projectId = existingProjectId;
|
||||
if (!projectId) {
|
||||
projectId = await discoverProject(tokenData.access_token);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
refresh: tokenData.refresh_token || refreshToken, // Use new refresh token if provided, otherwise keep existing
|
||||
access: tokenData.access_token,
|
||||
expires: expiresAt,
|
||||
projectId,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,54 +1,70 @@
|
|||
import { loginAnthropic, refreshAnthropicToken } from "./anthropic.js";
|
||||
import { loginGitHubCopilot, refreshGitHubCopilotToken } from "./github-copilot.js";
|
||||
import { loginGoogleCloud, refreshGoogleCloudToken } from "./google-cloud.js";
|
||||
/**
|
||||
* OAuth management for coding-agent.
|
||||
* Re-exports from @mariozechner/pi-ai and adds convenience wrappers.
|
||||
*/
|
||||
|
||||
import {
|
||||
listOAuthProviders as listOAuthProvidersFromStorage,
|
||||
getOAuthApiKey,
|
||||
listOAuthProviders as listOAuthProvidersFromAi,
|
||||
loadOAuthCredentials,
|
||||
loginAnthropic,
|
||||
loginAntigravity,
|
||||
loginGeminiCli,
|
||||
loginGitHubCopilot,
|
||||
type OAuthCredentials,
|
||||
type OAuthProvider,
|
||||
refreshToken as refreshTokenFromAi,
|
||||
removeOAuthCredentials,
|
||||
saveOAuthCredentials,
|
||||
} from "./storage.js";
|
||||
} from "@mariozechner/pi-ai";
|
||||
|
||||
// Re-export for convenience
|
||||
export { listOAuthProvidersFromStorage as listOAuthProviders };
|
||||
// Re-export types and functions
|
||||
export type { OAuthCredentials, OAuthProvider };
|
||||
export { listOAuthProvidersFromAi as listOAuthProviders };
|
||||
export { getOAuthApiKey, loadOAuthCredentials, removeOAuthCredentials, saveOAuthCredentials };
|
||||
|
||||
export type SupportedOAuthProvider = "anthropic" | "github-copilot" | "google-cloud-code-assist";
|
||||
|
||||
export interface OAuthProviderInfo {
|
||||
id: SupportedOAuthProvider;
|
||||
name: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export type OAuthPrompt = {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
allowEmpty?: boolean;
|
||||
};
|
||||
|
||||
export type OAuthAuthInfo = {
|
||||
// Types for OAuth flow
|
||||
export interface OAuthAuthInfo {
|
||||
url: string;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
export interface OAuthPrompt {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export type OAuthProviderInfo = {
|
||||
id: OAuthProvider;
|
||||
name: string;
|
||||
description: string;
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get list of OAuth providers
|
||||
*/
|
||||
export function getOAuthProviders(): OAuthProviderInfo[] {
|
||||
return [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic (Claude Pro/Max)",
|
||||
description: "Use Claude with your Pro/Max subscription",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "github-copilot",
|
||||
name: "GitHub Copilot",
|
||||
description: "Use models via GitHub Copilot subscription",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "google-cloud-code-assist",
|
||||
name: "Google Cloud Code Assist (Gemini CLI)",
|
||||
id: "google-gemini-cli",
|
||||
name: "Google Gemini CLI",
|
||||
description: "Free Gemini 2.0/2.5 models via Google Cloud",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "google-antigravity",
|
||||
name: "Antigravity",
|
||||
description: "Free Gemini 3, Claude, GPT-OSS via Google Cloud",
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
|
@ -58,7 +74,7 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
|
|||
* Login with OAuth provider
|
||||
*/
|
||||
export async function login(
|
||||
provider: SupportedOAuthProvider,
|
||||
provider: OAuthProvider,
|
||||
onAuth: (info: OAuthAuthInfo) => void,
|
||||
onPrompt: (prompt: OAuthPrompt) => Promise<string>,
|
||||
onProgress?: (message: string) => void,
|
||||
|
|
@ -79,8 +95,12 @@ export async function login(
|
|||
saveOAuthCredentials("github-copilot", creds);
|
||||
break;
|
||||
}
|
||||
case "google-cloud-code-assist": {
|
||||
await loginGoogleCloud(onAuth, onProgress);
|
||||
case "google-gemini-cli": {
|
||||
await loginGeminiCli((info) => onAuth({ url: info.url, instructions: info.instructions }), onProgress);
|
||||
break;
|
||||
}
|
||||
case "google-antigravity": {
|
||||
await loginAntigravity((info) => onAuth({ url: info.url, instructions: info.instructions }), onProgress);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
|
@ -91,62 +111,21 @@ export async function login(
|
|||
/**
|
||||
* Logout from OAuth provider
|
||||
*/
|
||||
export async function logout(provider: SupportedOAuthProvider): Promise<void> {
|
||||
export async function logout(provider: OAuthProvider): Promise<void> {
|
||||
removeOAuthCredentials(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OAuth token for provider
|
||||
* Refresh OAuth token for provider.
|
||||
* Delegates to the ai package implementation.
|
||||
*/
|
||||
export async function refreshToken(provider: SupportedOAuthProvider): 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-cloud-code-assist":
|
||||
newCredentials = await refreshGoogleCloudToken(credentials.refresh, credentials.projectId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown OAuth provider: ${provider}`);
|
||||
}
|
||||
|
||||
// Save new credentials
|
||||
saveOAuthCredentials(provider, newCredentials);
|
||||
|
||||
return newCredentials.access;
|
||||
export async function refreshToken(provider: OAuthProvider): Promise<string> {
|
||||
return refreshTokenFromAi(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth token for provider (auto-refreshes if expired)
|
||||
* Get OAuth token for provider (auto-refreshes if expired).
|
||||
*/
|
||||
export async function getOAuthToken(provider: SupportedOAuthProvider): Promise<string | null> {
|
||||
const credentials = loadOAuthCredentials(provider);
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired (with 5 min buffer already applied)
|
||||
if (Date.now() >= credentials.expires) {
|
||||
// Token expired - refresh it
|
||||
try {
|
||||
return await refreshToken(provider);
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh OAuth token for ${provider}:`, error);
|
||||
// Remove invalid credentials
|
||||
removeOAuthCredentials(provider);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return credentials.access;
|
||||
export async function getOAuthToken(provider: OAuthProvider): Promise<string | null> {
|
||||
return getOAuthApiKey(provider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { getAgentDir, getOAuthPath } from "../../config.js";
|
||||
|
||||
export interface OAuthCredentials {
|
||||
type: "oauth";
|
||||
refresh: string;
|
||||
access: string;
|
||||
expires: number;
|
||||
enterpriseUrl?: string;
|
||||
projectId?: string; // For Google Cloud Code Assist
|
||||
email?: string; // For Google Cloud Code Assist
|
||||
}
|
||||
|
||||
interface OAuthStorageFormat {
|
||||
[provider: string]: OAuthCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the config directory exists
|
||||
*/
|
||||
function ensureConfigDir(): void {
|
||||
const configDir = getAgentDir();
|
||||
if (!existsSync(configDir)) {
|
||||
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all OAuth credentials from oauth.json
|
||||
*/
|
||||
function loadStorage(): OAuthStorageFormat {
|
||||
const filePath = getOAuthPath();
|
||||
if (!existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Warning: Failed to load OAuth credentials: ${error}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all OAuth credentials to oauth.json
|
||||
*/
|
||||
function saveStorage(storage: OAuthStorageFormat): void {
|
||||
ensureConfigDir();
|
||||
const filePath = getOAuthPath();
|
||||
writeFileSync(filePath, JSON.stringify(storage, null, 2), "utf-8");
|
||||
// Set permissions to owner read/write only
|
||||
chmodSync(filePath, 0o600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OAuth credentials for a specific provider
|
||||
*/
|
||||
export function loadOAuthCredentials(provider: string): OAuthCredentials | null {
|
||||
const storage = loadStorage();
|
||||
return storage[provider] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OAuth credentials for a specific provider
|
||||
*/
|
||||
export function saveOAuthCredentials(provider: string, creds: OAuthCredentials): void {
|
||||
const storage = loadStorage();
|
||||
storage[provider] = creds;
|
||||
saveStorage(storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove OAuth credentials for a specific provider
|
||||
*/
|
||||
export function removeOAuthCredentials(provider: string): void {
|
||||
const storage = loadStorage();
|
||||
delete storage[provider];
|
||||
saveStorage(storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all providers with OAuth credentials
|
||||
*/
|
||||
export function listOAuthProviders(): string[] {
|
||||
const storage = loadStorage();
|
||||
return Object.keys(storage);
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ export {
|
|||
logout,
|
||||
type OAuthAuthInfo,
|
||||
type OAuthPrompt,
|
||||
type SupportedOAuthProvider,
|
||||
type OAuthProvider,
|
||||
} from "./core/oauth/index.js";
|
||||
export {
|
||||
type CompactionEntry,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { loadOAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, TruncatedText } from "@mariozechner/pi-tui";
|
||||
import { getOAuthProviders, type OAuthProviderInfo } from "../../../core/oauth/index.js";
|
||||
import { loadOAuthCredentials } from "../../../core/oauth/storage.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "../../c
|
|||
import type { HookUIContext } from "../../core/hooks/index.js";
|
||||
import { isBashExecutionMessage } from "../../core/messages.js";
|
||||
import { invalidateOAuthCache } from "../../core/model-config.js";
|
||||
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../core/oauth/index.js";
|
||||
import { listOAuthProviders, login, logout, type OAuthProvider } from "../../core/oauth/index.js";
|
||||
import { getLatestCompactionEntry, SUMMARY_PREFIX, SUMMARY_SUFFIX } from "../../core/session-manager.js";
|
||||
import { loadSkills } from "../../core/skills.js";
|
||||
import { loadProjectContextFiles } from "../../core/system-prompt.js";
|
||||
|
|
@ -1503,7 +1503,7 @@ export class InteractiveMode {
|
|||
|
||||
try {
|
||||
await login(
|
||||
providerId as SupportedOAuthProvider,
|
||||
providerId as OAuthProvider,
|
||||
(info) => {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(theme.fg("accent", "Opening browser to:"), 1, 0));
|
||||
|
|
@ -1563,7 +1563,7 @@ export class InteractiveMode {
|
|||
}
|
||||
} else {
|
||||
try {
|
||||
await logout(providerId as SupportedOAuthProvider);
|
||||
await logout(providerId as OAuthProvider);
|
||||
invalidateOAuthCache();
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue