@elvatis_com/openclaw-cli-bridge-elvatis 0.2.30 → 1.0.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
4
4
 
5
- **Current version:** `0.2.30`
5
+ **Current version:** `1.0.0`
6
6
 
7
7
  ---
8
8
 
@@ -287,6 +287,19 @@ npm test # vitest run (45 tests)
287
287
 
288
288
  ## Changelog
289
289
 
290
+ ## v1.0.0 — Full Headless Browser Bridge 🚀
291
+
292
+ All four major LLM providers are now available via browser automation.
293
+ No CLI binaries required — just authenticated browser sessions.
294
+
295
+ ### v1.0.0
296
+ - **feat:** `chatgpt-browser.ts` — chatgpt.com DOM-automation (`#prompt-textarea` + `[data-message-author-role]`)
297
+ - **feat:** `web-chatgpt/*` models: gpt-4o, gpt-4o-mini, gpt-o3, gpt-o4-mini, gpt-5
298
+ - **feat:** `/chatgpt-login`, `/chatgpt-status`, `/chatgpt-logout` + cookie-expiry tracking
299
+ - **feat:** All 4 providers headless: Grok ✅ Claude ✅ Gemini ✅ ChatGPT ✅
300
+ - **test:** 96/96 tests green (8 test files)
301
+ - **fix:** Singleton CDP connection, cleanupBrowsers() on plugin stop
302
+
290
303
  ### v0.2.30
291
304
  - **feat:** `gemini-browser.ts` — gemini.google.com DOM-automation (Quill editor + message-content polling)
292
305
  - **feat:** `web-gemini/*` models in proxy (gemini-2-5-pro, gemini-2-5-flash, gemini-3-pro, gemini-3-flash)
package/SKILL.md CHANGED
@@ -53,4 +53,4 @@ Each command runs `openclaw models set <model>` atomically and replies with a co
53
53
 
54
54
  See `README.md` for full configuration reference and architecture diagram.
55
55
 
56
- **Version:** 0.2.30
56
+ **Version:** 1.0.0
package/index.ts CHANGED
@@ -121,6 +121,32 @@ async function scanGeminiCookieExpiry(ctx: BrowserContext): Promise<GeminiExpiry
121
121
  }
122
122
  // ─────────────────────────────────────────────────────────────────────────────
123
123
 
124
+ // ── ChatGPT web-session state ─────────────────────────────────────────────────
125
+ let chatgptContext: BrowserContext | null = null;
126
+ const CHATGPT_EXPIRY_FILE = join(homedir(), ".openclaw", "chatgpt-cookie-expiry.json");
127
+ interface ChatGPTExpiryInfo { expiresAt: number; loginAt: number; cookieName: string; }
128
+ function saveChatGPTExpiry(i: ChatGPTExpiryInfo) { try { writeFileSync(CHATGPT_EXPIRY_FILE, JSON.stringify(i, null, 2)); } catch { /* ignore */ } }
129
+ function loadChatGPTExpiry(): ChatGPTExpiryInfo | null { try { return JSON.parse(readFileSync(CHATGPT_EXPIRY_FILE, "utf-8")); } catch { return null; } }
130
+ function formatChatGPTExpiry(i: ChatGPTExpiryInfo): string {
131
+ const d = Math.floor((i.expiresAt - Date.now()) / 86_400_000);
132
+ const dt = new Date(i.expiresAt).toISOString().substring(0, 10);
133
+ if (d < 0) return `⚠️ EXPIRED (${dt}) — run /chatgpt-login`;
134
+ if (d <= 7) return `🚨 expires in ${d}d (${dt}) — run /chatgpt-login NOW`;
135
+ if (d <= 14) return `⚠️ expires in ${d}d (${dt}) — run /chatgpt-login soon`;
136
+ return `✅ valid for ${d} more days (expires ${dt})`;
137
+ }
138
+ async function scanChatGPTCookieExpiry(ctx: BrowserContext): Promise<ChatGPTExpiryInfo | null> {
139
+ try {
140
+ const cookies = await ctx.cookies(["https://chatgpt.com", "https://openai.com"]);
141
+ const auth = cookies.filter(c => ["__Secure-next-auth.session-token", "cf_clearance", "__cf_bm"].includes(c.name) && c.expires && c.expires > 0);
142
+ if (!auth.length) return null;
143
+ auth.sort((a, b) => (a.expires ?? 0) - (b.expires ?? 0));
144
+ const earliest = auth[0];
145
+ return { expiresAt: (earliest.expires ?? 0) * 1000, loginAt: Date.now(), cookieName: earliest.name };
146
+ } catch { return null; }
147
+ }
148
+ // ─────────────────────────────────────────────────────────────────────────────
149
+
124
150
  // ── Claude web-session state ──────────────────────────────────────────────────
125
151
  let claudeContext: BrowserContext | null = null;
126
152
  const CLAUDE_EXPIRY_FILE = join(homedir(), ".openclaw", "claude-cookie-expiry.json");
@@ -293,6 +319,7 @@ async function cleanupBrowsers(log: (msg: string) => void): Promise<void> {
293
319
  }
294
320
  claudeContext = null;
295
321
  geminiContext = null;
322
+ chatgptContext = null;
296
323
  log("[cli-bridge] browser resources cleaned up");
297
324
  }
298
325
 
@@ -651,7 +678,7 @@ function proxyTestRequest(
651
678
  const plugin = {
652
679
  id: "openclaw-cli-bridge-elvatis",
653
680
  name: "OpenClaw CLI Bridge",
654
- version: "0.2.30",
681
+ version: "1.0.0",
655
682
  description:
656
683
  "Phase 1: openai-codex auth bridge. " +
657
684
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -796,6 +823,17 @@ const plugin = {
796
823
  }
797
824
  return null;
798
825
  },
826
+ getChatGPTContext: () => chatgptContext,
827
+ connectChatGPTContext: async () => {
828
+ const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
829
+ if (ctx) {
830
+ const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
831
+ const { page } = await getOrCreateChatGPTPage(ctx);
832
+ const editor = await page.locator("#prompt-textarea").isVisible().catch(() => false);
833
+ if (editor) { chatgptContext = ctx; return ctx; }
834
+ }
835
+ return null;
836
+ },
799
837
  });
800
838
  proxyServer = server;
801
839
  api.logger.info(
@@ -850,6 +888,17 @@ const plugin = {
850
888
  }
851
889
  return null;
852
890
  },
891
+ getChatGPTContext: () => chatgptContext,
892
+ connectChatGPTContext: async () => {
893
+ const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
894
+ if (ctx) {
895
+ const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
896
+ const { page } = await getOrCreateChatGPTPage(ctx);
897
+ const editor = await page.locator("#prompt-textarea").isVisible().catch(() => false);
898
+ if (editor) { chatgptContext = ctx; return ctx; }
899
+ }
900
+ return null;
901
+ },
853
902
  });
854
903
  proxyServer = server;
855
904
  api.logger.info(`[cli-bridge] proxy ready on :${port} (retry)`);
@@ -1346,6 +1395,68 @@ const plugin = {
1346
1395
  return { text: "✅ Disconnected from gemini.google.com. Run `/gemini-login` to reconnect." };
1347
1396
  },
1348
1397
  } satisfies OpenClawPluginCommandDefinition);
1398
+
1399
+ // ── ChatGPT web-session commands ──────────────────────────────────────────
1400
+ api.registerCommand({
1401
+ name: "chatgpt-login",
1402
+ description: "Authenticate chatgpt.com: imports session from OpenClaw browser",
1403
+ handler: async (): Promise<PluginCommandResult> => {
1404
+ if (chatgptContext) {
1405
+ const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
1406
+ try {
1407
+ const { page } = await getOrCreateChatGPTPage(chatgptContext);
1408
+ if (await page.locator("#prompt-textarea").isVisible().catch(() => false))
1409
+ return { text: "✅ Already connected to chatgpt.com. Use `/chatgpt-logout` first to reset." };
1410
+ } catch { /* fall through */ }
1411
+ chatgptContext = null;
1412
+ }
1413
+ api.logger.info("[cli-bridge:chatgpt] /chatgpt-login: connecting to OpenClaw browser…");
1414
+ const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
1415
+ if (!ctx) return { text: "❌ Could not connect to OpenClaw browser.\nMake sure chatgpt.com is open in your browser." };
1416
+
1417
+ const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
1418
+ let page;
1419
+ try { ({ page } = await getOrCreateChatGPTPage(ctx)); }
1420
+ catch (err) { return { text: `❌ Failed to open chatgpt.com: ${(err as Error).message}` }; }
1421
+
1422
+ if (!await page.locator("#prompt-textarea").isVisible().catch(() => false))
1423
+ return { text: "❌ ChatGPT editor not visible — are you logged in?\nOpen chatgpt.com in your browser and try again." };
1424
+
1425
+ chatgptContext = ctx;
1426
+ const expiry = await scanChatGPTCookieExpiry(ctx);
1427
+ if (expiry) { saveChatGPTExpiry(expiry); api.logger.info(`[cli-bridge:chatgpt] cookie expiry: ${new Date(expiry.expiresAt).toISOString()}`); }
1428
+ const expiryLine = expiry ? `\n\n🕐 Cookie expiry: ${formatChatGPTExpiry(expiry)}` : "";
1429
+ return { text: `✅ ChatGPT session ready!\n\nModels available:\n• \`vllm/web-chatgpt/gpt-4o\`\n• \`vllm/web-chatgpt/gpt-4o-mini\`\n• \`vllm/web-chatgpt/gpt-o3\`\n• \`vllm/web-chatgpt/gpt-o4-mini\`\n• \`vllm/web-chatgpt/gpt-5\`${expiryLine}` };
1430
+ },
1431
+ } satisfies OpenClawPluginCommandDefinition);
1432
+
1433
+ api.registerCommand({
1434
+ name: "chatgpt-status",
1435
+ description: "Check chatgpt.com session status",
1436
+ handler: async (): Promise<PluginCommandResult> => {
1437
+ if (!chatgptContext) return { text: "❌ No active chatgpt.com session\nRun `/chatgpt-login` to authenticate." };
1438
+ const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
1439
+ try {
1440
+ const { page } = await getOrCreateChatGPTPage(chatgptContext);
1441
+ if (await page.locator("#prompt-textarea").isVisible().catch(() => false)) {
1442
+ const expiry = loadChatGPTExpiry();
1443
+ const expiryLine = expiry ? `\n🕐 ${formatChatGPTExpiry(expiry)}` : "";
1444
+ return { text: `✅ chatgpt.com session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-chatgpt/gpt-4o, gpt-4o-mini, gpt-o3, gpt-o4-mini, gpt-5${expiryLine}` };
1445
+ }
1446
+ } catch { /* fall through */ }
1447
+ chatgptContext = null;
1448
+ return { text: "❌ Session lost — run `/chatgpt-login` to re-authenticate." };
1449
+ },
1450
+ } satisfies OpenClawPluginCommandDefinition);
1451
+
1452
+ api.registerCommand({
1453
+ name: "chatgpt-logout",
1454
+ description: "Disconnect from chatgpt.com session",
1455
+ handler: async (): Promise<PluginCommandResult> => {
1456
+ chatgptContext = null;
1457
+ return { text: "✅ Disconnected from chatgpt.com. Run `/chatgpt-login` to reconnect." };
1458
+ },
1459
+ } satisfies OpenClawPluginCommandDefinition);
1349
1460
  // ─────────────────────────────────────────────────────────────────────────
1350
1461
 
1351
1462
  const allCommands = [
@@ -1362,6 +1473,9 @@ const plugin = {
1362
1473
  "/gemini-login",
1363
1474
  "/gemini-status",
1364
1475
  "/gemini-logout",
1476
+ "/chatgpt-login",
1477
+ "/chatgpt-status",
1478
+ "/chatgpt-logout",
1365
1479
  ];
1366
1480
  api.logger.info(`[cli-bridge] registered ${allCommands.length} commands: ${allCommands.join(", ")}`);
1367
1481
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "0.2.30",
4
+ "version": "1.0.0",
5
5
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
6
6
  "providers": [
7
7
  "openai-codex"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "0.2.30",
3
+ "version": "1.0.0",
4
4
  "description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
5
5
  "type": "module",
6
6
  "openclaw": {
@@ -0,0 +1,253 @@
1
+ /**
2
+ * chatgpt-browser.ts
3
+ *
4
+ * ChatGPT web automation via Playwright DOM-polling.
5
+ * Strategy identical to claude-browser.ts / grok-client.ts.
6
+ *
7
+ * DOM structure (confirmed 2026-03-11):
8
+ * Editor: #prompt-textarea (ProseMirror — use execCommand)
9
+ * Send btn: button[data-testid="send-button"]
10
+ * Response: [data-message-author-role="assistant"] (last element)
11
+ * Streaming indicator: button[data-testid="stop-button"]
12
+ */
13
+
14
+ import type { BrowserContext, Page } from "playwright";
15
+
16
+ export interface ChatMessage {
17
+ role: "system" | "user" | "assistant";
18
+ content: string;
19
+ }
20
+
21
+ export interface ChatGPTBrowserOptions {
22
+ messages: ChatMessage[];
23
+ model?: string;
24
+ timeoutMs?: number;
25
+ }
26
+
27
+ export interface ChatGPTBrowserResult {
28
+ content: string;
29
+ model: string;
30
+ finishReason: string;
31
+ }
32
+
33
+ const DEFAULT_TIMEOUT_MS = 120_000;
34
+ const STABLE_CHECKS = 3;
35
+ const STABLE_INTERVAL_MS = 500;
36
+ const CHATGPT_HOME = "https://chatgpt.com";
37
+
38
+ const MODEL_MAP: Record<string, string> = {
39
+ "gpt-4o": "gpt-4o",
40
+ "gpt-4o-mini": "gpt-4o-mini",
41
+ "gpt-o3": "o3",
42
+ "gpt-o4-mini": "o4-mini",
43
+ "gpt-4-1": "gpt-4.1",
44
+ "gpt-5": "gpt-5",
45
+ };
46
+
47
+ function resolveModel(m?: string): string {
48
+ const clean = (m ?? "gpt-4o").replace("web-chatgpt/", "");
49
+ return MODEL_MAP[clean] ?? clean;
50
+ }
51
+
52
+ function flattenMessages(messages: ChatMessage[]): string {
53
+ if (messages.length === 1) return messages[0].content;
54
+ return messages
55
+ .map((m) => {
56
+ if (m.role === "system") return `[System]: ${m.content}`;
57
+ if (m.role === "assistant") return `[Assistant]: ${m.content}`;
58
+ return m.content;
59
+ })
60
+ .join("\n\n");
61
+ }
62
+
63
+ /**
64
+ * Get or create a chatgpt.com page in the given context.
65
+ */
66
+ export async function getOrCreateChatGPTPage(
67
+ context: BrowserContext
68
+ ): Promise<{ page: Page; owned: boolean }> {
69
+ const existing = context.pages().filter((p) => p.url().startsWith("https://chatgpt.com"));
70
+ if (existing.length > 0) return { page: existing[0], owned: false };
71
+ const page = await context.newPage();
72
+ await page.goto(CHATGPT_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
73
+ await new Promise((r) => setTimeout(r, 2_000));
74
+ return { page, owned: true };
75
+ }
76
+
77
+ /**
78
+ * Count assistant messages on the page.
79
+ */
80
+ async function countAssistantMessages(page: Page): Promise<number> {
81
+ return page.evaluate(() =>
82
+ document.querySelectorAll('[data-message-author-role="assistant"]').length
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Get the text of the last assistant message.
88
+ */
89
+ async function getLastAssistantText(page: Page): Promise<string> {
90
+ return page.evaluate(() => {
91
+ const els = [...document.querySelectorAll('[data-message-author-role="assistant"]')];
92
+ return els[els.length - 1]?.textContent?.trim() ?? "";
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Check if ChatGPT is still generating (stop button visible).
98
+ */
99
+ async function isStreaming(page: Page): Promise<boolean> {
100
+ return page.evaluate(() =>
101
+ !!document.querySelector('button[data-testid="stop-button"]')
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Send a message and wait for stable response.
107
+ */
108
+ async function sendAndWait(
109
+ page: Page,
110
+ message: string,
111
+ timeoutMs: number,
112
+ log: (msg: string) => void
113
+ ): Promise<string> {
114
+ const countBefore = await countAssistantMessages(page);
115
+
116
+ // Type into ProseMirror via execCommand
117
+ await page.evaluate((msg: string) => {
118
+ const ed = document.querySelector("#prompt-textarea") as HTMLElement | null;
119
+ if (!ed) throw new Error("ChatGPT editor (#prompt-textarea) not found");
120
+ ed.focus();
121
+ document.execCommand("insertText", false, msg);
122
+ }, message);
123
+
124
+ await new Promise((r) => setTimeout(r, 300));
125
+
126
+ // Click send button (preferred) or Enter
127
+ const sendBtn = page.locator('button[data-testid="send-button"]').first();
128
+ const hasSendBtn = await sendBtn.isVisible().catch(() => false);
129
+ if (hasSendBtn) {
130
+ await sendBtn.click();
131
+ } else {
132
+ await page.keyboard.press("Enter");
133
+ }
134
+
135
+ log(`chatgpt-browser: message sent (${message.length} chars), waiting…`);
136
+
137
+ const deadline = Date.now() + timeoutMs;
138
+ let lastText = "";
139
+ let stableCount = 0;
140
+
141
+ while (Date.now() < deadline) {
142
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
143
+
144
+ const currentCount = await countAssistantMessages(page);
145
+ if (currentCount <= countBefore) continue;
146
+
147
+ // Still generating?
148
+ const streaming = await isStreaming(page);
149
+ if (streaming) { stableCount = 0; continue; }
150
+
151
+ const text = await getLastAssistantText(page);
152
+ if (!text) continue;
153
+
154
+ if (text === lastText) {
155
+ stableCount++;
156
+ if (stableCount >= STABLE_CHECKS) {
157
+ log(`chatgpt-browser: response stable (${text.length} chars)`);
158
+ return text;
159
+ }
160
+ } else {
161
+ stableCount = 0;
162
+ lastText = text;
163
+ }
164
+ }
165
+
166
+ throw new Error(`chatgpt.com response timeout after ${timeoutMs}ms`);
167
+ }
168
+
169
+ // ─────────────────────────────────────────────────────────────────────────────
170
+
171
+ export async function chatgptComplete(
172
+ context: BrowserContext,
173
+ opts: ChatGPTBrowserOptions,
174
+ log: (msg: string) => void
175
+ ): Promise<ChatGPTBrowserResult> {
176
+ const { page, owned } = await getOrCreateChatGPTPage(context);
177
+ const model = resolveModel(opts.model);
178
+ const prompt = flattenMessages(opts.messages);
179
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
180
+
181
+ log(`chatgpt-browser: complete model=${model}`);
182
+
183
+ try {
184
+ const content = await sendAndWait(page, prompt, timeoutMs, log);
185
+ return { content, model, finishReason: "stop" };
186
+ } finally {
187
+ if (owned) await page.close().catch(() => {});
188
+ }
189
+ }
190
+
191
+ export async function chatgptCompleteStream(
192
+ context: BrowserContext,
193
+ opts: ChatGPTBrowserOptions,
194
+ onToken: (token: string) => void,
195
+ log: (msg: string) => void
196
+ ): Promise<ChatGPTBrowserResult> {
197
+ const { page, owned } = await getOrCreateChatGPTPage(context);
198
+ const model = resolveModel(opts.model);
199
+ const prompt = flattenMessages(opts.messages);
200
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
201
+
202
+ log(`chatgpt-browser: stream model=${model}`);
203
+
204
+ const countBefore = await countAssistantMessages(page);
205
+
206
+ await page.evaluate((msg: string) => {
207
+ const ed = document.querySelector("#prompt-textarea") as HTMLElement | null;
208
+ if (!ed) throw new Error("ChatGPT editor not found");
209
+ ed.focus();
210
+ document.execCommand("insertText", false, msg);
211
+ }, prompt);
212
+ await new Promise((r) => setTimeout(r, 300));
213
+ const sendBtn = page.locator('button[data-testid="send-button"]').first();
214
+ if (await sendBtn.isVisible().catch(() => false)) await sendBtn.click();
215
+ else await page.keyboard.press("Enter");
216
+
217
+ const deadline = Date.now() + timeoutMs;
218
+ let emittedLength = 0;
219
+ let lastText = "";
220
+ let stableCount = 0;
221
+
222
+ while (Date.now() < deadline) {
223
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
224
+
225
+ const currentCount = await countAssistantMessages(page);
226
+ if (currentCount <= countBefore) continue;
227
+
228
+ const text = await getLastAssistantText(page);
229
+
230
+ if (text.length > emittedLength) {
231
+ onToken(text.slice(emittedLength));
232
+ emittedLength = text.length;
233
+ }
234
+
235
+ const streaming = await isStreaming(page);
236
+ if (streaming) { stableCount = 0; continue; }
237
+
238
+ if (text && text === lastText) {
239
+ stableCount++;
240
+ if (stableCount >= STABLE_CHECKS) {
241
+ log(`chatgpt-browser: stream done (${text.length} chars)`);
242
+ if (owned) await page.close().catch(() => {});
243
+ return { content: text, model, finishReason: "stop" };
244
+ }
245
+ } else {
246
+ stableCount = 0;
247
+ lastText = text;
248
+ }
249
+ }
250
+
251
+ if (owned) await page.close().catch(() => {});
252
+ throw new Error(`chatgpt.com stream timeout after ${timeoutMs}ms`);
253
+ }
@@ -15,6 +15,7 @@ import { scheduleTokenRefresh, setAuthLogger, stopTokenRefresh } from "./claude-
15
15
  import { grokComplete, grokCompleteStream, type ChatMessage as GrokChatMessage } from "./grok-client.js";
16
16
  import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowserChatMessage } from "./claude-browser.js";
17
17
  import { geminiComplete, geminiCompleteStream, type ChatMessage as GeminiBrowserChatMessage } from "./gemini-browser.js";
18
+ import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrowserChatMessage } from "./chatgpt-browser.js";
18
19
  import type { BrowserContext } from "playwright";
19
20
 
20
21
  export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
@@ -51,6 +52,14 @@ export interface ProxyServerOptions {
51
52
  _geminiComplete?: typeof geminiComplete;
52
53
  /** Override for testing — replaces geminiCompleteStream */
53
54
  _geminiCompleteStream?: typeof geminiCompleteStream;
55
+ /** Returns the current authenticated ChatGPT BrowserContext */
56
+ getChatGPTContext?: () => BrowserContext | null;
57
+ /** Async lazy connect for ChatGPT */
58
+ connectChatGPTContext?: () => Promise<BrowserContext | null>;
59
+ /** Override for testing */
60
+ _chatgptComplete?: typeof chatgptComplete;
61
+ /** Override for testing */
62
+ _chatgptCompleteStream?: typeof chatgptCompleteStream;
54
63
  }
55
64
 
56
65
  /** Available CLI bridge models for GET /v1/models */
@@ -105,6 +114,12 @@ export const CLI_MODELS = [
105
114
  { id: "web-gemini/gemini-2-5-flash", name: "Gemini 2.5 Flash (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
106
115
  { id: "web-gemini/gemini-3-pro", name: "Gemini 3 Pro (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
107
116
  { id: "web-gemini/gemini-3-flash", name: "Gemini 3 Flash (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
117
+ // ChatGPT web-session models (requires /chatgpt-login)
118
+ { id: "web-chatgpt/gpt-4o", name: "GPT-4o (web session)", contextWindow: 128_000, maxTokens: 16_384 },
119
+ { id: "web-chatgpt/gpt-4o-mini", name: "GPT-4o Mini (web session)", contextWindow: 128_000, maxTokens: 16_384 },
120
+ { id: "web-chatgpt/gpt-o3", name: "o3 (web session)", contextWindow: 200_000, maxTokens: 100_000 },
121
+ { id: "web-chatgpt/gpt-o4-mini", name: "o4-mini (web session)", contextWindow: 200_000, maxTokens: 100_000 },
122
+ { id: "web-chatgpt/gpt-5", name: "GPT-5 (web session)", contextWindow: 1_000_000, maxTokens: 32_768 },
108
123
  ];
109
124
 
110
125
  // ──────────────────────────────────────────────────────────────────────────────
@@ -374,6 +389,43 @@ async function handleRequest(
374
389
  }
375
390
  // ─────────────────────────────────────────────────────────────────────────
376
391
 
392
+ // ── ChatGPT web-session routing ───────────────────────────────────────────
393
+ if (model.startsWith("web-chatgpt/")) {
394
+ let chatgptCtx = opts.getChatGPTContext?.() ?? null;
395
+ if (!chatgptCtx && opts.connectChatGPTContext) chatgptCtx = await opts.connectChatGPTContext();
396
+ if (!chatgptCtx) {
397
+ res.writeHead(503, { "Content-Type": "application/json" });
398
+ res.end(JSON.stringify({ error: { message: "No active chatgpt.com session. Use /chatgpt-login to authenticate.", code: "no_chatgpt_session" } }));
399
+ return;
400
+ }
401
+ const timeoutMs = opts.timeoutMs ?? 120_000;
402
+ const msgs = messages as ChatGPTBrowserChatMessage[];
403
+ const doComplete = opts._chatgptComplete ?? chatgptComplete;
404
+ const doStream = opts._chatgptCompleteStream ?? chatgptCompleteStream;
405
+ try {
406
+ if (stream) {
407
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
408
+ sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
409
+ const result = await doStream(chatgptCtx, { messages: msgs, model, timeoutMs },
410
+ (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }), opts.log);
411
+ sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
412
+ res.write("data: [DONE]\n\n"); res.end();
413
+ } else {
414
+ const result = await doComplete(chatgptCtx, { messages: msgs, model, timeoutMs }, opts.log);
415
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
416
+ res.end(JSON.stringify({ id, object: "chat.completion", created, model,
417
+ choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
418
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } }));
419
+ }
420
+ } catch (err) {
421
+ const msg = (err as Error).message;
422
+ opts.warn(`[cli-bridge] ChatGPT browser error for ${model}: ${msg}`);
423
+ if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: { message: msg, type: "chatgpt_browser_error" } })); }
424
+ }
425
+ return;
426
+ }
427
+ // ─────────────────────────────────────────────────────────────────────────
428
+
377
429
  // ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
378
430
  let content: string;
379
431
  try {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * test/chatgpt-proxy.test.ts
3
+ *
4
+ * Tests for ChatGPT web-session routing in the cli-bridge proxy.
5
+ * Uses _chatgptComplete/_chatgptCompleteStream DI overrides (no real browser).
6
+ */
7
+
8
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
9
+ import http from "node:http";
10
+ import type { AddressInfo } from "node:net";
11
+ import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
12
+ import type { BrowserContext } from "playwright";
13
+
14
+ type Opts = { messages: { role: string; content: string }[]; model?: string; timeoutMs?: number };
15
+ type Result = { content: string; model: string; finishReason: string };
16
+
17
+ const stubComplete = vi.fn(async (_ctx: BrowserContext, opts: Opts, _log: (m: string) => void): Promise<Result> => ({
18
+ content: `chatgpt mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
19
+ model: opts.model ?? "web-chatgpt/gpt-4o", finishReason: "stop",
20
+ }));
21
+ const stubStream = vi.fn(async (_ctx: BrowserContext, opts: Opts, onToken: (t: string) => void, _log: (m: string) => void): Promise<Result> => {
22
+ ["chatgpt ", "stream ", "mock"].forEach(t => onToken(t));
23
+ return { content: "chatgpt stream mock", model: opts.model ?? "web-chatgpt/gpt-4o", finishReason: "stop" };
24
+ });
25
+
26
+ async function post(url: string, body: unknown): Promise<{ status: number; body: unknown }> {
27
+ return new Promise((res, rej) => {
28
+ const d = JSON.stringify(body); const u = new URL(url);
29
+ const r = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
30
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(d) } },
31
+ resp => { let raw = ""; resp.on("data", c => raw += c); resp.on("end", () => { try { res({ status: resp.statusCode ?? 0, body: JSON.parse(raw) }); } catch { res({ status: resp.statusCode ?? 0, body: raw }); } }); });
32
+ r.on("error", rej); r.write(d); r.end();
33
+ });
34
+ }
35
+ async function get(url: string): Promise<{ status: number; body: unknown }> {
36
+ return new Promise((res, rej) => {
37
+ const u = new URL(url);
38
+ const r = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "GET" },
39
+ resp => { let raw = ""; resp.on("data", c => raw += c); resp.on("end", () => { try { res({ status: resp.statusCode ?? 0, body: JSON.parse(raw) }); } catch { res({ status: resp.statusCode ?? 0, body: raw }); } }); });
40
+ r.on("error", rej); r.end();
41
+ });
42
+ }
43
+
44
+ const fakeCtx = {} as BrowserContext;
45
+ let server: http.Server;
46
+ let baseUrl: string;
47
+
48
+ beforeAll(async () => {
49
+ server = await startProxyServer({
50
+ port: 0, log: () => {}, warn: () => {},
51
+ getChatGPTContext: () => fakeCtx,
52
+ // @ts-expect-error stub
53
+ _chatgptComplete: stubComplete,
54
+ // @ts-expect-error stub
55
+ _chatgptCompleteStream: stubStream,
56
+ });
57
+ baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
58
+ });
59
+ afterAll(() => server.close());
60
+
61
+ describe("ChatGPT routing — model list", () => {
62
+ it("includes web-chatgpt/* models in /v1/models", async () => {
63
+ const res = await get(`${baseUrl}/v1/models`);
64
+ expect(res.status).toBe(200);
65
+ const ids = (res.body as { data: { id: string }[] }).data.map(m => m.id);
66
+ expect(ids).toContain("web-chatgpt/gpt-4o");
67
+ expect(ids).toContain("web-chatgpt/gpt-5");
68
+ });
69
+ it("CLI_MODELS includes web-chatgpt/*", () => {
70
+ expect(CLI_MODELS.some(m => m.id.startsWith("web-chatgpt/"))).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe("ChatGPT routing — non-streaming", () => {
75
+ it("returns assistant message for web-chatgpt/gpt-4o", async () => {
76
+ const res = await post(`${baseUrl}/v1/chat/completions`, {
77
+ model: "web-chatgpt/gpt-4o", messages: [{ role: "user", content: "hello chatgpt" }], stream: false,
78
+ });
79
+ expect(res.status).toBe(200);
80
+ expect((res.body as any).choices[0].message.content).toContain("chatgpt mock");
81
+ });
82
+ it("passes correct model to stub", async () => {
83
+ stubComplete.mockClear();
84
+ await post(`${baseUrl}/v1/chat/completions`, { model: "web-chatgpt/gpt-o3", messages: [{ role: "user", content: "x" }] });
85
+ expect(stubComplete).toHaveBeenCalledOnce();
86
+ expect(stubComplete.mock.calls[0][1].model).toBe("web-chatgpt/gpt-o3");
87
+ });
88
+ it("returns 503 when no context", async () => {
89
+ const s = await startProxyServer({ port: 0, log: () => {}, warn: () => {}, getChatGPTContext: () => null });
90
+ const u = `http://127.0.0.1:${(s.address() as AddressInfo).port}`;
91
+ const r = await post(`${u}/v1/chat/completions`, { model: "web-chatgpt/gpt-4o", messages: [{ role: "user", content: "hi" }] });
92
+ expect(r.status).toBe(503);
93
+ expect((r.body as any).error.code).toBe("no_chatgpt_session");
94
+ s.close();
95
+ });
96
+ });
97
+
98
+ describe("ChatGPT routing — streaming", () => {
99
+ it("returns SSE stream with [DONE]", () => new Promise<void>((resolve, reject) => {
100
+ const body = JSON.stringify({ model: "web-chatgpt/gpt-4o", messages: [{ role: "user", content: "s" }], stream: true });
101
+ const u = new URL(`${baseUrl}/v1/chat/completions`);
102
+ const r = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
103
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
104
+ resp => { let raw = ""; resp.on("data", c => raw += c); resp.on("end", () => { expect(raw).toContain("[DONE]"); resolve(); }); });
105
+ r.on("error", reject); r.write(body); r.end();
106
+ }));
107
+ });