-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat: add OAuth authentication support for ChatGPT Plus/Pro accounts #6995
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { z } from "zod" | ||
|
|
||
| /** | ||
| * OAuth configuration for ChatGPT authentication | ||
| */ | ||
| export const CHATGPT_OAUTH_CONFIG = { | ||
| clientId: "app_EMoamEEZ73f0CkXaXp7hrann", // Codex CLI client ID for compatibility | ||
| authorizationUrl: "https://auth.openai.com/oauth/authorize", | ||
| tokenUrl: "https://auth.openai.com/oauth/token", | ||
| redirectUri: "http://localhost:1455/auth/callback", | ||
| defaultPort: 1455, | ||
| scopes: ["openid", "profile", "email", "offline_access"], | ||
| } as const | ||
|
|
||
| /** | ||
| * OAuth tokens structure | ||
| */ | ||
| export const oauthTokensSchema = z.object({ | ||
| accessToken: z.string(), | ||
| idToken: z.string(), | ||
| refreshToken: z.string(), | ||
| expiresIn: z.number().optional(), | ||
| tokenType: z.string().optional(), | ||
| }) | ||
|
|
||
| export type OAuthTokens = z.infer<typeof oauthTokensSchema> | ||
|
|
||
| /** | ||
| * ChatGPT credentials stored in SecretStorage | ||
| */ | ||
| export const chatGptCredentialsSchema = z.object({ | ||
| apiKey: z.string().optional(), // Exchanged API key | ||
| idToken: z.string(), | ||
| refreshToken: z.string(), | ||
| lastRefreshIso: z.string().optional(), | ||
| responseId: z.string().optional(), // For conversation continuity | ||
| }) | ||
|
|
||
| export type ChatGptCredentials = z.infer<typeof chatGptCredentialsSchema> | ||
|
|
||
| /** | ||
| * Codex CLI auth.json structure for import | ||
| */ | ||
| export const codexAuthJsonSchema = z.object({ | ||
| OPENAI_API_KEY: z.string().optional(), | ||
| tokens: z | ||
| .object({ | ||
| id_token: z.string(), | ||
| access_token: z.string().optional(), | ||
| refresh_token: z.string().optional(), | ||
| }) | ||
| .optional(), | ||
| last_refresh: z.string().optional(), | ||
| }) | ||
|
|
||
| export type CodexAuthJson = z.infer<typeof codexAuthJsonSchema> | ||
|
|
||
| /** | ||
| * OAuth state for CSRF protection | ||
| */ | ||
| export interface OAuthState { | ||
| state: string | ||
| codeVerifier: string | ||
| timestamp: number | ||
| } | ||
|
|
||
| /** | ||
| * Token exchange request for getting API key from OAuth tokens | ||
| */ | ||
| export interface TokenExchangeRequest { | ||
| grant_type: "urn:ietf:params:oauth:grant-type:token-exchange" | ||
| requested_token_type: "openai-api-key" | ||
| subject_token: string // ID token | ||
| subject_token_type: "urn:ietf:params:oauth:token-type:id_token" | ||
| client_id: string | ||
| } | ||
|
|
||
| /** | ||
| * OAuth error response | ||
| */ | ||
| export const oauthErrorSchema = z.object({ | ||
| error: z.string(), | ||
| error_description: z.string().optional(), | ||
| }) | ||
|
|
||
| export type OAuthError = z.infer<typeof oauthErrorSchema> | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -29,17 +29,47 @@ import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from ". | |||||||||||||||||||||||||||||||||
| // compatible with the OpenAI API. We can also rename it to `OpenAIHandler`. | ||||||||||||||||||||||||||||||||||
| export class OpenAiHandler extends BaseProvider implements SingleCompletionHandler { | ||||||||||||||||||||||||||||||||||
| protected options: ApiHandlerOptions | ||||||||||||||||||||||||||||||||||
| private client: OpenAI | ||||||||||||||||||||||||||||||||||
| private client!: OpenAI // Using definite assignment assertion since we initialize it | ||||||||||||||||||||||||||||||||||
| private apiKeyPromise?: Promise<string> | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| constructor(options: ApiHandlerOptions) { | ||||||||||||||||||||||||||||||||||
| super() | ||||||||||||||||||||||||||||||||||
| this.options = options | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Initialize the client asynchronously if using ChatGPT auth | ||||||||||||||||||||||||||||||||||
| if (this.options.openAiAuthMode === "chatgpt") { | ||||||||||||||||||||||||||||||||||
| this.apiKeyPromise = this.getApiKeyFromChatGpt() | ||||||||||||||||||||||||||||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this race condition intentional? The async initialization could cause issues if multiple requests come in before the API key is fetched. Consider using a more robust initialization pattern:
Suggested change
Then use |
||||||||||||||||||||||||||||||||||
| this.apiKeyPromise | ||||||||||||||||||||||||||||||||||
| .then((apiKey) => { | ||||||||||||||||||||||||||||||||||
| this.initializeClient(apiKey) | ||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||
| .catch((error) => { | ||||||||||||||||||||||||||||||||||
| console.error("Failed to get API key from ChatGPT:", error) | ||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| // Initialize immediately for regular API key mode | ||||||||||||||||||||||||||||||||||
| this.initializeClient(this.options.openAiApiKey ?? "not-provided") | ||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When not using ChatGPT auth mode, the API key defaults to 'not-provided'. Consider explicit error handling instead of using a fallback string. |
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| private async getApiKeyFromChatGpt(): Promise<string> { | ||||||||||||||||||||||||||||||||||
| // Lazy import to avoid circular dependencies | ||||||||||||||||||||||||||||||||||
| const { getCredentialsManager } = await import("../../core/auth/chatgpt-credentials-manager") | ||||||||||||||||||||||||||||||||||
| const credentialsManager = getCredentialsManager() | ||||||||||||||||||||||||||||||||||
| const apiKey = await credentialsManager.getApiKey() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (!apiKey) { | ||||||||||||||||||||||||||||||||||
| throw new Error("No API key found for ChatGPT authentication. Please sign in with your ChatGPT account.") | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| return apiKey | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| private initializeClient(apiKey: string): void { | ||||||||||||||||||||||||||||||||||
| const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1" | ||||||||||||||||||||||||||||||||||
| const apiKey = this.options.openAiApiKey ?? "not-provided" | ||||||||||||||||||||||||||||||||||
| const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) | ||||||||||||||||||||||||||||||||||
| const urlHost = this._getUrlHost(this.options.openAiBaseUrl) | ||||||||||||||||||||||||||||||||||
| const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure | ||||||||||||||||||||||||||||||||||
| const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || this.options.openAiUseAzure | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const headers = { | ||||||||||||||||||||||||||||||||||
| ...DEFAULT_HEADERS, | ||||||||||||||||||||||||||||||||||
|
|
@@ -77,6 +107,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl | |||||||||||||||||||||||||||||||||
| messages: Anthropic.Messages.MessageParam[], | ||||||||||||||||||||||||||||||||||
| metadata?: ApiHandlerCreateMessageMetadata, | ||||||||||||||||||||||||||||||||||
| ): ApiStream { | ||||||||||||||||||||||||||||||||||
| // Ensure client is initialized for ChatGPT auth mode | ||||||||||||||||||||||||||||||||||
| if (this.apiKeyPromise) { | ||||||||||||||||||||||||||||||||||
| await this.apiKeyPromise | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (!this.client) { | ||||||||||||||||||||||||||||||||||
| throw new Error("OpenAI client not initialized") | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| const { info: modelInfo, reasoning } = this.getModel() | ||||||||||||||||||||||||||||||||||
| const modelUrl = this.options.openAiBaseUrl ?? "" | ||||||||||||||||||||||||||||||||||
| const modelId = this.options.openAiModelId ?? "" | ||||||||||||||||||||||||||||||||||
|
|
@@ -256,6 +294,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl | |||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| async completePrompt(prompt: string): Promise<string> { | ||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||
| // Ensure client is initialized for ChatGPT auth mode | ||||||||||||||||||||||||||||||||||
| if (this.apiKeyPromise) { | ||||||||||||||||||||||||||||||||||
| await this.apiKeyPromise | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (!this.client) { | ||||||||||||||||||||||||||||||||||
| throw new Error("OpenAI client not initialized") | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) | ||||||||||||||||||||||||||||||||||
| const model = this.getModel() | ||||||||||||||||||||||||||||||||||
| const modelInfo = model.info | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using Codex CLI's client ID for compatibility is clever, but what's the fallback plan if OpenAI revokes this ID? Should we consider making this configurable or having a backup client ID?