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 +10 -9
- package/package.json +4 -2
- package/src/cli/accounts.js +2 -1
- package/src/constants.js +13 -2
- package/src/providers/codex-auth.js +440 -0
- package/src/providers/copilot.js +65 -72
- package/src/providers/index.js +2 -1
- package/src/webui/index.js +160 -3
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.
|
|
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
|
-
│ •
|
|
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,
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
- [
|
|
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.
|
|
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",
|
package/src/cli/accounts.js
CHANGED
|
@@ -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:
|
|
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
|
+
};
|
package/src/providers/copilot.js
CHANGED
|
@@ -11,15 +11,16 @@
|
|
|
11
11
|
import BaseProvider from './base-provider.js';
|
|
12
12
|
|
|
13
13
|
// GitHub Copilot OAuth configuration
|
|
14
|
-
|
|
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
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
86
|
-
*
|
|
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>}
|
|
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
|
-
|
|
97
|
-
|
|
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: '
|
|
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
|
|
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
|
|
268
|
+
static async pollForToken(deviceCode, interval = 5, signal = null, domain = 'github.com') {
|
|
269
|
+
const accessTokenUrl = `https://${domain}/login/oauth/access_token`;
|
|
309
270
|
|
|
310
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
package/src/providers/index.js
CHANGED
|
@@ -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
|
|
135
|
+
authType: id === 'google' ? 'oauth' : (deviceAuthProviders.has(id) ? 'device-auth' : 'api-key')
|
|
135
136
|
}));
|
|
136
137
|
}
|
|
137
138
|
|
package/src/webui/index.js
CHANGED
|
@@ -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:
|
|
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
|
// ==========================================
|