commons-proxy 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,9 +7,9 @@
7
7
 
8
8
  <a href="https://buymeacoffee.com/badrinarayanans" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="50"></a>
9
9
 
10
- A **universal AI proxy server** exposing an **Anthropic-compatible API** backed by **multiple providers** (Google Cloud Code, Anthropic, OpenAI, GitHub Models, GitHub Copilot, OpenRouter), enabling you to use Claude, Gemini, GPT, and more with **Claude Code CLI**.
10
+ A **universal AI proxy server** exposing an **Anthropic-compatible API** backed by **multiple providers** (Google Cloud Code, Anthropic, OpenAI, GitHub Models, GitHub Copilot, ChatGPT Plus/Pro, OpenRouter), enabling you to use Claude, Gemini, GPT, and more with **Claude Code CLI**.
11
11
 
12
- > 🎉 **v2.0.0 Released**: Now supporting Anthropic, OpenAI, GitHub Models, GitHub Copilot, and OpenRouter in addition to Google Cloud Code!
12
+ > 🎉 **v2.1.0 Released**: Now supporting Anthropic, OpenAI, GitHub Models, GitHub Copilot, ChatGPT Plus/Pro (Codex), and OpenRouter in addition to Google Cloud Code!
13
13
 
14
14
  📚 **Quick Links**: [Installation](#installation) | [Provider Setup](docs/PROVIDERS.md) | [Docker](#option-3-docker-recommended-for-production) | [Contributing](CONTRIBUTING.md)
15
15
 
@@ -23,7 +23,8 @@ A **universal AI proxy server** exposing an **Anthropic-compatible API** backed
23
23
  └──────────────────┘ └─────────────────────┘ │ • OpenAI API │
24
24
  │ • GitHub Models │
25
25
  │ • GitHub Copilot │
26
- │ • OpenRouter
26
+ │ • ChatGPT Plus/Pro
27
+ │ • OpenRouter │
27
28
  └─────────────────────────┘
28
29
  ```
29
30
 
@@ -36,7 +37,7 @@ A **universal AI proxy server** exposing an **Anthropic-compatible API** backed
36
37
 
37
38
  **Key Features**:
38
39
  - 🔄 **Multi-Provider Support**: Use Google, Anthropic, OpenAI, GitHub Models, GitHub Copilot, and OpenRouter accounts
39
- - 🔐 **Flexible Authentication**: OAuth 2.0 (Google), Device Auth (Copilot), or API Keys (others)
40
+ - 🔐 **Flexible Authentication**: OAuth 2.0 (Google), Device Auth (Copilot, Codex), or API Keys (others)
40
41
  - ⚖️ **Intelligent Load Balancing**: Hybrid/Sticky/Round-Robin strategies
41
42
  - 📊 **Real-time Quota Tracking**: Dashboard shows usage across all providers
42
43
  - 💾 **Prompt Caching**: Maintains cache continuity with sticky account selection
@@ -50,7 +51,8 @@ A **universal AI proxy server** exposing an **Anthropic-compatible API** backed
50
51
  | **Anthropic** | API Key | Claude 3.5 Sonnet/Opus/Haiku | ⚠️ Manual (console) | ✅ Supported |
51
52
  | **OpenAI** | API Key | GPT-4 Turbo, GPT-4, GPT-3.5 Turbo | ⚠️ Manual (console) | ✅ Supported |
52
53
  | **GitHub Models** | Personal Access Token | GitHub Marketplace models | ⚠️ GitHub API limits | ✅ Supported |
53
- | **GitHub Copilot** | Device Authorization | GPT-4o, GPT-4, Claude 3.5 Sonnet, o1 | ⚠️ Copilot limits | ✅ Supported |
54
+ | **GitHub Copilot** | Device Authorization | GPT-4o, Claude Sonnet 4, o1, o3-mini | ⚠️ Copilot limits | ✅ Supported |
55
+ | **ChatGPT Plus/Pro** | OAuth (Browser/Device) | GPT-5 Codex, GPT-5.1 Codex | ⚠️ Subscription limits | ✅ New |
54
56
  | **OpenRouter** | API Key | 100+ models (Claude, GPT, Gemini, Llama, etc.) | ✅ Credit-based | ✅ Supported |
55
57
 
56
58
  **Quota Tracking Legend**:
@@ -739,10 +741,9 @@ See [CLAUDE.md](./CLAUDE.md) for detailed architecture documentation, including:
739
741
 
740
742
  ## Credits
741
743
 
742
- This project is based on insights and code from:
743
-
744
- - [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) - CommonsProxy OAuth plugin for OpenCode
745
- - [claude-code-proxy](https://github.com/1rgs/claude-code-proxy) - Anthropic API proxy using LiteLLM
744
+ - **[opencode](https://github.com/nichochar/opencode)** Authentication flows for GitHub Copilot (device auth) and ChatGPT Plus/Pro (Codex OAuth) are inspired by opencode's plugin architecture. Copilot client ID, header handling, and Codex PKCE/device auth flows adapted from opencode's `copilot.ts` and `codex.ts` plugins.
745
+ - **[opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth)** — CommonsProxy OAuth plugin for OpenCode
746
+ - **[claude-code-proxy](https://github.com/1rgs/claude-code-proxy)** Anthropic API proxy using LiteLLM
746
747
 
747
748
  ---
748
749
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "commons-proxy",
3
- "version": "2.0.0",
4
- "description": "Universal AI proxy server with multi-provider support (Google Cloud Code, Anthropic, OpenAI, GitHub Models, GitHub Copilot, OpenRouter) - Anthropic-compatible API for Claude Code CLI",
3
+ "version": "2.1.0",
4
+ "description": "Universal AI proxy server with multi-provider support (Google Cloud Code, Anthropic, OpenAI, GitHub Models, GitHub Copilot, ChatGPT Plus/Pro, OpenRouter) - Anthropic-compatible API for Claude Code CLI",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
7
7
  "bin": {
@@ -53,6 +53,8 @@
53
53
  "llm-proxy",
54
54
  "llm",
55
55
  "copilot",
56
+ "codex",
57
+ "chatgpt",
56
58
  "proxy",
57
59
  "vertex-ai",
58
60
  "gemini",
@@ -21,6 +21,7 @@ import { dirname } from 'path';
21
21
  import { spawn } from 'child_process';
22
22
  import net from 'net';
23
23
  import { ACCOUNT_CONFIG_PATH, DEFAULT_PORT, MAX_ACCOUNTS } from '../constants.js';
24
+ import { COPILOT_CONFIG } from '../providers/copilot.js';
24
25
  import {
25
26
  getAuthorizationUrl,
26
27
  startCallbackServer,
@@ -511,7 +512,7 @@ async function addCopilotAccount(rl) {
511
512
  'User-Agent': 'commons-proxy/2.0.0'
512
513
  },
513
514
  body: JSON.stringify({
514
- client_id: 'Iv1.b507a08c87ecfe98',
515
+ client_id: COPILOT_CONFIG.clientId,
515
516
  device_code: deviceData.device_code,
516
517
  grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
517
518
  })
package/src/constants.js CHANGED
@@ -328,6 +328,15 @@ export const PROVIDER_CONFIG = {
328
328
  color: '#6d28d9', // Purple
329
329
  icon: 'openrouter',
330
330
  requiresProjectId: false
331
+ },
332
+ codex: {
333
+ id: 'codex',
334
+ name: 'ChatGPT Plus/Pro (Codex)',
335
+ authType: 'device-auth', // OpenAI Device Authorization Flow
336
+ apiEndpoint: 'https://chatgpt.com/backend-api/codex/responses',
337
+ color: '#10b981', // Green (OpenAI family)
338
+ icon: 'openai',
339
+ requiresProjectId: false
331
340
  }
332
341
  };
333
342
 
@@ -338,7 +347,8 @@ export const PROVIDER_NAMES = {
338
347
  openai: 'OpenAI',
339
348
  github: 'GitHub Models',
340
349
  copilot: 'GitHub Copilot',
341
- openrouter: 'OpenRouter'
350
+ openrouter: 'OpenRouter',
351
+ codex: 'ChatGPT Plus/Pro (Codex)'
342
352
  };
343
353
 
344
354
  // Provider colors for UI visualization
@@ -348,7 +358,8 @@ export const PROVIDER_COLORS = {
348
358
  openai: '#10b981',
349
359
  github: '#6366f1',
350
360
  copilot: '#f97316',
351
- openrouter: '#6d28d9'
361
+ openrouter: '#6d28d9',
362
+ codex: '#10b981'
352
363
  };
353
364
 
354
365
  export default {
@@ -0,0 +1,440 @@
1
+ /**
2
+ * OpenAI Codex OAuth Provider
3
+ *
4
+ * Enables ChatGPT Plus/Pro users to authenticate via OAuth (browser or device flow).
5
+ * Inspired by opencode's codex.ts plugin implementation.
6
+ *
7
+ * Supports two auth methods:
8
+ * 1. Browser OAuth (PKCE flow with local callback server)
9
+ * 2. Headless device auth (for SSH/remote environments)
10
+ *
11
+ * Credits: Authentication flow based on opencode (https://github.com/nichochar/opencode)
12
+ */
13
+
14
+ import crypto from 'crypto';
15
+ import http from 'http';
16
+ import { logger } from '../utils/logger.js';
17
+
18
+ // OpenAI Codex OAuth configuration (from opencode's codex.ts)
19
+ const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
20
+ const CODEX_ISSUER = 'https://auth.openai.com';
21
+ const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses';
22
+ const CODEX_OAUTH_PORT = 1455;
23
+ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000;
24
+
25
+ /**
26
+ * Generate PKCE code verifier and challenge
27
+ */
28
+ function generatePKCE() {
29
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
30
+ const bytes = crypto.randomBytes(43);
31
+ const verifier = Array.from(bytes).map(b => chars[b % chars.length]).join('');
32
+
33
+ const challenge = crypto
34
+ .createHash('sha256')
35
+ .update(verifier)
36
+ .digest('base64url');
37
+
38
+ return { verifier, challenge };
39
+ }
40
+
41
+ /**
42
+ * Generate random state parameter
43
+ */
44
+ function generateState() {
45
+ return crypto.randomBytes(32).toString('base64url');
46
+ }
47
+
48
+ /**
49
+ * Parse JWT claims from an ID token or access token
50
+ * @param {string} token - JWT token
51
+ * @returns {Object|undefined} Parsed claims
52
+ */
53
+ export function parseJwtClaims(token) {
54
+ const parts = token.split('.');
55
+ if (parts.length !== 3) return undefined;
56
+ try {
57
+ return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Extract ChatGPT account ID from JWT claims
65
+ * @param {Object} claims - JWT claims
66
+ * @returns {string|undefined} Account ID
67
+ */
68
+ export function extractAccountIdFromClaims(claims) {
69
+ return (
70
+ claims.chatgpt_account_id ||
71
+ claims['https://api.openai.com/auth']?.chatgpt_account_id ||
72
+ claims.organizations?.[0]?.id
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Extract account ID from token response
78
+ * @param {Object} tokens - Token response with id_token and access_token
79
+ * @returns {string|undefined} Account ID
80
+ */
81
+ export function extractAccountId(tokens) {
82
+ if (tokens.id_token) {
83
+ const claims = parseJwtClaims(tokens.id_token);
84
+ const accountId = claims && extractAccountIdFromClaims(claims);
85
+ if (accountId) return accountId;
86
+ }
87
+ if (tokens.access_token) {
88
+ const claims = parseJwtClaims(tokens.access_token);
89
+ return claims ? extractAccountIdFromClaims(claims) : undefined;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /**
95
+ * Build the OAuth authorization URL
96
+ * @param {string} redirectUri - Redirect URI for callback
97
+ * @param {Object} pkce - PKCE codes { verifier, challenge }
98
+ * @param {string} state - State parameter
99
+ * @returns {string} Authorization URL
100
+ */
101
+ function buildAuthorizeUrl(redirectUri, pkce, state) {
102
+ const params = new URLSearchParams({
103
+ response_type: 'code',
104
+ client_id: CODEX_CLIENT_ID,
105
+ redirect_uri: redirectUri,
106
+ scope: 'openid profile email offline_access',
107
+ code_challenge: pkce.challenge,
108
+ code_challenge_method: 'S256',
109
+ id_token_add_organizations: 'true',
110
+ codex_cli_simplified_flow: 'true',
111
+ state,
112
+ originator: 'commons-proxy'
113
+ });
114
+ return `${CODEX_ISSUER}/oauth/authorize?${params.toString()}`;
115
+ }
116
+
117
+ /**
118
+ * Exchange authorization code for tokens
119
+ * @param {string} code - Authorization code
120
+ * @param {string} redirectUri - Redirect URI used in authorization
121
+ * @param {Object} pkce - PKCE codes { verifier }
122
+ * @returns {Promise<Object>} Token response
123
+ */
124
+ async function exchangeCodeForTokens(code, redirectUri, pkce) {
125
+ const response = await fetch(`${CODEX_ISSUER}/oauth/token`, {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
128
+ body: new URLSearchParams({
129
+ grant_type: 'authorization_code',
130
+ code,
131
+ redirect_uri: redirectUri,
132
+ client_id: CODEX_CLIENT_ID,
133
+ code_verifier: pkce.verifier
134
+ }).toString()
135
+ });
136
+
137
+ if (!response.ok) {
138
+ const text = await response.text();
139
+ throw new Error(`Token exchange failed: ${response.status} ${text}`);
140
+ }
141
+
142
+ return response.json();
143
+ }
144
+
145
+ /**
146
+ * Refresh an access token using a refresh token
147
+ * @param {string} refreshToken - OAuth refresh token
148
+ * @returns {Promise<Object>} Token response
149
+ */
150
+ export async function refreshCodexAccessToken(refreshToken) {
151
+ const response = await fetch(`${CODEX_ISSUER}/oauth/token`, {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
154
+ body: new URLSearchParams({
155
+ grant_type: 'refresh_token',
156
+ refresh_token: refreshToken,
157
+ client_id: CODEX_CLIENT_ID
158
+ }).toString()
159
+ });
160
+
161
+ if (!response.ok) {
162
+ const text = await response.text();
163
+ throw new Error(`Token refresh failed: ${response.status} ${text}`);
164
+ }
165
+
166
+ return response.json();
167
+ }
168
+
169
+ // ============================================================================
170
+ // Browser OAuth Flow (PKCE with local callback server)
171
+ // ============================================================================
172
+
173
+ const HTML_SUCCESS = `<!doctype html>
174
+ <html>
175
+ <head><title>CommonsProxy - Authorization Successful</title>
176
+ <style>
177
+ body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #131010; color: #f1ecec; }
178
+ .container { text-align: center; padding: 2rem; }
179
+ h1 { color: #28a745; margin-bottom: 1rem; }
180
+ p { color: #b7b1b1; }
181
+ </style>
182
+ </head>
183
+ <body>
184
+ <div class="container">
185
+ <h1>Authorization Successful</h1>
186
+ <p>You can close this window and return to CommonsProxy.</p>
187
+ </div>
188
+ <script>setTimeout(() => window.close(), 2000)</script>
189
+ </body>
190
+ </html>`;
191
+
192
+ const HTML_ERROR = (error) => `<!doctype html>
193
+ <html>
194
+ <head><title>CommonsProxy - Authorization Failed</title>
195
+ <style>
196
+ body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #131010; color: #f1ecec; }
197
+ .container { text-align: center; padding: 2rem; }
198
+ h1 { color: #dc3545; margin-bottom: 1rem; }
199
+ p { color: #b7b1b1; }
200
+ .error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
201
+ </style>
202
+ </head>
203
+ <body>
204
+ <div class="container">
205
+ <h1>Authorization Failed</h1>
206
+ <p>An error occurred during authorization.</p>
207
+ <div class="error">${error}</div>
208
+ </div>
209
+ </body>
210
+ </html>`;
211
+
212
+ /**
213
+ * Start browser OAuth flow for OpenAI Codex
214
+ * Returns authorization URL and a promise that resolves with tokens
215
+ *
216
+ * @returns {Promise<{url: string, promise: Promise<Object>, abort: Function}>}
217
+ */
218
+ export async function startCodexBrowserAuth() {
219
+ const pkce = generatePKCE();
220
+ const state = generateState();
221
+ const port = CODEX_OAUTH_PORT;
222
+ const redirectUri = `http://localhost:${port}/auth/callback`;
223
+ const authUrl = buildAuthorizeUrl(redirectUri, pkce, state);
224
+
225
+ let server = null;
226
+ let pendingResolve = null;
227
+ let pendingReject = null;
228
+ let timeoutId = null;
229
+
230
+ const promise = new Promise((resolve, reject) => {
231
+ pendingResolve = resolve;
232
+ pendingReject = reject;
233
+
234
+ server = http.createServer(async (req, res) => {
235
+ const url = new URL(req.url, `http://localhost:${port}`);
236
+
237
+ if (url.pathname === '/auth/callback') {
238
+ const code = url.searchParams.get('code');
239
+ const returnedState = url.searchParams.get('state');
240
+ const error = url.searchParams.get('error');
241
+ const errorDescription = url.searchParams.get('error_description');
242
+
243
+ if (error) {
244
+ const errorMsg = errorDescription || error;
245
+ res.writeHead(200, { 'Content-Type': 'text/html' });
246
+ res.end(HTML_ERROR(errorMsg));
247
+ reject(new Error(errorMsg));
248
+ cleanup();
249
+ return;
250
+ }
251
+
252
+ if (!code) {
253
+ res.writeHead(400, { 'Content-Type': 'text/html' });
254
+ res.end(HTML_ERROR('Missing authorization code'));
255
+ reject(new Error('Missing authorization code'));
256
+ cleanup();
257
+ return;
258
+ }
259
+
260
+ if (returnedState !== state) {
261
+ res.writeHead(400, { 'Content-Type': 'text/html' });
262
+ res.end(HTML_ERROR('Invalid state - potential CSRF attack'));
263
+ reject(new Error('State mismatch'));
264
+ cleanup();
265
+ return;
266
+ }
267
+
268
+ res.writeHead(200, { 'Content-Type': 'text/html' });
269
+ res.end(HTML_SUCCESS);
270
+
271
+ try {
272
+ const tokens = await exchangeCodeForTokens(code, redirectUri, pkce);
273
+ const accountId = extractAccountId(tokens);
274
+ resolve({
275
+ accessToken: tokens.access_token,
276
+ refreshToken: tokens.refresh_token,
277
+ idToken: tokens.id_token,
278
+ expiresIn: tokens.expires_in,
279
+ accountId
280
+ });
281
+ } catch (err) {
282
+ reject(err);
283
+ }
284
+ cleanup();
285
+ return;
286
+ }
287
+
288
+ res.writeHead(404);
289
+ res.end('Not found');
290
+ });
291
+
292
+ server.listen(port, () => {
293
+ logger.info(`[CodexAuth] OAuth callback server listening on port ${port}`);
294
+ });
295
+
296
+ server.on('error', (err) => {
297
+ reject(new Error(`Failed to start OAuth server on port ${port}: ${err.message}`));
298
+ });
299
+
300
+ // 5 minute timeout
301
+ timeoutId = setTimeout(() => {
302
+ reject(new Error('OAuth callback timeout'));
303
+ cleanup();
304
+ }, 5 * 60 * 1000);
305
+ });
306
+
307
+ const cleanup = () => {
308
+ if (timeoutId) clearTimeout(timeoutId);
309
+ if (server) {
310
+ server.close();
311
+ server = null;
312
+ }
313
+ };
314
+
315
+ const abort = () => {
316
+ if (pendingReject) {
317
+ pendingReject(new Error('OAuth flow aborted'));
318
+ }
319
+ cleanup();
320
+ };
321
+
322
+ return { url: authUrl, promise, abort };
323
+ }
324
+
325
+ // ============================================================================
326
+ // Headless Device Auth Flow
327
+ // ============================================================================
328
+
329
+ /**
330
+ * Initiate headless device authorization for OpenAI Codex
331
+ * Returns device code info for user to complete in browser
332
+ *
333
+ * @returns {Promise<Object>} { deviceAuthId, userCode, interval }
334
+ */
335
+ export async function initiateCodexDeviceAuth() {
336
+ const response = await fetch(`${CODEX_ISSUER}/api/accounts/deviceauth/usercode`, {
337
+ method: 'POST',
338
+ headers: {
339
+ 'Content-Type': 'application/json',
340
+ 'User-Agent': 'commons-proxy/2.0.0'
341
+ },
342
+ body: JSON.stringify({ client_id: CODEX_CLIENT_ID })
343
+ });
344
+
345
+ if (!response.ok) {
346
+ const text = await response.text();
347
+ throw new Error(`Failed to initiate device authorization: ${response.status} ${text}`);
348
+ }
349
+
350
+ const data = await response.json();
351
+ return {
352
+ deviceAuthId: data.device_auth_id,
353
+ userCode: data.user_code,
354
+ interval: Math.max(parseInt(data.interval) || 5, 1),
355
+ verificationUri: `${CODEX_ISSUER}/codex/device`
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Poll for device auth token completion
361
+ * Single poll attempt - caller should retry
362
+ *
363
+ * @param {string} deviceAuthId - Device auth ID from initiateCodexDeviceAuth
364
+ * @param {string} userCode - User code from initiateCodexDeviceAuth
365
+ * @returns {Promise<Object>} { completed, tokens?, pending? }
366
+ */
367
+ export async function pollCodexDeviceAuth(deviceAuthId, userCode) {
368
+ const response = await fetch(`${CODEX_ISSUER}/api/accounts/deviceauth/token`, {
369
+ method: 'POST',
370
+ headers: {
371
+ 'Content-Type': 'application/json',
372
+ 'User-Agent': 'commons-proxy/2.0.0'
373
+ },
374
+ body: JSON.stringify({
375
+ device_auth_id: deviceAuthId,
376
+ user_code: userCode
377
+ })
378
+ });
379
+
380
+ if (response.ok) {
381
+ const data = await response.json();
382
+
383
+ // Exchange the authorization code for tokens
384
+ const tokenResponse = await fetch(`${CODEX_ISSUER}/oauth/token`, {
385
+ method: 'POST',
386
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
387
+ body: new URLSearchParams({
388
+ grant_type: 'authorization_code',
389
+ code: data.authorization_code,
390
+ redirect_uri: `${CODEX_ISSUER}/deviceauth/callback`,
391
+ client_id: CODEX_CLIENT_ID,
392
+ code_verifier: data.code_verifier
393
+ }).toString()
394
+ });
395
+
396
+ if (!tokenResponse.ok) {
397
+ throw new Error(`Token exchange failed: ${tokenResponse.status}`);
398
+ }
399
+
400
+ const tokens = await tokenResponse.json();
401
+ const accountId = extractAccountId(tokens);
402
+
403
+ return {
404
+ completed: true,
405
+ tokens: {
406
+ accessToken: tokens.access_token,
407
+ refreshToken: tokens.refresh_token,
408
+ idToken: tokens.id_token,
409
+ expiresIn: tokens.expires_in,
410
+ accountId
411
+ }
412
+ };
413
+ }
414
+
415
+ // 403/404 means still pending
416
+ if (response.status === 403 || response.status === 404) {
417
+ return { completed: false, pending: true };
418
+ }
419
+
420
+ // Other errors
421
+ return { completed: false, error: `Unexpected response: ${response.status}` };
422
+ }
423
+
424
+ // Export configuration
425
+ export const CODEX_CONFIG = {
426
+ clientId: CODEX_CLIENT_ID,
427
+ issuer: CODEX_ISSUER,
428
+ apiEndpoint: CODEX_API_ENDPOINT,
429
+ oauthPort: CODEX_OAUTH_PORT
430
+ };
431
+
432
+ export default {
433
+ startCodexBrowserAuth,
434
+ initiateCodexDeviceAuth,
435
+ pollCodexDeviceAuth,
436
+ refreshCodexAccessToken,
437
+ extractAccountId,
438
+ parseJwtClaims,
439
+ CODEX_CONFIG
440
+ };
@@ -11,15 +11,16 @@
11
11
  import BaseProvider from './base-provider.js';
12
12
 
13
13
  // GitHub Copilot OAuth configuration
14
- const COPILOT_CLIENT_ID = 'Iv1.b507a08c87ecfe98';
14
+ // Client ID from opencode's copilot plugin (newer OAuth app)
15
+ const COPILOT_CLIENT_ID = 'Ov23li8tweQw6odWQebz';
15
16
  const COPILOT_DEVICE_CODE_URL = 'https://github.com/login/device/code';
16
17
  const COPILOT_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
17
18
  const COPILOT_API_URL = 'https://api.githubcopilot.com';
18
19
  const COPILOT_TOKEN_URL = 'https://api.github.com/copilot_internal/v2/token';
19
20
 
20
- // In-memory cache for short-lived Copilot API tokens
21
- // Maps GitHub access token -> { token, expiresAt }
22
- const copilotTokenCache = new Map();
21
+ // Polling safety margin to avoid hitting the server too early (from opencode)
22
+ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000;
23
+
23
24
 
24
25
  export class CopilotProvider extends BaseProvider {
25
26
  constructor(config = {}) {
@@ -63,16 +64,6 @@ export class CopilotProvider extends BaseProvider {
63
64
 
64
65
  const userData = await userResponse.json();
65
66
 
66
- // Try to get a Copilot token to verify Copilot access
67
- try {
68
- await this._getCopilotToken(account.apiKey);
69
- } catch (copilotError) {
70
- return {
71
- valid: false,
72
- error: `GitHub token valid but Copilot access denied: ${copilotError.message}. Ensure you have an active GitHub Copilot subscription.`
73
- };
74
- }
75
-
76
67
  const email = userData.email || `${userData.login}@github`;
77
68
  return { valid: true, email };
78
69
  } catch (error) {
@@ -82,60 +73,21 @@ export class CopilotProvider extends BaseProvider {
82
73
  }
83
74
 
84
75
  /**
85
- * Get Copilot API token from the stored GitHub access token.
86
- * Copilot tokens are short-lived (~30 min), so we cache and refresh.
76
+ * Get access token for Copilot API requests.
77
+ * Following opencode's approach: use the GitHub OAuth token directly
78
+ * as Bearer auth with proper Copilot headers.
87
79
  *
88
80
  * @param {Object} account - Account with apiKey (GitHub access token)
89
- * @returns {Promise<string>} Copilot API token
81
+ * @returns {Promise<string>} GitHub access token (used directly as Bearer)
90
82
  */
91
83
  async getAccessToken(account) {
92
84
  if (!account.apiKey) {
93
85
  throw new Error('Account missing GitHub access token');
94
86
  }
95
87
 
96
- const copilotToken = await this._getCopilotToken(account.apiKey);
97
- return copilotToken;
98
- }
99
-
100
- /**
101
- * Internal: Get or refresh Copilot API token
102
- *
103
- * @param {string} githubToken - GitHub OAuth access token
104
- * @returns {Promise<string>} Copilot API token
105
- */
106
- async _getCopilotToken(githubToken) {
107
- // Check cache
108
- const cached = copilotTokenCache.get(githubToken);
109
- if (cached && cached.expiresAt > Date.now() + 60000) {
110
- // Return cached token if it has > 1 minute left
111
- return cached.token;
112
- }
113
-
114
- const response = await fetch(this.config.tokenUrl, {
115
- method: 'GET',
116
- headers: {
117
- 'Authorization': `Bearer ${githubToken}`,
118
- 'User-Agent': 'commons-proxy/2.0.0',
119
- 'Accept': 'application/json'
120
- }
121
- });
122
-
123
- if (!response.ok) {
124
- const text = await response.text();
125
- throw new Error(`Failed to get Copilot token: ${response.status} ${text}`);
126
- }
127
-
128
- const data = await response.json();
129
- const expiresAt = data.expires_at
130
- ? new Date(data.expires_at * 1000).getTime()
131
- : Date.now() + 30 * 60 * 1000; // Default 30 min
132
-
133
- copilotTokenCache.set(githubToken, {
134
- token: data.token,
135
- expiresAt
136
- });
137
-
138
- return data.token;
88
+ // opencode uses the GitHub token directly as Bearer auth
89
+ // with Copilot-specific headers, no separate token exchange needed
90
+ return account.apiKey;
139
91
  }
140
92
 
141
93
  /**
@@ -201,6 +153,7 @@ export class CopilotProvider extends BaseProvider {
201
153
 
202
154
  /**
203
155
  * Get available models from Copilot
156
+ * Updated model list to match current Copilot offerings (aligned with opencode)
204
157
  *
205
158
  * @param {Object} account - Account object
206
159
  * @param {string} token - Copilot API token
@@ -209,12 +162,15 @@ export class CopilotProvider extends BaseProvider {
209
162
  async getAvailableModels(account, token) {
210
163
  return [
211
164
  { id: 'gpt-4o', name: 'GPT-4o', family: 'gpt' },
165
+ { id: 'gpt-4o-mini', name: 'GPT-4o Mini', family: 'gpt' },
212
166
  { id: 'gpt-4', name: 'GPT-4', family: 'gpt' },
213
167
  { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', family: 'gpt' },
214
- { id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', family: 'gpt' },
168
+ { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude' },
215
169
  { id: 'claude-3.5-sonnet', name: 'Claude 3.5 Sonnet', family: 'claude' },
170
+ { id: 'claude-haiku-3.5', name: 'Claude Haiku 3.5', family: 'claude' },
216
171
  { id: 'o1-preview', name: 'o1 Preview', family: 'o1' },
217
- { id: 'o1-mini', name: 'o1 Mini', family: 'o1' }
172
+ { id: 'o1-mini', name: 'o1 Mini', family: 'o1' },
173
+ { id: 'o3-mini', name: 'o3 Mini', family: 'o3' }
218
174
  ];
219
175
  }
220
176
 
@@ -273,10 +229,13 @@ export class CopilotProvider extends BaseProvider {
273
229
 
274
230
  /**
275
231
  * Initiate device authorization flow
232
+ * Uses the same flow as opencode's copilot plugin
233
+ * @param {string} [domain='github.com'] - GitHub domain (for Enterprise support)
276
234
  * @returns {Promise<Object>} Device code response with verification_uri and user_code
277
235
  */
278
- static async initiateDeviceAuth() {
279
- const response = await fetch(COPILOT_DEVICE_CODE_URL, {
236
+ static async initiateDeviceAuth(domain = 'github.com') {
237
+ const deviceCodeUrl = `https://${domain}/login/device/code`;
238
+ const response = await fetch(deviceCodeUrl, {
280
239
  method: 'POST',
281
240
  headers: {
282
241
  'Accept': 'application/json',
@@ -299,22 +258,24 @@ export class CopilotProvider extends BaseProvider {
299
258
 
300
259
  /**
301
260
  * Poll for access token after user completes device auth
261
+ * Matches opencode's polling logic with proper slow_down handling per RFC 8628
302
262
  * @param {string} deviceCode - Device code from initiateDeviceAuth
303
263
  * @param {number} interval - Polling interval in seconds
304
264
  * @param {AbortSignal} [signal] - Optional abort signal
265
+ * @param {string} [domain='github.com'] - GitHub domain (for Enterprise support)
305
266
  * @returns {Promise<Object>} { accessToken, tokenType }
306
267
  */
307
- static async pollForToken(deviceCode, interval = 5, signal = null) {
308
- const maxAttempts = 60; // 5 minutes max
268
+ static async pollForToken(deviceCode, interval = 5, signal = null, domain = 'github.com') {
269
+ const accessTokenUrl = `https://${domain}/login/oauth/access_token`;
309
270
 
310
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
271
+ while (true) {
311
272
  if (signal?.aborted) {
312
273
  throw new Error('Device auth polling aborted');
313
274
  }
314
275
 
315
- await new Promise(resolve => setTimeout(resolve, interval * 1000));
276
+ await new Promise(resolve => setTimeout(resolve, interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS));
316
277
 
317
- const response = await fetch(COPILOT_ACCESS_TOKEN_URL, {
278
+ const response = await fetch(accessTokenUrl, {
318
279
  method: 'POST',
319
280
  headers: {
320
281
  'Accept': 'application/json',
@@ -329,7 +290,7 @@ export class CopilotProvider extends BaseProvider {
329
290
  });
330
291
 
331
292
  if (!response.ok) {
332
- continue;
293
+ return { accessToken: null, error: 'failed' };
333
294
  }
334
295
 
335
296
  const data = await response.json();
@@ -346,16 +307,23 @@ export class CopilotProvider extends BaseProvider {
346
307
  }
347
308
 
348
309
  if (data.error === 'slow_down') {
310
+ // Per RFC 8628 section 3.5: add 5 seconds to current polling interval
349
311
  interval += 5;
312
+ // Use server-provided interval if available
313
+ if (data.interval && typeof data.interval === 'number' && data.interval > 0) {
314
+ interval = data.interval;
315
+ }
350
316
  continue;
351
317
  }
352
318
 
319
+ if (data.error === 'expired_token') {
320
+ throw new Error('Device code expired. Please try again.');
321
+ }
322
+
353
323
  if (data.error) {
354
324
  throw new Error(`OAuth error: ${data.error_description || data.error}`);
355
325
  }
356
326
  }
357
-
358
- throw new Error('Authorization timed out after 5 minutes');
359
327
  }
360
328
 
361
329
  /**
@@ -385,6 +353,31 @@ export class CopilotProvider extends BaseProvider {
385
353
  name: data.name || data.login
386
354
  };
387
355
  }
356
+
357
+ /**
358
+ * Build request headers for Copilot API calls.
359
+ * Matches opencode's copilot plugin header format.
360
+ *
361
+ * @param {string} githubToken - GitHub OAuth access token
362
+ * @param {Object} [options] - Additional options
363
+ * @param {boolean} [options.isAgent=false] - Whether this is an agent-initiated request
364
+ * @param {boolean} [options.isVision=false] - Whether this request contains vision content
365
+ * @returns {Object} Headers object
366
+ */
367
+ static buildCopilotHeaders(githubToken, options = {}) {
368
+ const headers = {
369
+ 'Authorization': `Bearer ${githubToken}`,
370
+ 'User-Agent': 'commons-proxy/2.0.0',
371
+ 'Openai-Intent': 'conversation-edits',
372
+ 'x-initiator': options.isAgent ? 'agent' : 'user'
373
+ };
374
+
375
+ if (options.isVision) {
376
+ headers['Copilot-Vision-Request'] = 'true';
377
+ }
378
+
379
+ return headers;
380
+ }
388
381
  }
389
382
 
390
383
  // Export config constants for use by other modules
@@ -128,10 +128,11 @@ export function registerAuthProvider(id, provider) {
128
128
  * @returns {Array<{id: string, name: string, authType: string}>} Provider list
129
129
  */
130
130
  export function getAllAuthProviders() {
131
+ const deviceAuthProviders = new Set(['copilot', 'codex']);
131
132
  return Array.from(authProviders.entries()).map(([id, provider]) => ({
132
133
  id,
133
134
  name: provider.name,
134
- authType: id === 'google' ? 'oauth' : (id === 'copilot' ? 'device-auth' : 'api-key')
135
+ authType: id === 'google' ? 'oauth' : (deviceAuthProviders.has(id) ? 'device-auth' : 'api-key')
135
136
  }));
136
137
  }
137
138
 
@@ -24,6 +24,8 @@ import { logger } from '../utils/logger.js';
24
24
  import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
25
25
  import { loadAccounts, saveAccounts } from '../account-manager/storage.js';
26
26
  import { getAllAuthProviders, getAuthProvider } from '../providers/index.js';
27
+ import { COPILOT_CONFIG } from '../providers/copilot.js';
28
+ import { initiateCodexDeviceAuth, pollCodexDeviceAuth, startCodexBrowserAuth, CODEX_CONFIG } from '../providers/codex-auth.js';
27
29
 
28
30
  // Get package version
29
31
  const __filename = fileURLToPath(import.meta.url);
@@ -449,8 +451,8 @@ export function mountWebUI(app, dirname, accountManager) {
449
451
  return res.status(400).json({ status: 'error', error: 'provider is required' });
450
452
  }
451
453
 
452
- // For non-Google, non-Copilot providers, API key is required
453
- if (provider !== 'google' && provider !== 'copilot' && !apiKey) {
454
+ // For non-Google, non-Copilot, non-Codex providers, API key is required
455
+ if (provider !== 'google' && provider !== 'copilot' && provider !== 'codex' && !apiKey) {
454
456
  return res.status(400).json({ status: 'error', error: 'apiKey is required for this provider' });
455
457
  }
456
458
 
@@ -547,7 +549,7 @@ export function mountWebUI(app, dirname, accountManager) {
547
549
  'User-Agent': 'commons-proxy/2.0.0'
548
550
  },
549
551
  body: JSON.stringify({
550
- client_id: 'Iv1.b507a08c87ecfe98',
552
+ client_id: COPILOT_CONFIG.clientId,
551
553
  device_code: flow.deviceCode,
552
554
  grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
553
555
  })
@@ -597,6 +599,161 @@ export function mountWebUI(app, dirname, accountManager) {
597
599
  }
598
600
  });
599
601
 
602
+ // ==========================================
603
+ // OpenAI Codex Auth API (ChatGPT Plus/Pro)
604
+ // Inspired by opencode's codex.ts plugin
605
+ // ==========================================
606
+
607
+ // Pending Codex device auth flows
608
+ const pendingCodexFlows = new Map();
609
+
610
+ /**
611
+ * POST /api/codex/device-auth - Initiate Codex headless device authorization
612
+ */
613
+ app.post('/api/codex/device-auth', async (req, res) => {
614
+ try {
615
+ const deviceData = await initiateCodexDeviceAuth();
616
+
617
+ const flowId = crypto.randomUUID();
618
+ pendingCodexFlows.set(flowId, {
619
+ deviceAuthId: deviceData.deviceAuthId,
620
+ userCode: deviceData.userCode,
621
+ interval: deviceData.interval,
622
+ timestamp: Date.now()
623
+ });
624
+
625
+ // Clean up old flows (> 10 mins)
626
+ const now = Date.now();
627
+ for (const [key, val] of pendingCodexFlows.entries()) {
628
+ if (now - val.timestamp > 10 * 60 * 1000) {
629
+ pendingCodexFlows.delete(key);
630
+ }
631
+ }
632
+
633
+ res.json({
634
+ status: 'ok',
635
+ flowId,
636
+ verificationUri: deviceData.verificationUri,
637
+ userCode: deviceData.userCode,
638
+ interval: deviceData.interval
639
+ });
640
+ } catch (error) {
641
+ logger.error('[WebUI] Codex device auth error:', error);
642
+ res.status(500).json({ status: 'error', error: error.message });
643
+ }
644
+ });
645
+
646
+ /**
647
+ * POST /api/codex/poll-token - Poll for Codex token after user authorizes
648
+ */
649
+ app.post('/api/codex/poll-token', async (req, res) => {
650
+ try {
651
+ const { flowId } = req.body;
652
+ if (!flowId) {
653
+ return res.status(400).json({ status: 'error', error: 'flowId is required' });
654
+ }
655
+
656
+ const flow = pendingCodexFlows.get(flowId);
657
+ if (!flow) {
658
+ return res.status(400).json({ status: 'error', error: 'Flow not found or expired' });
659
+ }
660
+
661
+ const result = await pollCodexDeviceAuth(flow.deviceAuthId, flow.userCode);
662
+
663
+ if (result.completed && result.tokens) {
664
+ const email = `codex-${result.tokens.accountId || 'user'}@chatgpt`;
665
+
666
+ await addAccount({
667
+ email,
668
+ provider: 'codex',
669
+ source: 'oauth',
670
+ refreshToken: result.tokens.refreshToken,
671
+ apiKey: result.tokens.accessToken,
672
+ accountId: result.tokens.accountId
673
+ });
674
+
675
+ await accountManager.reload();
676
+ pendingCodexFlows.delete(flowId);
677
+
678
+ logger.success(`[WebUI] Codex account ${email} added via device auth`);
679
+
680
+ res.json({
681
+ status: 'ok',
682
+ completed: true,
683
+ email,
684
+ message: `Account ${email} added successfully`
685
+ });
686
+ } else if (result.pending) {
687
+ res.json({ status: 'ok', completed: false, pending: true });
688
+ } else if (result.error) {
689
+ pendingCodexFlows.delete(flowId);
690
+ res.json({ status: 'error', error: result.error });
691
+ } else {
692
+ res.json({ status: 'ok', completed: false, pending: true });
693
+ }
694
+ } catch (error) {
695
+ logger.error('[WebUI] Codex poll token error:', error);
696
+ res.status(500).json({ status: 'error', error: error.message });
697
+ }
698
+ });
699
+
700
+ /**
701
+ * POST /api/codex/browser-auth - Initiate Codex browser OAuth (PKCE)
702
+ */
703
+ app.post('/api/codex/browser-auth', async (req, res) => {
704
+ try {
705
+ const { url, promise, abort } = await startCodexBrowserAuth();
706
+
707
+ const flowId = crypto.randomUUID();
708
+ pendingCodexFlows.set(flowId, {
709
+ type: 'browser',
710
+ promise,
711
+ abort,
712
+ timestamp: Date.now()
713
+ });
714
+
715
+ // Handle async completion
716
+ promise
717
+ .then(async (tokens) => {
718
+ try {
719
+ const email = `codex-${tokens.accountId || 'user'}@chatgpt`;
720
+
721
+ await addAccount({
722
+ email,
723
+ provider: 'codex',
724
+ source: 'oauth',
725
+ refreshToken: tokens.refreshToken,
726
+ apiKey: tokens.accessToken,
727
+ accountId: tokens.accountId
728
+ });
729
+
730
+ await accountManager.reload();
731
+ logger.success(`[WebUI] Codex account ${email} added via browser auth`);
732
+ } catch (err) {
733
+ logger.error('[WebUI] Codex browser auth completion error:', err);
734
+ } finally {
735
+ pendingCodexFlows.delete(flowId);
736
+ }
737
+ })
738
+ .catch((err) => {
739
+ if (!err.message?.includes('aborted')) {
740
+ logger.error('[WebUI] Codex browser auth error:', err);
741
+ }
742
+ pendingCodexFlows.delete(flowId);
743
+ });
744
+
745
+ res.json({
746
+ status: 'ok',
747
+ flowId,
748
+ url,
749
+ message: 'Open the URL in your browser to authorize'
750
+ });
751
+ } catch (error) {
752
+ logger.error('[WebUI] Codex browser auth error:', error);
753
+ res.status(500).json({ status: 'error', error: error.message });
754
+ }
755
+ });
756
+
600
757
  // ==========================================
601
758
  // Configuration API
602
759
  // ==========================================