feat: replace /connect claude with /connect codex for OpenAI OAuth (#527)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -2,7 +2,6 @@ import { hostname } from "node:os";
|
||||
import Letta from "@letta-ai/letta-client";
|
||||
import packageJson from "../../package.json";
|
||||
import { LETTA_CLOUD_API_URL, refreshAccessToken } from "../auth/oauth";
|
||||
import { ensureAnthropicProviderToken } from "../providers/anthropic-provider";
|
||||
import { settingsManager } from "../settings-manager";
|
||||
import { createTimingFetch, isTimingsEnabled } from "../utils/timing";
|
||||
|
||||
@@ -76,9 +75,8 @@ export async function getClient() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure Anthropic OAuth token is valid and provider is updated
|
||||
// This checks if token is expired, refreshes it, and updates the provider
|
||||
await ensureAnthropicProviderToken();
|
||||
// Note: OpenAI Codex OAuth token refresh is handled by the Letta backend
|
||||
// when using the chatgpt_oauth provider type
|
||||
|
||||
return new Letta({
|
||||
apiKey,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
OpenAIModelSettings,
|
||||
} from "@letta-ai/letta-client/resources/agents/agents";
|
||||
import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models";
|
||||
import { ANTHROPIC_PROVIDER_NAME } from "../providers/anthropic-provider";
|
||||
import { OPENAI_CODEX_PROVIDER_NAME } from "../providers/openai-codex-provider";
|
||||
import { getClient } from "./client";
|
||||
|
||||
type ModelSettings =
|
||||
@@ -25,11 +25,14 @@ function buildModelSettings(
|
||||
modelHandle: string,
|
||||
updateArgs?: Record<string, unknown>,
|
||||
): ModelSettings {
|
||||
const isOpenAI = modelHandle.startsWith("openai/");
|
||||
// Include our custom Anthropic OAuth provider (claude-pro-max)
|
||||
// Include our custom OpenAI Codex OAuth provider (chatgpt-plus-pro)
|
||||
const isOpenAI =
|
||||
modelHandle.startsWith("openai/") ||
|
||||
modelHandle.startsWith(`${OPENAI_CODEX_PROVIDER_NAME}/`);
|
||||
// Include legacy custom Anthropic OAuth provider (claude-pro-max)
|
||||
const isAnthropic =
|
||||
modelHandle.startsWith("anthropic/") ||
|
||||
modelHandle.startsWith(`${ANTHROPIC_PROVIDER_NAME}/`);
|
||||
modelHandle.startsWith("claude-pro-max/");
|
||||
const isZai = modelHandle.startsWith("zai/");
|
||||
const isGoogleAI = modelHandle.startsWith("google_ai/");
|
||||
const isGoogleVertex = modelHandle.startsWith("google_vertex/");
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* OAuth 2.0 utilities for Anthropic authentication
|
||||
* Uses Authorization Code Flow with PKCE
|
||||
*/
|
||||
|
||||
export const ANTHROPIC_OAUTH_CONFIG = {
|
||||
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
authorizationUrl: "https://claude.ai/oauth/authorize",
|
||||
tokenUrl: "https://console.anthropic.com/v1/oauth/token",
|
||||
redirectUri: "https://console.anthropic.com/oauth/code/callback",
|
||||
scope: "org:create_api_key user:profile user:inference",
|
||||
} as const;
|
||||
|
||||
export interface AnthropicTokens {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface OAuthError {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier (43-128 characters of unreserved URI characters)
|
||||
*/
|
||||
export function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code challenge from verifier using SHA-256
|
||||
*/
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure state parameter (32-byte hex)
|
||||
*/
|
||||
export function generateState(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode (RFC 4648)
|
||||
*/
|
||||
function base64UrlEncode(buffer: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...buffer));
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge
|
||||
*/
|
||||
export async function generatePKCE(): Promise<{
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
}> {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth flow - returns authorization URL and PKCE values
|
||||
*/
|
||||
export async function startAnthropicOAuth(): Promise<{
|
||||
authorizationUrl: string;
|
||||
state: string;
|
||||
codeVerifier: string;
|
||||
}> {
|
||||
const state = generateState();
|
||||
const { codeVerifier, codeChallenge } = await generatePKCE();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
|
||||
redirect_uri: ANTHROPIC_OAUTH_CONFIG.redirectUri,
|
||||
scope: ANTHROPIC_OAUTH_CONFIG.scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
const authorizationUrl = `${ANTHROPIC_OAUTH_CONFIG.authorizationUrl}?${params.toString()}`;
|
||||
|
||||
return {
|
||||
authorizationUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
state: string,
|
||||
): Promise<AnthropicTokens> {
|
||||
const response = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
|
||||
code,
|
||||
state,
|
||||
redirect_uri: ANTHROPIC_OAUTH_CONFIG.redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
// Show full response for debugging
|
||||
throw new Error(
|
||||
`Failed to exchange code for tokens (HTTP ${response.status}): ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as AnthropicTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token using a refresh token
|
||||
*/
|
||||
export async function refreshAnthropicToken(
|
||||
refreshToken: string,
|
||||
): Promise<AnthropicTokens> {
|
||||
const response = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json().catch(() => ({
|
||||
error: "unknown_error",
|
||||
error_description: `HTTP ${response.status}`,
|
||||
}))) as OAuthError;
|
||||
throw new Error(
|
||||
`Failed to refresh access token: ${error.error_description || error.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as AnthropicTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate credentials by making a test API call
|
||||
* OAuth tokens require the anthropic-beta header
|
||||
*/
|
||||
export async function validateAnthropicCredentials(
|
||||
accessToken: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Use the models endpoint to validate the token
|
||||
const response = await fetch("https://api.anthropic.com/v1/models", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid Anthropic access token, refreshing if necessary
|
||||
* Returns null if no OAuth tokens are configured
|
||||
*/
|
||||
export async function getAnthropicAccessToken(): Promise<string | null> {
|
||||
// Lazy import to avoid circular dependencies
|
||||
const { settingsManager } = await import("../settings-manager");
|
||||
|
||||
const tokens = settingsManager.getAnthropicTokens();
|
||||
if (!tokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired or about to expire (within 5 minutes)
|
||||
const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000;
|
||||
if (tokens.expires_at < fiveMinutesFromNow && tokens.refresh_token) {
|
||||
try {
|
||||
const newTokens = await refreshAnthropicToken(tokens.refresh_token);
|
||||
settingsManager.storeAnthropicTokens(newTokens);
|
||||
return newTokens.access_token;
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh Anthropic access token:", error);
|
||||
// Return existing token even if refresh failed - it might still work
|
||||
return tokens.access_token;
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Anthropic OAuth is configured and valid
|
||||
*/
|
||||
export async function hasValidAnthropicAuth(): Promise<boolean> {
|
||||
const token = await getAnthropicAccessToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
return validateAnthropicCredentials(token);
|
||||
}
|
||||
390
src/auth/openai-oauth.ts
Normal file
390
src/auth/openai-oauth.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* OAuth 2.0 utilities for OpenAI Codex authentication
|
||||
* Uses Authorization Code Flow with PKCE and local callback server
|
||||
* Compatible with Codex CLI authentication flow
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
|
||||
export const OPENAI_OAUTH_CONFIG = {
|
||||
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
authorizationUrl: "https://auth.openai.com/oauth/authorize",
|
||||
tokenUrl: "https://auth.openai.com/oauth/token",
|
||||
defaultPort: 1455,
|
||||
callbackPath: "/auth/callback",
|
||||
scope: "openid profile email offline_access",
|
||||
} as const;
|
||||
|
||||
export interface OpenAITokens {
|
||||
access_token: string;
|
||||
id_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface OAuthError {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
export interface OAuthCallbackResult {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a minimal OAuth callback page with ASCII art
|
||||
*/
|
||||
function renderOAuthPage(options: {
|
||||
success: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
autoClose?: boolean;
|
||||
}): string {
|
||||
const { title, message, autoClose } = options;
|
||||
|
||||
// ASCII art logo (escaped for HTML)
|
||||
const asciiLogo = ` ██████ ██╗ ███████╗████████╗████████╗ █████╗
|
||||
██ ██ ██║ ██╔════╝╚══██╔══╝╚══██╔══╝██╔══██╗
|
||||
██ ▇▇ ██ ██║ █████╗ ██║ ██║ ███████║
|
||||
██ ██ ██║ ██╔══╝ ██║ ██║ ██╔══██║
|
||||
██████ ███████╗███████╗ ██║ ██║ ██║ ██║
|
||||
╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title} - Letta Code</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #161616;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.ascii-art {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: #404040;
|
||||
white-space: pre;
|
||||
user-select: none;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #e5e5e5;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.message {
|
||||
font-size: 16px;
|
||||
color: #737373;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.ascii-art { font-size: 8px; }
|
||||
.title { font-size: 24px; }
|
||||
.message { font-size: 14px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="ascii-art">${asciiLogo}</div>
|
||||
<h1 class="title">${title}</h1>
|
||||
<p class="message">${message}</p>
|
||||
</div>
|
||||
${autoClose ? `<script>setTimeout(() => window.close(), 2000);</script>` : ""}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier (43-128 characters of unreserved URI characters)
|
||||
*/
|
||||
export function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code challenge from verifier using SHA-256
|
||||
*/
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure state parameter (32-byte hex)
|
||||
*/
|
||||
export function generateState(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode (RFC 4648)
|
||||
*/
|
||||
function base64UrlEncode(buffer: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...buffer));
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT payload (no signature verification - for local extraction only)
|
||||
*/
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid JWT format");
|
||||
}
|
||||
const payload = parts[1];
|
||||
if (!payload) {
|
||||
throw new Error("Missing JWT payload");
|
||||
}
|
||||
// Handle base64url encoding
|
||||
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const decoded = atob(padded);
|
||||
return JSON.parse(decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ChatGPT Account ID from access token JWT
|
||||
* The account ID is in the custom claim: https://api.openai.com/auth.chatgpt_account_id
|
||||
*/
|
||||
export function extractAccountIdFromToken(accessToken: string): string {
|
||||
try {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
// The account ID is in the custom claim path
|
||||
const authClaim = payload["https://api.openai.com/auth"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (authClaim && typeof authClaim.chatgpt_account_id === "string") {
|
||||
return authClaim.chatgpt_account_id;
|
||||
}
|
||||
throw new Error("chatgpt_account_id not found in token claims");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to extract account ID from token: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge
|
||||
*/
|
||||
export async function generatePKCE(): Promise<{
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
}> {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local HTTP server to receive OAuth callback
|
||||
* Returns a promise that resolves with the authorization code when received
|
||||
*/
|
||||
export function startLocalOAuthServer(
|
||||
expectedState: string,
|
||||
port = OPENAI_OAUTH_CONFIG.defaultPort,
|
||||
): Promise<{ result: OAuthCallbackResult; server: http.Server }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || "", `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === OPENAI_OAUTH_CONFIG.callbackPath) {
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const error = url.searchParams.get("error");
|
||||
const errorDescription = url.searchParams.get("error_description");
|
||||
|
||||
if (error) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
renderOAuthPage({
|
||||
success: false,
|
||||
title: "Authentication Failed",
|
||||
message: `Error: ${error}`,
|
||||
detail: errorDescription || undefined,
|
||||
}),
|
||||
);
|
||||
reject(
|
||||
new Error(`OAuth error: ${error} - ${errorDescription || ""}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
renderOAuthPage({
|
||||
success: false,
|
||||
title: "Authentication Failed",
|
||||
message: "Missing authorization code or state parameter.",
|
||||
}),
|
||||
);
|
||||
reject(new Error("Missing authorization code or state parameter"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== expectedState) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
renderOAuthPage({
|
||||
success: false,
|
||||
title: "Authentication Failed",
|
||||
message:
|
||||
"State mismatch - the authorization may have been tampered with.",
|
||||
}),
|
||||
);
|
||||
reject(
|
||||
new Error(
|
||||
"State mismatch - the authorization may have been tampered with",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success!
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
renderOAuthPage({
|
||||
success: true,
|
||||
title: "Authorization Successful",
|
||||
message: "You can close this window and return to Letta Code.",
|
||||
autoClose: true,
|
||||
}),
|
||||
);
|
||||
|
||||
resolve({ result: { code, state }, server });
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not found");
|
||||
}
|
||||
});
|
||||
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
reject(
|
||||
new Error(
|
||||
`Port ${port} is already in use. Please close any application using this port and try again.`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
// Server started successfully, waiting for callback
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(
|
||||
() => {
|
||||
server.close();
|
||||
reject(
|
||||
new Error("OAuth timeout - no callback received within 5 minutes"),
|
||||
);
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth flow - returns authorization URL and PKCE values
|
||||
* Also starts local server to receive callback
|
||||
*/
|
||||
export async function startOpenAIOAuth(
|
||||
port = OPENAI_OAUTH_CONFIG.defaultPort,
|
||||
): Promise<{
|
||||
authorizationUrl: string;
|
||||
state: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}> {
|
||||
const state = generateState();
|
||||
const { codeVerifier, codeChallenge } = await generatePKCE();
|
||||
const redirectUri = `http://localhost:${port}${OPENAI_OAUTH_CONFIG.callbackPath}`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: OPENAI_OAUTH_CONFIG.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: OPENAI_OAUTH_CONFIG.scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
id_token_add_organizations: "true",
|
||||
codex_cli_simplified_flow: "true",
|
||||
originator: "codex_cli_rs",
|
||||
});
|
||||
|
||||
const authorizationUrl = `${OPENAI_OAUTH_CONFIG.authorizationUrl}?${params.toString()}`;
|
||||
|
||||
return {
|
||||
authorizationUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
redirectUri: string,
|
||||
): Promise<OpenAITokens> {
|
||||
const response = await fetch(OPENAI_OAUTH_CONFIG.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: OPENAI_OAUTH_CONFIG.clientId,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Failed to exchange code for tokens (HTTP ${response.status}): ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as OpenAITokens;
|
||||
}
|
||||
@@ -95,7 +95,6 @@ import { MemoryViewer } from "./components/MemoryViewer";
|
||||
import { MessageSearch } from "./components/MessageSearch";
|
||||
import { ModelSelector } from "./components/ModelSelector";
|
||||
import { NewAgentDialog } from "./components/NewAgentDialog";
|
||||
import { OAuthCodeDialog } from "./components/OAuthCodeDialog";
|
||||
import { PendingApprovalStub } from "./components/PendingApprovalStub";
|
||||
import { PinDialog, validateAgentName } from "./components/PinDialog";
|
||||
// QuestionDialog removed - now using InlineQuestionApproval
|
||||
@@ -821,13 +820,17 @@ export default function App({
|
||||
| "new"
|
||||
| "mcp"
|
||||
| "help"
|
||||
| "oauth"
|
||||
| null;
|
||||
const [activeOverlay, setActiveOverlay] = useState<ActiveOverlay>(null);
|
||||
const [feedbackPrefill, setFeedbackPrefill] = useState("");
|
||||
const [modelSelectorOptions, setModelSelectorOptions] = useState<{
|
||||
filterProvider?: string;
|
||||
forceRefresh?: boolean;
|
||||
}>({});
|
||||
const closeOverlay = useCallback(() => {
|
||||
setActiveOverlay(null);
|
||||
setFeedbackPrefill("");
|
||||
setModelSelectorOptions({});
|
||||
}, []);
|
||||
|
||||
// Pin dialog state
|
||||
@@ -3469,6 +3472,7 @@ export default function App({
|
||||
|
||||
// Special handling for /model command - opens selector
|
||||
if (trimmed === "/model") {
|
||||
setModelSelectorOptions({}); // Clear any filters from previous connection
|
||||
setActiveOverlay("model");
|
||||
return { submitted: true };
|
||||
}
|
||||
@@ -3556,37 +3560,22 @@ export default function App({
|
||||
|
||||
// Special handling for /connect command - OAuth connection
|
||||
if (msg.trim().startsWith("/connect")) {
|
||||
const parts = msg.trim().split(/\s+/);
|
||||
const provider = parts[1]?.toLowerCase();
|
||||
const hasCode = parts.length > 2;
|
||||
|
||||
// Handle /connect zai - create zai-coding-plan provider
|
||||
if (provider === "zai") {
|
||||
const { handleConnectZai } = await import("./commands/connect");
|
||||
await handleConnectZai(
|
||||
{
|
||||
buffersRef,
|
||||
refreshDerived,
|
||||
setCommandRunning,
|
||||
},
|
||||
msg,
|
||||
);
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// If no code provided and provider is claude, show the OAuth dialog
|
||||
if (provider === "claude" && !hasCode) {
|
||||
setActiveOverlay("oauth");
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Otherwise (with code or invalid provider), use existing handler
|
||||
// Handle all /connect commands through the unified handler
|
||||
// For codex: uses local OAuth server (no dialog needed)
|
||||
// For zai: requires API key as argument
|
||||
const { handleConnect } = await import("./commands/connect");
|
||||
await handleConnect(
|
||||
{
|
||||
buffersRef,
|
||||
refreshDerived,
|
||||
setCommandRunning,
|
||||
onCodexConnected: () => {
|
||||
setModelSelectorOptions({
|
||||
filterProvider: "chatgpt-plus-pro",
|
||||
forceRefresh: true,
|
||||
});
|
||||
setActiveOverlay("model");
|
||||
},
|
||||
},
|
||||
msg,
|
||||
);
|
||||
@@ -6527,7 +6516,6 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
||||
const {
|
||||
env: _env,
|
||||
refreshToken: _refreshToken,
|
||||
anthropicOAuth: _anthropicOAuth,
|
||||
...safeSettings
|
||||
} = settings;
|
||||
|
||||
@@ -7382,6 +7370,8 @@ Plan file path: ${planFilePath}`;
|
||||
currentModelId={currentModelId ?? undefined}
|
||||
onSelect={handleModelSelect}
|
||||
onCancel={closeOverlay}
|
||||
filterProvider={modelSelectorOptions.filterProvider}
|
||||
forceRefresh={modelSelectorOptions.forceRefresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7755,40 +7745,6 @@ Plan file path: ${planFilePath}`;
|
||||
{/* Help Dialog - conditionally mounted as overlay */}
|
||||
{activeOverlay === "help" && <HelpDialog onClose={closeOverlay} />}
|
||||
|
||||
{/* OAuth Code Dialog - for Claude OAuth connection */}
|
||||
{activeOverlay === "oauth" && (
|
||||
<OAuthCodeDialog
|
||||
onComplete={(success, message) => {
|
||||
closeOverlay();
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/connect claude",
|
||||
output: message,
|
||||
phase: "finished",
|
||||
success,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
}}
|
||||
onCancel={closeOverlay}
|
||||
onModelSwitch={async (modelHandle: string) => {
|
||||
const { updateAgentLLMConfig } = await import(
|
||||
"../agent/modify"
|
||||
);
|
||||
const { getModelUpdateArgs, getModelInfo } = await import(
|
||||
"../agent/model"
|
||||
);
|
||||
const updateArgs = getModelUpdateArgs(modelHandle);
|
||||
await updateAgentLLMConfig(agentId, modelHandle, updateArgs);
|
||||
// Update current model display - use model id for correct "(current)" indicator
|
||||
const modelInfo = getModelInfo(modelHandle);
|
||||
setCurrentModelId(modelInfo?.id || modelHandle);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New Agent Dialog - for naming new agent before creation */}
|
||||
{activeOverlay === "new" && (
|
||||
<NewAgentDialog
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
|
||||
import {
|
||||
exchangeCodeForTokens,
|
||||
startAnthropicOAuth,
|
||||
validateAnthropicCredentials,
|
||||
} from "../../auth/anthropic-oauth";
|
||||
extractAccountIdFromToken,
|
||||
OPENAI_OAUTH_CONFIG,
|
||||
startLocalOAuthServer,
|
||||
startOpenAIOAuth,
|
||||
} from "../../auth/openai-oauth";
|
||||
import {
|
||||
ANTHROPIC_PROVIDER_NAME,
|
||||
checkAnthropicOAuthEligibility,
|
||||
createOrUpdateAnthropicProvider,
|
||||
removeAnthropicProvider,
|
||||
} from "../../providers/anthropic-provider";
|
||||
checkOpenAICodexEligibility,
|
||||
createOrUpdateOpenAICodexProvider,
|
||||
getOpenAICodexProvider,
|
||||
OPENAI_CODEX_PROVIDER_NAME,
|
||||
removeOpenAICodexProvider,
|
||||
} from "../../providers/openai-codex-provider";
|
||||
import {
|
||||
createOrUpdateZaiProvider,
|
||||
getZaiProvider,
|
||||
@@ -35,6 +38,7 @@ export interface ConnectCommandContext {
|
||||
buffersRef: { current: Buffers };
|
||||
refreshDerived: () => void;
|
||||
setCommandRunning: (running: boolean) => void;
|
||||
onCodexConnected?: () => void; // Callback to show model selector after successful connection
|
||||
}
|
||||
|
||||
// Helper to add a command result to buffers
|
||||
@@ -85,13 +89,13 @@ function updateCommandResult(
|
||||
|
||||
/**
|
||||
* Handle /connect command
|
||||
* Usage: /connect claude [code]
|
||||
* Usage: /connect codex
|
||||
*
|
||||
* Flow:
|
||||
* 1. User runs `/connect claude` - opens browser for authorization
|
||||
* 2. User authorizes on claude.ai, gets redirected to Anthropic's callback page
|
||||
* 3. User copies the authorization code from the URL
|
||||
* 4. User runs `/connect claude <code>` to complete the exchange
|
||||
* 1. User runs `/connect codex` - starts local server and opens browser for authorization
|
||||
* 2. User authorizes in browser, gets redirected back to local server
|
||||
* 3. Server automatically exchanges code for tokens and API key
|
||||
* 4. Provider is created and user sees success message
|
||||
*/
|
||||
export async function handleConnect(
|
||||
ctx: ConnectCommandContext,
|
||||
@@ -99,8 +103,6 @@ export async function handleConnect(
|
||||
): Promise<void> {
|
||||
const parts = msg.trim().split(/\s+/);
|
||||
const provider = parts[1]?.toLowerCase();
|
||||
// Join all remaining parts in case the code#state got split across lines
|
||||
const authCode = parts.slice(2).join(""); // Optional authorization code
|
||||
|
||||
// Validate provider argument
|
||||
if (!provider) {
|
||||
@@ -108,18 +110,18 @@ export async function handleConnect(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
msg,
|
||||
"Usage: /connect <provider> [options]\n\nAvailable providers:\n • claude - Connect via OAuth to authenticate without an API key\n • zai <api_key> - Connect to Zai with your API key",
|
||||
"Usage: /connect <provider> [options]\n\nAvailable providers:\n \u2022 codex - Connect via OAuth to authenticate with ChatGPT Plus/Pro\n \u2022 zai <api_key> - Connect to Zai with your API key",
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider !== "claude" && provider !== "zai") {
|
||||
if (provider !== "codex" && provider !== "zai") {
|
||||
addCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
msg,
|
||||
`Error: Unknown provider "${provider}"\n\nAvailable providers: claude, zai\nUsage: /connect <provider> [options]`,
|
||||
`Error: Unknown provider "${provider}"\n\nAvailable providers: codex, zai\nUsage: /connect <provider> [options]`,
|
||||
false,
|
||||
);
|
||||
return;
|
||||
@@ -131,28 +133,31 @@ export async function handleConnect(
|
||||
return;
|
||||
}
|
||||
|
||||
// If authorization code is provided, complete the OAuth flow
|
||||
if (authCode && authCode.length > 0) {
|
||||
await completeOAuthFlow(ctx, msg, authCode);
|
||||
return;
|
||||
}
|
||||
// Handle /connect codex
|
||||
await handleConnectCodex(ctx, msg);
|
||||
}
|
||||
|
||||
// Check if already connected
|
||||
if (
|
||||
settingsManager.hasAnthropicOAuth() &&
|
||||
!settingsManager.isAnthropicTokenExpired()
|
||||
) {
|
||||
/**
|
||||
* Handle /connect codex - OpenAI Codex OAuth with local server
|
||||
*/
|
||||
async function handleConnectCodex(
|
||||
ctx: ConnectCommandContext,
|
||||
msg: string,
|
||||
): Promise<void> {
|
||||
// Check if already connected (provider exists on backend)
|
||||
const existingProvider = await getOpenAICodexProvider();
|
||||
if (existingProvider) {
|
||||
addCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
msg,
|
||||
"Already connected to Claude via OAuth.\n\nUse /disconnect to remove the current connection first.",
|
||||
"Already connected to OpenAI Codex via OAuth.\n\nUse /disconnect codex to remove the current connection first.",
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the OAuth flow (step 1)
|
||||
// Start the OAuth flow
|
||||
ctx.setCommandRunning(true);
|
||||
|
||||
// Show initial status
|
||||
@@ -167,20 +172,20 @@ export async function handleConnect(
|
||||
|
||||
try {
|
||||
// 1. Check eligibility before starting OAuth flow
|
||||
const eligibility = await checkAnthropicOAuthEligibility();
|
||||
const eligibility = await checkOpenAICodexEligibility();
|
||||
if (!eligibility.eligible) {
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`✗ Claude OAuth requires a Pro or Enterprise plan\n\n` +
|
||||
`\u2717 OpenAI Codex OAuth requires a Pro or Enterprise plan\n\n` +
|
||||
`This feature is only available for Letta Pro or Enterprise customers.\n` +
|
||||
`Current plan: ${eligibility.billing_tier}\n\n` +
|
||||
`To upgrade your plan, visit:\n\n` +
|
||||
` https://app.letta.com/settings/organization/usage\n\n` +
|
||||
`If you have an Anthropic API key, you can use it directly by setting:\n` +
|
||||
` export ANTHROPIC_API_KEY=your-key`,
|
||||
`If you have an OpenAI API key, you can use it directly by setting:\n` +
|
||||
` export OPENAI_API_KEY=your-key`,
|
||||
false,
|
||||
"finished",
|
||||
);
|
||||
@@ -188,18 +193,46 @@ export async function handleConnect(
|
||||
}
|
||||
|
||||
// 2. Start OAuth flow - generate PKCE and authorization URL
|
||||
const { authorizationUrl, state, codeVerifier } =
|
||||
await startAnthropicOAuth();
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
"Starting OAuth flow...\nA browser window will open for authorization.",
|
||||
true,
|
||||
"running",
|
||||
);
|
||||
|
||||
// 3. Store state for validation when user returns with code
|
||||
settingsManager.storeOAuthState(state, codeVerifier, "anthropic");
|
||||
const { authorizationUrl, state, codeVerifier, redirectUri } =
|
||||
await startOpenAIOAuth(OPENAI_OAUTH_CONFIG.defaultPort);
|
||||
|
||||
// 4. Try to open browser
|
||||
let browserOpened = false;
|
||||
// 3. Store state for validation
|
||||
settingsManager.storeOAuthState(state, codeVerifier, redirectUri, "openai");
|
||||
|
||||
// 4. Start local server to receive callback
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`Starting local OAuth server on port ${OPENAI_OAUTH_CONFIG.defaultPort}...\n\n` +
|
||||
`Opening browser for authorization...\n` +
|
||||
`If the browser doesn't open automatically, visit:\n\n` +
|
||||
`${authorizationUrl}`,
|
||||
true,
|
||||
"running",
|
||||
);
|
||||
|
||||
// Start the server and wait for callback
|
||||
const serverPromise = startLocalOAuthServer(
|
||||
state,
|
||||
OPENAI_OAUTH_CONFIG.defaultPort,
|
||||
);
|
||||
|
||||
// 5. Try to open browser
|
||||
try {
|
||||
const { default: open } = await import("open");
|
||||
const subprocess = await open(authorizationUrl, { wait: false });
|
||||
browserOpened = true;
|
||||
// Handle errors from the spawned process (e.g., xdg-open not found in containers)
|
||||
subprocess.on("error", () => {
|
||||
// Silently ignore - user can still manually visit the URL
|
||||
@@ -208,143 +241,81 @@ export async function handleConnect(
|
||||
// If auto-open fails, user can still manually visit the URL
|
||||
}
|
||||
|
||||
// 5. Show instructions
|
||||
const browserMsg = browserOpened
|
||||
? "Opening browser for authorization..."
|
||||
: "Please open the following URL in your browser:";
|
||||
|
||||
// 6. Wait for callback
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`${browserMsg}\n\n${authorizationUrl}\n\n` +
|
||||
"After authorizing, you'll be redirected to a page showing: code#state\n" +
|
||||
"Copy the entire value and run:\n\n" +
|
||||
" /connect claude <code#state>\n\n" +
|
||||
"Example: /connect claude abc123...#def456...",
|
||||
`Waiting for authorization...\n\n` +
|
||||
`Please complete the sign-in process in your browser.\n` +
|
||||
`The page will redirect automatically when done.\n\n` +
|
||||
`If needed, visit:\n${authorizationUrl}`,
|
||||
true,
|
||||
"finished",
|
||||
"running",
|
||||
);
|
||||
} catch (error) {
|
||||
// Clear any partial state
|
||||
settingsManager.clearOAuthState();
|
||||
|
||||
const { result, server } = await serverPromise;
|
||||
|
||||
// Close the server
|
||||
server.close();
|
||||
|
||||
// 7. Exchange code for tokens
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`✗ Failed to start OAuth flow: ${getErrorMessage(error)}`,
|
||||
false,
|
||||
"finished",
|
||||
"Authorization received! Exchanging code for tokens...",
|
||||
true,
|
||||
"running",
|
||||
);
|
||||
} finally {
|
||||
ctx.setCommandRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete OAuth flow after user provides authorization code
|
||||
* Accepts either:
|
||||
* - Just the code: "n3nzU6B7gMep..."
|
||||
* - Code#state format: "n3nzU6B7gMep...#9ba626d8..."
|
||||
*/
|
||||
async function completeOAuthFlow(
|
||||
ctx: ConnectCommandContext,
|
||||
msg: string,
|
||||
authCodeInput: string,
|
||||
): Promise<void> {
|
||||
// Show initial status
|
||||
const cmdId = addCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
msg,
|
||||
"Exchanging authorization code for tokens...",
|
||||
true,
|
||||
"running",
|
||||
);
|
||||
|
||||
ctx.setCommandRunning(true);
|
||||
|
||||
try {
|
||||
// 1. Get stored OAuth state
|
||||
const storedState = settingsManager.getOAuthState();
|
||||
if (!storedState) {
|
||||
throw new Error(
|
||||
"No pending OAuth flow found. Please run '/connect claude' first to start the authorization.",
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Check if state is too old (5 minute timeout)
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
if (Date.now() - storedState.timestamp > fiveMinutes) {
|
||||
settingsManager.clearOAuthState();
|
||||
throw new Error(
|
||||
"OAuth session expired. Please run '/connect claude' again to start a new authorization.",
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Parse code#state format if provided
|
||||
let authCode = authCodeInput;
|
||||
let stateFromInput: string | undefined;
|
||||
if (authCodeInput.includes("#")) {
|
||||
const [code, stateVal] = authCodeInput.split("#");
|
||||
authCode = code ?? authCodeInput;
|
||||
stateFromInput = stateVal;
|
||||
// Validate state matches what we stored
|
||||
if (stateVal && stateVal !== storedState.state) {
|
||||
throw new Error(
|
||||
"State mismatch - the authorization may have been tampered with. Please try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use state from input if provided, otherwise use stored state
|
||||
const stateToUse = stateFromInput || storedState.state;
|
||||
|
||||
// 4. Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(
|
||||
authCode,
|
||||
storedState.codeVerifier,
|
||||
stateToUse,
|
||||
result.code,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
);
|
||||
|
||||
// 5. Update status
|
||||
// 8. Extract account ID from JWT
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
"Validating credentials...",
|
||||
"Extracting account information...",
|
||||
true,
|
||||
"running",
|
||||
);
|
||||
|
||||
// 6. Validate tokens work
|
||||
const isValid = await validateAnthropicCredentials(tokens.access_token);
|
||||
if (!isValid) {
|
||||
let accountId: string;
|
||||
try {
|
||||
accountId = extractAccountIdFromToken(tokens.access_token);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"Token validation failed - the token may not have the required permissions.",
|
||||
`Failed to extract account ID from token. This may indicate an incompatible account type. Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Store tokens locally
|
||||
settingsManager.storeAnthropicTokens(tokens);
|
||||
|
||||
// 8. Update status for provider creation
|
||||
// 9. Create or update provider in Letta with OAuth config
|
||||
// Backend handles request transformation to ChatGPT backend API
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
"Creating Anthropic provider...",
|
||||
"Creating OpenAI Codex provider...",
|
||||
true,
|
||||
"running",
|
||||
);
|
||||
|
||||
// 9. Create or update provider in Letta with the access token
|
||||
await createOrUpdateAnthropicProvider(tokens.access_token);
|
||||
await createOrUpdateOpenAICodexProvider({
|
||||
access_token: tokens.access_token,
|
||||
id_token: tokens.id_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
account_id: accountId,
|
||||
expires_at: Date.now() + tokens.expires_in * 1000,
|
||||
});
|
||||
|
||||
// 10. Clear OAuth state
|
||||
settingsManager.clearOAuthState();
|
||||
@@ -355,27 +326,36 @@ async function completeOAuthFlow(
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`✓ Successfully connected to Claude via OAuth!\n\n` +
|
||||
`Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` +
|
||||
`Your OAuth tokens are stored securely in ~/.letta/settings.json`,
|
||||
`\u2713 Successfully connected to OpenAI Codex!\n\n` +
|
||||
`Provider '${OPENAI_CODEX_PROVIDER_NAME}' created/updated in Letta.\n` +
|
||||
`Your ChatGPT Plus/Pro subscription is now linked.`,
|
||||
true,
|
||||
"finished",
|
||||
);
|
||||
|
||||
// 12. Show model selector to let user switch to a ChatGPT Plus/Pro model
|
||||
if (ctx.onCodexConnected) {
|
||||
// Small delay to let the success message render first
|
||||
setTimeout(() => ctx.onCodexConnected?.(), 500);
|
||||
}
|
||||
} catch (error) {
|
||||
// Clear any partial state
|
||||
settingsManager.clearOAuthState();
|
||||
|
||||
// Check if this is a plan upgrade requirement error from provider creation
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
let displayMessage: string;
|
||||
if (errorMessage === "PLAN_UPGRADE_REQUIRED") {
|
||||
displayMessage =
|
||||
`✗ Claude OAuth requires a Pro or Enterprise plan\n\n` +
|
||||
`\u2717 OpenAI Codex OAuth requires a Pro or Enterprise plan\n\n` +
|
||||
`This feature is only available for Letta Pro or Enterprise customers.\n` +
|
||||
`To upgrade your plan, visit:\n\n` +
|
||||
` https://app.letta.com/settings/organization/usage\n\n` +
|
||||
`If you have an Anthropic API key, you can use it directly by setting:\n` +
|
||||
` export ANTHROPIC_API_KEY=your-key`;
|
||||
`If you have an OpenAI API key, you can use it directly by setting:\n` +
|
||||
` export OPENAI_API_KEY=your-key`;
|
||||
} else {
|
||||
displayMessage = `✗ Failed to connect: ${errorMessage}`;
|
||||
displayMessage = `\u2717 Failed to connect: ${errorMessage}`;
|
||||
}
|
||||
|
||||
updateCommandResult(
|
||||
@@ -409,7 +389,7 @@ export async function handleDisconnect(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
msg,
|
||||
"Usage: /disconnect <provider>\n\nAvailable providers: claude, zai",
|
||||
"Usage: /disconnect <provider>\n\nAvailable providers: codex, claude, zai",
|
||||
false,
|
||||
);
|
||||
return;
|
||||
@@ -421,7 +401,13 @@ export async function handleDisconnect(
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle /disconnect claude
|
||||
// Handle /disconnect codex
|
||||
if (provider === "codex") {
|
||||
await handleDisconnectCodex(ctx, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle /disconnect claude (legacy - for users who connected before)
|
||||
if (provider === "claude") {
|
||||
await handleDisconnectClaude(ctx, msg);
|
||||
return;
|
||||
@@ -432,25 +418,26 @@ export async function handleDisconnect(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
msg,
|
||||
`Error: Unknown provider "${provider}"\n\nAvailable providers: claude, zai\nUsage: /disconnect <provider>`,
|
||||
`Error: Unknown provider "${provider}"\n\nAvailable providers: codex, claude, zai\nUsage: /disconnect <provider>`,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /disconnect claude
|
||||
* Handle /disconnect codex
|
||||
*/
|
||||
async function handleDisconnectClaude(
|
||||
async function handleDisconnectCodex(
|
||||
ctx: ConnectCommandContext,
|
||||
msg: string,
|
||||
): Promise<void> {
|
||||
// Check if connected
|
||||
if (!settingsManager.hasAnthropicOAuth()) {
|
||||
// Check if provider exists on backend
|
||||
const existingProvider = await getOpenAICodexProvider();
|
||||
if (!existingProvider) {
|
||||
addCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
msg,
|
||||
"Not currently connected to Claude via OAuth.\n\nUse /connect claude to authenticate.",
|
||||
"Not currently connected to OpenAI Codex via OAuth.\n\nUse /connect codex to authenticate.",
|
||||
false,
|
||||
);
|
||||
return;
|
||||
@@ -461,7 +448,7 @@ async function handleDisconnectClaude(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
msg,
|
||||
"Disconnecting from Claude OAuth...",
|
||||
"Disconnecting from OpenAI Codex OAuth...",
|
||||
true,
|
||||
"running",
|
||||
);
|
||||
@@ -469,37 +456,117 @@ async function handleDisconnectClaude(
|
||||
ctx.setCommandRunning(true);
|
||||
|
||||
try {
|
||||
// Remove provider from Letta
|
||||
await removeAnthropicProvider();
|
||||
|
||||
// Clear local tokens
|
||||
settingsManager.clearAnthropicOAuth();
|
||||
// Remove provider from Letta backend
|
||||
await removeOpenAICodexProvider();
|
||||
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`✓ Disconnected from Claude OAuth.\n\n` +
|
||||
`Provider '${ANTHROPIC_PROVIDER_NAME}' removed from Letta.`,
|
||||
`\u2713 Disconnected from OpenAI Codex OAuth.\n\n` +
|
||||
`Provider '${OPENAI_CODEX_PROVIDER_NAME}' removed from Letta.`,
|
||||
true,
|
||||
"finished",
|
||||
);
|
||||
} catch (error) {
|
||||
// Still clear local tokens even if provider removal fails
|
||||
settingsManager.clearAnthropicOAuth();
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`\u2717 Failed to disconnect from OpenAI Codex: ${getErrorMessage(error)}`,
|
||||
false,
|
||||
"finished",
|
||||
);
|
||||
} finally {
|
||||
ctx.setCommandRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /disconnect claude (legacy provider removal)
|
||||
* This allows users who connected Claude before it was replaced with Codex
|
||||
* to remove the old claude-pro-max provider
|
||||
*/
|
||||
async function handleDisconnectClaude(
|
||||
ctx: ConnectCommandContext,
|
||||
msg: string,
|
||||
): Promise<void> {
|
||||
const CLAUDE_PROVIDER_NAME = "claude-pro-max";
|
||||
|
||||
// Show running status
|
||||
const cmdId = addCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
msg,
|
||||
"Checking for Claude provider...",
|
||||
true,
|
||||
"running",
|
||||
);
|
||||
|
||||
ctx.setCommandRunning(true);
|
||||
|
||||
try {
|
||||
// Check if claude-pro-max provider exists
|
||||
const { listProviders } = await import(
|
||||
"../../providers/openai-codex-provider"
|
||||
);
|
||||
const providers = await listProviders();
|
||||
const claudeProvider = providers.find(
|
||||
(p) => p.name === CLAUDE_PROVIDER_NAME,
|
||||
);
|
||||
|
||||
if (!claudeProvider) {
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`No Claude provider found.\n\nThe '${CLAUDE_PROVIDER_NAME}' provider does not exist in your Letta account.`,
|
||||
false,
|
||||
"finished",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove provider from Letta
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
"Removing Claude provider...",
|
||||
true,
|
||||
"running",
|
||||
);
|
||||
|
||||
const { deleteOpenAICodexProvider } = await import(
|
||||
"../../providers/openai-codex-provider"
|
||||
);
|
||||
await deleteOpenAICodexProvider(claudeProvider.id);
|
||||
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`✓ Disconnected from Claude OAuth.\n\n` +
|
||||
`Warning: Failed to remove provider from Letta: ${getErrorMessage(error)}\n` +
|
||||
`Your local OAuth tokens have been removed.`,
|
||||
`✓ Disconnected from Claude.\n\n` +
|
||||
`Provider '${CLAUDE_PROVIDER_NAME}' has been removed from Letta.\n\n` +
|
||||
`Note: /connect claude has been replaced with /connect codex for OpenAI ChatGPT Plus/Pro.`,
|
||||
true,
|
||||
"finished",
|
||||
);
|
||||
} catch (error) {
|
||||
updateCommandResult(
|
||||
ctx.buffersRef,
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`✗ Failed to disconnect from Claude: ${getErrorMessage(error)}`,
|
||||
false,
|
||||
"finished",
|
||||
);
|
||||
} finally {
|
||||
ctx.setCommandRunning(false);
|
||||
}
|
||||
@@ -546,7 +613,7 @@ async function handleDisconnectZai(
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`✓ Disconnected from Zai.\n\n` +
|
||||
`\u2713 Disconnected from Zai.\n\n` +
|
||||
`Provider '${ZAI_PROVIDER_NAME}' removed from Letta.`,
|
||||
true,
|
||||
"finished",
|
||||
@@ -557,7 +624,7 @@ async function handleDisconnectZai(
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`✗ Failed to disconnect from Zai: ${getErrorMessage(error)}`,
|
||||
`\u2717 Failed to disconnect from Zai: ${getErrorMessage(error)}`,
|
||||
false,
|
||||
"finished",
|
||||
);
|
||||
@@ -615,9 +682,9 @@ export async function handleConnectZai(
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`✓ Successfully connected to Zai!\n\n` +
|
||||
`\u2713 Successfully connected to Zai!\n\n` +
|
||||
`Provider '${ZAI_PROVIDER_NAME}' created in Letta.\n\n` +
|
||||
`The models are populated in /model → "All Available Models"`,
|
||||
`The models are populated in /model \u2192 "All Available Models"`,
|
||||
true,
|
||||
"finished",
|
||||
);
|
||||
@@ -627,7 +694,7 @@ export async function handleConnectZai(
|
||||
ctx.refreshDerived,
|
||||
cmdId,
|
||||
msg,
|
||||
`✗ Failed to create Zai provider: ${getErrorMessage(error)}`,
|
||||
`\u2717 Failed to create Zai provider: ${getErrorMessage(error)}`,
|
||||
false,
|
||||
"finished",
|
||||
);
|
||||
|
||||
@@ -265,7 +265,7 @@ export const commands: Record<string, Command> = {
|
||||
|
||||
// === Session management (order 40-49) ===
|
||||
"/connect": {
|
||||
desc: "Connect an existing account (/connect zai <api-key>)",
|
||||
desc: "Connect an existing account (/connect codex or /connect zai <api-key>)",
|
||||
order: 40,
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx
|
||||
@@ -273,7 +273,7 @@ export const commands: Record<string, Command> = {
|
||||
},
|
||||
},
|
||||
"/disconnect": {
|
||||
desc: "Disconnect an existing account (/disconnect zai)",
|
||||
desc: "Disconnect an existing account (/disconnect codex|claude|zai)",
|
||||
order: 41,
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "../../constants";
|
||||
import type { PermissionMode } from "../../permissions/mode";
|
||||
import { permissionMode } from "../../permissions/mode";
|
||||
import { ANTHROPIC_PROVIDER_NAME } from "../../providers/anthropic-provider";
|
||||
import { OPENAI_CODEX_PROVIDER_NAME } from "../../providers/openai-codex-provider";
|
||||
import { ralphMode } from "../../ralph/mode";
|
||||
import { settingsManager } from "../../settings-manager";
|
||||
import { charsToTokens, formatCompact } from "../helpers/format";
|
||||
@@ -50,7 +50,7 @@ const InputFooter = memo(function InputFooter({
|
||||
showExitHint,
|
||||
agentName,
|
||||
currentModel,
|
||||
isAnthropicProvider,
|
||||
isOpenAICodexProvider,
|
||||
}: {
|
||||
ctrlCPressed: boolean;
|
||||
escapePressed: boolean;
|
||||
@@ -60,7 +60,7 @@ const InputFooter = memo(function InputFooter({
|
||||
showExitHint: boolean;
|
||||
agentName: string | null | undefined;
|
||||
currentModel: string | null | undefined;
|
||||
isAnthropicProvider: boolean;
|
||||
isOpenAICodexProvider: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box justifyContent="space-between" marginBottom={1}>
|
||||
@@ -90,8 +90,8 @@ const InputFooter = memo(function InputFooter({
|
||||
<Text>
|
||||
<Text color={colors.footer.agentName}>{agentName || "Unnamed"}</Text>
|
||||
<Text
|
||||
dimColor={!isAnthropicProvider}
|
||||
color={isAnthropicProvider ? "#FFC787" : undefined}
|
||||
dimColor={!isOpenAICodexProvider}
|
||||
color={isOpenAICodexProvider ? "#74AA9C" : undefined}
|
||||
>
|
||||
{` [${currentModel ?? "unknown"}]`}
|
||||
</Text>
|
||||
@@ -838,7 +838,9 @@ export function Input({
|
||||
showExitHint={ralphActive || ralphPending}
|
||||
agentName={agentName}
|
||||
currentModel={currentModel}
|
||||
isAnthropicProvider={currentModelProvider === ANTHROPIC_PROVIDER_NAME}
|
||||
isOpenAICodexProvider={
|
||||
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -28,12 +28,18 @@ interface ModelSelectorProps {
|
||||
currentModelId?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
onCancel: () => void;
|
||||
/** Filter models to only show those matching this provider prefix (e.g., "chatgpt-plus-pro") */
|
||||
filterProvider?: string;
|
||||
/** Force refresh the models list on mount */
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
currentModelId,
|
||||
onSelect,
|
||||
onCancel,
|
||||
filterProvider,
|
||||
forceRefresh: forceRefreshOnMount,
|
||||
}: ModelSelectorProps) {
|
||||
const typedModels = models as UiModel[];
|
||||
const [category, setCategory] = useState<ModelCategory>("supported");
|
||||
@@ -94,8 +100,8 @@ export function ModelSelector({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadModels.current(false);
|
||||
}, []);
|
||||
loadModels.current(forceRefreshOnMount ?? false);
|
||||
}, [forceRefreshOnMount]);
|
||||
|
||||
// Handles from models.json (for filtering "all" category)
|
||||
const staticModelHandles = useMemo(
|
||||
@@ -105,16 +111,23 @@ export function ModelSelector({
|
||||
|
||||
// Supported models: models.json entries that are available
|
||||
// Featured models first, then non-featured, preserving JSON order within each group
|
||||
// If filterProvider is set, only show models from that provider
|
||||
const supportedModels = useMemo(() => {
|
||||
if (availableHandles === undefined) return [];
|
||||
const available =
|
||||
let available =
|
||||
availableHandles === null
|
||||
? typedModels // fallback
|
||||
: typedModels.filter((m) => availableHandles.has(m.handle));
|
||||
// Apply provider filter if specified
|
||||
if (filterProvider) {
|
||||
available = available.filter((m) =>
|
||||
m.handle.startsWith(`${filterProvider}/`),
|
||||
);
|
||||
}
|
||||
const featured = available.filter((m) => m.isFeatured);
|
||||
const nonFeatured = available.filter((m) => !m.isFeatured);
|
||||
return [...featured, ...nonFeatured];
|
||||
}, [typedModels, availableHandles]);
|
||||
}, [typedModels, availableHandles, filterProvider]);
|
||||
|
||||
// All other models: API handles not in models.json
|
||||
const otherModelHandles = useMemo(() => {
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import {
|
||||
exchangeCodeForTokens,
|
||||
startAnthropicOAuth,
|
||||
validateAnthropicCredentials,
|
||||
} from "../../auth/anthropic-oauth";
|
||||
import {
|
||||
ANTHROPIC_PROVIDER_NAME,
|
||||
checkAnthropicOAuthEligibility,
|
||||
createOrUpdateAnthropicProvider,
|
||||
} from "../../providers/anthropic-provider";
|
||||
import { settingsManager } from "../../settings-manager";
|
||||
import { colors } from "./colors";
|
||||
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
||||
|
||||
type Props = {
|
||||
onComplete: (success: boolean, message: string) => void;
|
||||
onCancel: () => void;
|
||||
onModelSwitch?: (modelHandle: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type FlowState =
|
||||
| "initializing"
|
||||
| "checking_eligibility"
|
||||
| "waiting_for_code"
|
||||
| "exchanging"
|
||||
| "validating"
|
||||
| "creating_provider"
|
||||
| "fetching_models"
|
||||
| "select_model"
|
||||
| "switching_model"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
export const OAuthCodeDialog = memo(
|
||||
({ onComplete, onCancel, onModelSwitch }: Props) => {
|
||||
const [flowState, setFlowState] = useState<FlowState>("initializing");
|
||||
const [authUrl, setAuthUrl] = useState<string>("");
|
||||
const [codeInput, setCodeInput] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const [codeVerifier, setCodeVerifier] = useState<string>("");
|
||||
const [state, setState] = useState<string>("");
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [selectedModelIndex, setSelectedModelIndex] = useState(0);
|
||||
|
||||
// Initialize OAuth flow on mount
|
||||
useEffect(() => {
|
||||
const initFlow = async () => {
|
||||
try {
|
||||
// Check if already connected
|
||||
if (
|
||||
settingsManager.hasAnthropicOAuth() &&
|
||||
!settingsManager.isAnthropicTokenExpired()
|
||||
) {
|
||||
onComplete(
|
||||
false,
|
||||
"Already connected to Claude via OAuth.\n\nUse /disconnect to remove the current connection first.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check eligibility before starting OAuth flow
|
||||
setFlowState("checking_eligibility");
|
||||
const eligibility = await checkAnthropicOAuthEligibility();
|
||||
if (!eligibility.eligible) {
|
||||
onComplete(
|
||||
false,
|
||||
`✗ Claude OAuth requires a Pro or Enterprise plan\n\n` +
|
||||
`This feature is only available for Letta Pro or Enterprise customers.\n` +
|
||||
`Current plan: ${eligibility.billing_tier}\n\n` +
|
||||
`To upgrade your plan, visit:\n\n` +
|
||||
` https://app.letta.com/settings/organization/usage\n\n` +
|
||||
`If you have an Anthropic API key, you can use it directly by setting:\n` +
|
||||
` export ANTHROPIC_API_KEY=your-key`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start OAuth flow
|
||||
const {
|
||||
authorizationUrl,
|
||||
state: oauthState,
|
||||
codeVerifier: verifier,
|
||||
} = await startAnthropicOAuth();
|
||||
|
||||
// Store state for validation
|
||||
settingsManager.storeOAuthState(oauthState, verifier, "anthropic");
|
||||
|
||||
setAuthUrl(authorizationUrl);
|
||||
setCodeVerifier(verifier);
|
||||
setState(oauthState);
|
||||
setFlowState("waiting_for_code");
|
||||
|
||||
// Try to open browser
|
||||
try {
|
||||
const { default: open } = await import("open");
|
||||
const subprocess = await open(authorizationUrl, { wait: false });
|
||||
subprocess.on("error", () => {
|
||||
// Silently ignore - user can manually visit URL
|
||||
});
|
||||
} catch {
|
||||
// If auto-open fails, user can still manually visit the URL
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
setFlowState("error");
|
||||
}
|
||||
};
|
||||
|
||||
initFlow();
|
||||
}, [onComplete]);
|
||||
|
||||
// Handle keyboard input
|
||||
useInput((input, key) => {
|
||||
// CTRL-C: cancel at any cancelable state
|
||||
if (key.ctrl && input === "c") {
|
||||
if (flowState === "waiting_for_code" || flowState === "select_model") {
|
||||
settingsManager.clearOAuthState();
|
||||
onCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.escape && flowState === "waiting_for_code") {
|
||||
settingsManager.clearOAuthState();
|
||||
onCancel();
|
||||
}
|
||||
|
||||
// Handle model selection navigation
|
||||
if (flowState === "select_model") {
|
||||
if (key.upArrow) {
|
||||
setSelectedModelIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedModelIndex((prev) =>
|
||||
Math.min(availableModels.length - 1, prev + 1),
|
||||
);
|
||||
} else if (key.return && onModelSwitch) {
|
||||
// Select current model
|
||||
const selectedModel = availableModels[selectedModelIndex];
|
||||
if (selectedModel) {
|
||||
handleModelSelection(selectedModel);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
// Skip model selection
|
||||
skipModelSelection();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle model selection
|
||||
const handleModelSelection = async (modelHandle: string) => {
|
||||
if (!onModelSwitch) return;
|
||||
|
||||
setFlowState("switching_model");
|
||||
try {
|
||||
await onModelSwitch(modelHandle);
|
||||
setFlowState("success");
|
||||
onComplete(
|
||||
true,
|
||||
`✓ Successfully connected to Claude via OAuth!\n\n` +
|
||||
`Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` +
|
||||
`Switched to model: ${modelHandle.replace(`${ANTHROPIC_PROVIDER_NAME}/`, "")}`,
|
||||
);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error));
|
||||
setFlowState("error");
|
||||
}
|
||||
};
|
||||
|
||||
// Skip model selection
|
||||
const skipModelSelection = () => {
|
||||
setFlowState("success");
|
||||
onComplete(
|
||||
true,
|
||||
`✓ Successfully connected to Claude via OAuth!\n\n` +
|
||||
`Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` +
|
||||
`Your OAuth tokens are stored securely in ~/.letta/settings.json\n` +
|
||||
`Use /model to switch to a Claude model.`,
|
||||
);
|
||||
};
|
||||
|
||||
// Handle code submission
|
||||
const handleSubmit = async (input: string) => {
|
||||
if (!input.trim()) return;
|
||||
|
||||
try {
|
||||
setFlowState("exchanging");
|
||||
|
||||
// Parse code#state format
|
||||
let authCode = input.trim();
|
||||
let stateFromInput: string | undefined;
|
||||
|
||||
if (authCode.includes("#")) {
|
||||
const [code, inputState] = authCode.split("#");
|
||||
authCode = code ?? input.trim();
|
||||
stateFromInput = inputState;
|
||||
|
||||
// Validate state matches
|
||||
if (stateFromInput && stateFromInput !== state) {
|
||||
throw new Error(
|
||||
"State mismatch - the authorization may have been tampered with. Please try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const stateToUse = stateFromInput || state;
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(
|
||||
authCode,
|
||||
codeVerifier,
|
||||
stateToUse,
|
||||
);
|
||||
|
||||
setFlowState("validating");
|
||||
|
||||
// Validate tokens
|
||||
const isValid = await validateAnthropicCredentials(tokens.access_token);
|
||||
if (!isValid) {
|
||||
throw new Error(
|
||||
"Token validation failed - the token may not have the required permissions.",
|
||||
);
|
||||
}
|
||||
|
||||
// Store tokens locally
|
||||
settingsManager.storeAnthropicTokens(tokens);
|
||||
|
||||
setFlowState("creating_provider");
|
||||
|
||||
// Create/update provider in Letta
|
||||
await createOrUpdateAnthropicProvider(tokens.access_token);
|
||||
|
||||
// Clear OAuth state
|
||||
settingsManager.clearOAuthState();
|
||||
|
||||
// If we have a model switch handler, try to fetch available models
|
||||
if (onModelSwitch) {
|
||||
setFlowState("fetching_models");
|
||||
try {
|
||||
const { getAvailableModelHandles } = await import(
|
||||
"../../agent/available-models"
|
||||
);
|
||||
const result = await getAvailableModelHandles({
|
||||
forceRefresh: true,
|
||||
});
|
||||
|
||||
// Filter to only claude-pro-max models
|
||||
const claudeModels = Array.from(result.handles)
|
||||
.filter((h) => h.startsWith(`${ANTHROPIC_PROVIDER_NAME}/`))
|
||||
.sort();
|
||||
|
||||
if (claudeModels.length > 0) {
|
||||
setAvailableModels(claudeModels);
|
||||
setFlowState("select_model");
|
||||
return; // Don't complete yet, wait for model selection
|
||||
}
|
||||
} catch {
|
||||
// If fetching models fails, just complete without selection
|
||||
}
|
||||
}
|
||||
|
||||
setFlowState("success");
|
||||
onComplete(
|
||||
true,
|
||||
`✓ Successfully connected to Claude via OAuth!\n\n` +
|
||||
`Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` +
|
||||
`Your OAuth tokens are stored securely in ~/.letta/settings.json\n` +
|
||||
`Use /model to switch to a Claude model.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error));
|
||||
setFlowState("error");
|
||||
}
|
||||
};
|
||||
|
||||
if (flowState === "initializing" || flowState === "checking_eligibility") {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color={colors.status.processing}>
|
||||
{flowState === "checking_eligibility"
|
||||
? "Checking account eligibility..."
|
||||
: "Starting Claude OAuth flow..."}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (flowState === "error") {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="red">✗ OAuth Error: {errorMessage}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press any key to close</Text>
|
||||
</Box>
|
||||
<WaitForKeyThenClose
|
||||
onClose={() => {
|
||||
settingsManager.clearOAuthState();
|
||||
onComplete(false, `✗ Failed to connect: ${errorMessage}`);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Model selection UI
|
||||
if (flowState === "select_model") {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.approval.header} bold>
|
||||
[Claude OAuth]
|
||||
</Text>
|
||||
<Text color="green"> Connected!</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text>Select a model to switch to:</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{availableModels.map((model, index) => {
|
||||
const displayName = model.replace(
|
||||
`${ANTHROPIC_PROVIDER_NAME}/`,
|
||||
"",
|
||||
);
|
||||
const isSelected = index === selectedModelIndex;
|
||||
return (
|
||||
<Box key={model}>
|
||||
<Text color={isSelected ? colors.approval.header : undefined}>
|
||||
{isSelected ? "› " : " "}
|
||||
{displayName}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑↓ to select, Enter to confirm, Esc to skip</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (flowState !== "waiting_for_code") {
|
||||
const statusMessages: Record<string, string> = {
|
||||
exchanging: "Exchanging authorization code for tokens...",
|
||||
validating: "Validating credentials...",
|
||||
creating_provider: "Creating Claude provider...",
|
||||
fetching_models: "Fetching available models...",
|
||||
switching_model: "Switching model...",
|
||||
success: "Success!",
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color={colors.status.processing}>
|
||||
{statusMessages[flowState]}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.approval.header} bold>
|
||||
[Claude OAuth]
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text>Opening browser for authorization...</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text dimColor>If browser doesn't open, copy this URL:</Text>
|
||||
<Text color={colors.link.url}>{authUrl}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text>
|
||||
After authorizing, copy the <Text bold>code</Text> value from the
|
||||
page and paste it below:
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text color={colors.approval.header}>> </Text>
|
||||
<PasteAwareTextInput
|
||||
value={codeInput}
|
||||
onChange={setCodeInput}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder="Paste code here..."
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Enter to submit, Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
OAuthCodeDialog.displayName = "OAuthCodeDialog";
|
||||
|
||||
// Helper component to wait for any key press then close
|
||||
const WaitForKeyThenClose = memo(({ onClose }: { onClose: () => void }) => {
|
||||
useInput(() => {
|
||||
onClose();
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
WaitForKeyThenClose.displayName = "WaitForKeyThenClose";
|
||||
119
src/models.json
119
src/models.json
@@ -47,46 +47,111 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sonnet-4.5-pro-max",
|
||||
"handle": "claude-pro-max/claude-sonnet-4-5-20250929",
|
||||
"label": "Sonnet 4.5",
|
||||
"description": "Sonnet 4.5 via Claude Pro/Max Plan",
|
||||
"id": "gpt-5.2-codex-plus-pro-medium",
|
||||
"handle": "chatgpt-plus-pro/gpt-5.2-codex",
|
||||
"label": "GPT-5.2 Codex",
|
||||
"description": "GPT-5.2 Codex (med reasoning) via ChatGPT Plus/Pro",
|
||||
"updateArgs": {
|
||||
"context_window": 180000,
|
||||
"max_output_tokens": 64000,
|
||||
"max_reasoning_tokens": 31999
|
||||
"reasoning_effort": "medium",
|
||||
"verbosity": "medium",
|
||||
"context_window": 272000,
|
||||
"max_output_tokens": 128000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sonnet-4.5-no-reasoning-pro-max",
|
||||
"handle": "claude-pro-max/claude-sonnet-4-5-20250929",
|
||||
"label": "Sonnet 4.5",
|
||||
"description": "Sonnet 4.5 (no reasoning) via Claude Pro/Max Plan",
|
||||
"id": "gpt-5.2-codex-plus-pro-high",
|
||||
"handle": "chatgpt-plus-pro/gpt-5.2-codex",
|
||||
"label": "GPT-5.2 Codex",
|
||||
"description": "GPT-5.2 Codex (high reasoning) via ChatGPT Plus/Pro",
|
||||
"updateArgs": {
|
||||
"enable_reasoner": false,
|
||||
"context_window": 180000,
|
||||
"max_output_tokens": 64000
|
||||
"reasoning_effort": "high",
|
||||
"verbosity": "medium",
|
||||
"context_window": 272000,
|
||||
"max_output_tokens": 128000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "opus-pro-max",
|
||||
"handle": "claude-pro-max/claude-opus-4-5-20251101",
|
||||
"label": "Opus 4.5",
|
||||
"description": "Opus 4.5 via Claude Pro/Max Plan",
|
||||
"id": "gpt-5.2-plus-pro-medium",
|
||||
"handle": "chatgpt-plus-pro/gpt-5.2",
|
||||
"label": "GPT-5.2",
|
||||
"description": "GPT-5.2 (med reasoning) via ChatGPT Plus/Pro",
|
||||
"updateArgs": {
|
||||
"context_window": 180000,
|
||||
"max_output_tokens": 64000,
|
||||
"max_reasoning_tokens": 31999
|
||||
"reasoning_effort": "medium",
|
||||
"verbosity": "medium",
|
||||
"context_window": 272000,
|
||||
"max_output_tokens": 128000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "haiku-pro-max",
|
||||
"handle": "claude-pro-max/claude-haiku-4-5-20251001",
|
||||
"label": "Haiku 4.5",
|
||||
"description": "Haiku 4.5 via Claude Pro/Max Plan",
|
||||
"id": "gpt-5.2-plus-pro-high",
|
||||
"handle": "chatgpt-plus-pro/gpt-5.2",
|
||||
"label": "GPT-5.2",
|
||||
"description": "GPT-5.2 (high reasoning) via ChatGPT Plus/Pro",
|
||||
"updateArgs": {
|
||||
"context_window": 180000,
|
||||
"max_output_tokens": 64000
|
||||
"reasoning_effort": "high",
|
||||
"verbosity": "medium",
|
||||
"context_window": 272000,
|
||||
"max_output_tokens": 128000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.1-codex-plus-pro-medium",
|
||||
"handle": "chatgpt-plus-pro/gpt-5.1-codex",
|
||||
"label": "GPT-5.1 Codex",
|
||||
"description": "GPT-5.1 Codex (med reasoning) via ChatGPT Plus/Pro",
|
||||
"updateArgs": {
|
||||
"reasoning_effort": "medium",
|
||||
"verbosity": "medium",
|
||||
"context_window": 272000,
|
||||
"max_output_tokens": 128000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.1-codex-plus-pro-high",
|
||||
"handle": "chatgpt-plus-pro/gpt-5.1-codex",
|
||||
"label": "GPT-5.1 Codex",
|
||||
"description": "GPT-5.1 Codex (high reasoning) via ChatGPT Plus/Pro",
|
||||
"updateArgs": {
|
||||
"reasoning_effort": "high",
|
||||
"verbosity": "medium",
|
||||
"context_window": 272000,
|
||||
"max_output_tokens": 128000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.1-codex-max-plus-pro-medium",
|
||||
"handle": "chatgpt-plus-pro/gpt-5.1-codex-max",
|
||||
"label": "GPT-5.1 Codex Max",
|
||||
"description": "GPT-5.1 Codex Max (med reasoning) via ChatGPT Plus/Pro",
|
||||
"updateArgs": {
|
||||
"reasoning_effort": "medium",
|
||||
"verbosity": "medium",
|
||||
"context_window": 272000,
|
||||
"max_output_tokens": 128000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.1-codex-max-plus-pro-high",
|
||||
"handle": "chatgpt-plus-pro/gpt-5.1-codex-max",
|
||||
"label": "GPT-5.1 Codex Max",
|
||||
"description": "GPT-5.1 Codex Max (high reasoning) via ChatGPT Plus/Pro",
|
||||
"updateArgs": {
|
||||
"reasoning_effort": "high",
|
||||
"verbosity": "medium",
|
||||
"context_window": 272000,
|
||||
"max_output_tokens": 128000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.1-codex-max-plus-pro-xhigh",
|
||||
"handle": "chatgpt-plus-pro/gpt-5.1-codex-max",
|
||||
"label": "GPT-5.1 Codex Max",
|
||||
"description": "GPT-5.1 Codex Max (extra-high reasoning) via ChatGPT Plus/Pro",
|
||||
"updateArgs": {
|
||||
"reasoning_effort": "xhigh",
|
||||
"verbosity": "medium",
|
||||
"context_window": 272000,
|
||||
"max_output_tokens": 128000
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
/**
|
||||
* Direct API calls to Letta for managing Anthropic provider
|
||||
* Bypasses SDK since it doesn't expose providers API
|
||||
* Direct API calls to Letta for managing OpenAI Codex provider
|
||||
* Uses the chatgpt_oauth provider type - backend handles request transformation
|
||||
* (transforms OpenAI API format → ChatGPT backend API format)
|
||||
*/
|
||||
|
||||
import { LETTA_CLOUD_API_URL } from "../auth/oauth";
|
||||
import { settingsManager } from "../settings-manager";
|
||||
|
||||
// Provider name constant for letta-code's Anthropic OAuth provider
|
||||
export const ANTHROPIC_PROVIDER_NAME = "claude-pro-max";
|
||||
// Provider name constant for letta-code's OpenAI Codex OAuth provider
|
||||
export const OPENAI_CODEX_PROVIDER_NAME = "chatgpt-plus-pro";
|
||||
|
||||
// Provider type for ChatGPT OAuth (backend handles transformation)
|
||||
export const CHATGPT_OAUTH_PROVIDER_TYPE = "chatgpt_oauth";
|
||||
|
||||
/**
|
||||
* ChatGPT OAuth configuration sent to Letta backend
|
||||
* Backend uses this to authenticate with ChatGPT and transform requests
|
||||
*/
|
||||
export interface ChatGPTOAuthConfig {
|
||||
access_token: string;
|
||||
id_token: string;
|
||||
refresh_token?: string;
|
||||
account_id: string;
|
||||
expires_at: number; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
interface ProviderResponse {
|
||||
id: string;
|
||||
@@ -116,119 +132,112 @@ export async function listProviders(): Promise<ProviderResponse[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the letta-code-claude provider if it exists
|
||||
* Get the chatgpt-plus-pro provider if it exists
|
||||
*/
|
||||
export async function getAnthropicProvider(): Promise<ProviderResponse | null> {
|
||||
export async function getOpenAICodexProvider(): Promise<ProviderResponse | null> {
|
||||
const providers = await listProviders();
|
||||
return providers.find((p) => p.name === ANTHROPIC_PROVIDER_NAME) || null;
|
||||
return providers.find((p) => p.name === OPENAI_CODEX_PROVIDER_NAME) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Anthropic provider with OAuth access token
|
||||
* Create a new ChatGPT OAuth provider
|
||||
* OAuth config is JSON-encoded in api_key field to avoid backend schema changes
|
||||
* Backend parses api_key as JSON when provider_type is "chatgpt_oauth"
|
||||
*/
|
||||
export async function createAnthropicProvider(
|
||||
accessToken: string,
|
||||
export async function createOpenAICodexProvider(
|
||||
config: ChatGPTOAuthConfig,
|
||||
): Promise<ProviderResponse> {
|
||||
// Encode OAuth config as JSON in api_key field
|
||||
const apiKeyJson = JSON.stringify({
|
||||
access_token: config.access_token,
|
||||
id_token: config.id_token,
|
||||
refresh_token: config.refresh_token,
|
||||
account_id: config.account_id,
|
||||
expires_at: config.expires_at,
|
||||
});
|
||||
|
||||
return providersRequest<ProviderResponse>("POST", "/v1/providers", {
|
||||
name: ANTHROPIC_PROVIDER_NAME,
|
||||
provider_type: "anthropic",
|
||||
api_key: accessToken,
|
||||
name: OPENAI_CODEX_PROVIDER_NAME,
|
||||
provider_type: CHATGPT_OAUTH_PROVIDER_TYPE,
|
||||
api_key: apiKeyJson,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing Anthropic provider with new access token
|
||||
* Update an existing ChatGPT OAuth provider with new OAuth config
|
||||
* OAuth config is JSON-encoded in api_key field
|
||||
*/
|
||||
export async function updateAnthropicProvider(
|
||||
export async function updateOpenAICodexProvider(
|
||||
providerId: string,
|
||||
accessToken: string,
|
||||
config: ChatGPTOAuthConfig,
|
||||
): Promise<ProviderResponse> {
|
||||
// Encode OAuth config as JSON in api_key field
|
||||
const apiKeyJson = JSON.stringify({
|
||||
access_token: config.access_token,
|
||||
id_token: config.id_token,
|
||||
refresh_token: config.refresh_token,
|
||||
account_id: config.account_id,
|
||||
expires_at: config.expires_at,
|
||||
});
|
||||
|
||||
return providersRequest<ProviderResponse>(
|
||||
"PATCH",
|
||||
`/v1/providers/${providerId}`,
|
||||
{
|
||||
api_key: accessToken,
|
||||
api_key: apiKeyJson,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the Anthropic provider
|
||||
* Delete the OpenAI Codex provider
|
||||
*/
|
||||
export async function deleteAnthropicProvider(
|
||||
export async function deleteOpenAICodexProvider(
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
await providersRequest<void>("DELETE", `/v1/providers/${providerId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update the Anthropic provider with OAuth access token
|
||||
* This is the main function called after successful /connect
|
||||
* Create or update the ChatGPT OAuth provider
|
||||
* This is the main function called after successful /connect codex
|
||||
*
|
||||
* The Letta backend will:
|
||||
* 1. Store the OAuth tokens securely
|
||||
* 2. Handle token refresh when needed
|
||||
* 3. Transform requests from OpenAI format to ChatGPT backend format
|
||||
* 4. Add required headers (Authorization, ChatGPT-Account-Id, etc.)
|
||||
* 5. Forward to chatgpt.com/backend-api/codex
|
||||
*/
|
||||
export async function createOrUpdateAnthropicProvider(
|
||||
accessToken: string,
|
||||
export async function createOrUpdateOpenAICodexProvider(
|
||||
config: ChatGPTOAuthConfig,
|
||||
): Promise<ProviderResponse> {
|
||||
const existing = await getAnthropicProvider();
|
||||
const existing = await getOpenAICodexProvider();
|
||||
|
||||
if (existing) {
|
||||
// Update existing provider with new token
|
||||
return updateAnthropicProvider(existing.id, accessToken);
|
||||
// Update existing provider with new OAuth config
|
||||
return updateOpenAICodexProvider(existing.id, config);
|
||||
} else {
|
||||
// Create new provider
|
||||
return createAnthropicProvider(accessToken);
|
||||
return createOpenAICodexProvider(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Anthropic provider has a valid (non-expired) token
|
||||
* Call this before making requests that use the provider
|
||||
* Remove the OpenAI Codex provider (called on /disconnect)
|
||||
*/
|
||||
export async function ensureAnthropicProviderToken(): Promise<void> {
|
||||
const settings = settingsManager.getSettings();
|
||||
const tokens = settings.anthropicOAuth;
|
||||
|
||||
if (!tokens) {
|
||||
// No Anthropic OAuth configured, nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is expired or about to expire (within 5 minutes)
|
||||
const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000;
|
||||
if (tokens.expires_at < fiveMinutesFromNow && tokens.refresh_token) {
|
||||
// Token is expired or about to expire, refresh it
|
||||
const { refreshAnthropicToken } = await import("../auth/anthropic-oauth");
|
||||
|
||||
try {
|
||||
const newTokens = await refreshAnthropicToken(tokens.refresh_token);
|
||||
settingsManager.storeAnthropicTokens(newTokens);
|
||||
|
||||
// Update the provider with the new access token
|
||||
const existing = await getAnthropicProvider();
|
||||
if (existing) {
|
||||
await updateAnthropicProvider(existing.id, newTokens.access_token);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh Anthropic access token:", error);
|
||||
// Continue with existing token, it might still work
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the Anthropic provider (called on /disconnect)
|
||||
*/
|
||||
export async function removeAnthropicProvider(): Promise<void> {
|
||||
const existing = await getAnthropicProvider();
|
||||
export async function removeOpenAICodexProvider(): Promise<void> {
|
||||
const existing = await getOpenAICodexProvider();
|
||||
if (existing) {
|
||||
await deleteAnthropicProvider(existing.id);
|
||||
await deleteOpenAICodexProvider(existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is eligible for Anthropic OAuth
|
||||
* Check if user is eligible for OpenAI Codex OAuth
|
||||
* Requires Pro or Enterprise billing tier
|
||||
*/
|
||||
export async function checkAnthropicOAuthEligibility(): Promise<EligibilityCheckResult> {
|
||||
export async function checkOpenAICodexEligibility(): Promise<EligibilityCheckResult> {
|
||||
try {
|
||||
const balance = await providersRequest<BalanceResponse>(
|
||||
"GET",
|
||||
@@ -248,12 +257,12 @@ export async function checkAnthropicOAuthEligibility(): Promise<EligibilityCheck
|
||||
return {
|
||||
eligible: false,
|
||||
billing_tier: balance.billing_tier,
|
||||
reason: `Claude OAuth requires a Pro or Enterprise plan. Current plan: ${balance.billing_tier}`,
|
||||
reason: `OpenAI Codex OAuth requires a Pro or Enterprise plan. Current plan: ${balance.billing_tier}`,
|
||||
};
|
||||
} catch (error) {
|
||||
// If we can't check eligibility, allow the flow to continue
|
||||
// The provider creation will handle the error appropriately
|
||||
console.warn("Failed to check Anthropic OAuth eligibility:", error);
|
||||
console.warn("Failed to check OpenAI Codex OAuth eligibility:", error);
|
||||
return {
|
||||
eligible: true,
|
||||
billing_tier: "unknown",
|
||||
@@ -40,18 +40,12 @@ export interface Settings {
|
||||
refreshToken?: string; // DEPRECATED: kept for migration, now stored in secrets
|
||||
tokenExpiresAt?: number; // Unix timestamp in milliseconds
|
||||
deviceId?: string;
|
||||
// Anthropic OAuth
|
||||
anthropicOAuth?: {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_at: number; // Unix timestamp in milliseconds
|
||||
scope?: string;
|
||||
};
|
||||
// Pending OAuth state (for PKCE flow)
|
||||
oauthState?: {
|
||||
state: string;
|
||||
codeVerifier: string;
|
||||
provider: "anthropic";
|
||||
redirectUri: string;
|
||||
provider: "openai";
|
||||
timestamp: number;
|
||||
};
|
||||
}
|
||||
@@ -971,84 +965,20 @@ class SettingsManager {
|
||||
return exists(dirPath);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Anthropic OAuth Management
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Store Anthropic OAuth tokens
|
||||
*/
|
||||
storeAnthropicTokens(tokens: {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
}): void {
|
||||
this.updateSettings({
|
||||
anthropicOAuth: {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_at: Date.now() + tokens.expires_in * 1000,
|
||||
scope: tokens.scope,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Anthropic OAuth tokens (returns null if not set or expired)
|
||||
*/
|
||||
getAnthropicTokens(): Settings["anthropicOAuth"] | null {
|
||||
const settings = this.getSettings();
|
||||
if (!settings.anthropicOAuth) return null;
|
||||
return settings.anthropicOAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Anthropic OAuth tokens are expired or about to expire
|
||||
* Returns true if token expires within the next 5 minutes
|
||||
*/
|
||||
isAnthropicTokenExpired(): boolean {
|
||||
const tokens = this.getAnthropicTokens();
|
||||
if (!tokens) return true;
|
||||
|
||||
const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000;
|
||||
return tokens.expires_at < fiveMinutesFromNow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Anthropic OAuth is configured
|
||||
*/
|
||||
hasAnthropicOAuth(): boolean {
|
||||
return !!this.getAnthropicTokens();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear Anthropic OAuth tokens and state
|
||||
*/
|
||||
clearAnthropicOAuth(): void {
|
||||
const settings = this.getSettings();
|
||||
const { anthropicOAuth: _, oauthState: __, ...rest } = settings;
|
||||
this.settings = { ...DEFAULT_SETTINGS, ...rest };
|
||||
this.persistSettings().catch((error) => {
|
||||
console.error(
|
||||
"Failed to persist settings after clearing Anthropic OAuth:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Store OAuth state for pending authorization
|
||||
*/
|
||||
storeOAuthState(
|
||||
state: string,
|
||||
codeVerifier: string,
|
||||
provider: "anthropic",
|
||||
redirectUri: string,
|
||||
provider: "openai",
|
||||
): void {
|
||||
this.updateSettings({
|
||||
oauthState: {
|
||||
state,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
provider,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user