@elvatis_com/openclaw-cli-bridge-elvatis 0.2.28 → 0.2.30

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.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * claude-browser.ts
3
+ *
4
+ * Claude.ai browser automation via Playwright DOM-polling.
5
+ * Identical strategy to grok-client.ts — no direct API calls,
6
+ * everything runs through the authenticated browser page.
7
+ *
8
+ * DOM structure (as of 2026-03-11):
9
+ * Editor: .ProseMirror (tiptap)
10
+ * Messages: [data-test-render-count] divs, alternating user/assistant
11
+ * Assistant messages: child div with class "group" (no "mb-1 mt-6")
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 ClaudeBrowserOptions {
22
+ messages: ChatMessage[];
23
+ model?: string;
24
+ timeoutMs?: number;
25
+ }
26
+
27
+ export interface ClaudeBrowserResult {
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 CLAUDE_HOME = "https://claude.ai/new";
37
+
38
+ const MODEL_MAP: Record<string, string> = {
39
+ "claude-sonnet": "claude-sonnet",
40
+ "claude-opus": "claude-opus",
41
+ "claude-haiku": "claude-haiku",
42
+ "claude-sonnet-4-5": "claude-sonnet-4-5",
43
+ "claude-sonnet-4-6": "claude-sonnet-4-6",
44
+ "claude-opus-4-5": "claude-opus-4-5",
45
+ };
46
+
47
+ function resolveModel(m?: string): string {
48
+ const clean = (m ?? "claude-sonnet").replace("web-claude/", "");
49
+ return MODEL_MAP[clean] ?? "claude-sonnet";
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 claude.ai/new page in the given context.
65
+ */
66
+ export async function getOrCreateClaudePage(
67
+ context: BrowserContext
68
+ ): Promise<{ page: Page; owned: boolean }> {
69
+ const existing = context.pages().filter((p) => p.url().startsWith("https://claude.ai"));
70
+ if (existing.length > 0) return { page: existing[0], owned: false };
71
+ const page = await context.newPage();
72
+ await page.goto(CLAUDE_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 message containers currently on the page.
79
+ * Assistant messages: [data-test-render-count] where child div lacks "mb-1 mt-6".
80
+ */
81
+ async function countAssistantMessages(page: Page): Promise<number> {
82
+ return page.evaluate(() => {
83
+ const all = [...document.querySelectorAll("[data-test-render-count]")];
84
+ return all.filter((el) => {
85
+ const child = el.querySelector("div");
86
+ return child && !child.className.includes("mb-1");
87
+ }).length;
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Get the text of the last assistant message.
93
+ */
94
+ async function getLastAssistantText(page: Page): Promise<string> {
95
+ return page.evaluate(() => {
96
+ const all = [...document.querySelectorAll("[data-test-render-count]")];
97
+ const assistants = all.filter((el) => {
98
+ const child = el.querySelector("div");
99
+ return child && !child.className.includes("mb-1");
100
+ });
101
+ return assistants[assistants.length - 1]?.textContent?.trim() ?? "";
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Send a message and wait for a stable response via DOM-polling.
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 editor
117
+ await page.evaluate((msg: string) => {
118
+ const ed = document.querySelector(".ProseMirror") as HTMLElement | null;
119
+ if (!ed) throw new Error("Claude editor not found");
120
+ ed.focus();
121
+ document.execCommand("insertText", false, msg);
122
+ }, message);
123
+
124
+ await new Promise((r) => setTimeout(r, 300));
125
+ await page.keyboard.press("Enter");
126
+
127
+ log(`claude-browser: message sent (${message.length} chars), waiting…`);
128
+
129
+ const deadline = Date.now() + timeoutMs;
130
+ let lastText = "";
131
+ let stableCount = 0;
132
+
133
+ while (Date.now() < deadline) {
134
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
135
+
136
+ const currentCount = await countAssistantMessages(page);
137
+ if (currentCount <= countBefore) continue; // response not started yet
138
+
139
+ const text = await getLastAssistantText(page);
140
+
141
+ if (text && text === lastText) {
142
+ stableCount++;
143
+ if (stableCount >= STABLE_CHECKS) {
144
+ log(`claude-browser: response stable (${text.length} chars)`);
145
+ return text;
146
+ }
147
+ } else {
148
+ stableCount = 0;
149
+ lastText = text;
150
+ }
151
+ }
152
+
153
+ throw new Error(`claude.ai response timeout after ${timeoutMs}ms`);
154
+ }
155
+
156
+ // ─────────────────────────────────────────────────────────────────────────────
157
+
158
+ export async function claudeComplete(
159
+ context: BrowserContext,
160
+ opts: ClaudeBrowserOptions,
161
+ log: (msg: string) => void
162
+ ): Promise<ClaudeBrowserResult> {
163
+ const { page, owned } = await getOrCreateClaudePage(context);
164
+ const model = resolveModel(opts.model);
165
+ const prompt = flattenMessages(opts.messages);
166
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
167
+
168
+ log(`claude-browser: complete model=${model}`);
169
+
170
+ try {
171
+ const content = await sendAndWait(page, prompt, timeoutMs, log);
172
+ return { content, model, finishReason: "stop" };
173
+ } finally {
174
+ if (owned) await page.close().catch(() => {});
175
+ }
176
+ }
177
+
178
+ export async function claudeCompleteStream(
179
+ context: BrowserContext,
180
+ opts: ClaudeBrowserOptions,
181
+ onToken: (token: string) => void,
182
+ log: (msg: string) => void
183
+ ): Promise<ClaudeBrowserResult> {
184
+ const { page, owned } = await getOrCreateClaudePage(context);
185
+ const model = resolveModel(opts.model);
186
+ const prompt = flattenMessages(opts.messages);
187
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
188
+
189
+ log(`claude-browser: stream model=${model}`);
190
+
191
+ const countBefore = await countAssistantMessages(page);
192
+
193
+ await page.evaluate((msg: string) => {
194
+ const ed = document.querySelector(".ProseMirror") as HTMLElement | null;
195
+ if (!ed) throw new Error("Claude editor not found");
196
+ ed.focus();
197
+ document.execCommand("insertText", false, msg);
198
+ }, prompt);
199
+ await new Promise((r) => setTimeout(r, 300));
200
+ await page.keyboard.press("Enter");
201
+
202
+ const deadline = Date.now() + timeoutMs;
203
+ let emittedLength = 0;
204
+ let lastText = "";
205
+ let stableCount = 0;
206
+
207
+ while (Date.now() < deadline) {
208
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
209
+
210
+ const currentCount = await countAssistantMessages(page);
211
+ if (currentCount <= countBefore) continue;
212
+
213
+ const text = await getLastAssistantText(page);
214
+
215
+ if (text.length > emittedLength) {
216
+ onToken(text.slice(emittedLength));
217
+ emittedLength = text.length;
218
+ }
219
+
220
+ if (text && text === lastText) {
221
+ stableCount++;
222
+ if (stableCount >= STABLE_CHECKS) {
223
+ log(`claude-browser: stream done (${text.length} chars)`);
224
+ return { content: text, model, finishReason: "stop" };
225
+ }
226
+ } else {
227
+ stableCount = 0;
228
+ lastText = text;
229
+ }
230
+ }
231
+
232
+ throw new Error(`claude.ai stream timeout after ${timeoutMs}ms`);
233
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * gemini-browser.ts
3
+ *
4
+ * Gemini web automation via Playwright DOM-polling.
5
+ * Strategy identical to grok-client.ts / claude-browser.ts.
6
+ *
7
+ * DOM structure (confirmed 2026-03-11):
8
+ * Editor: .ql-editor (Quill — use page.type(), NOT execCommand)
9
+ * Response: message-content (custom element, innerText = clean response)
10
+ * Also: .markdown (same content, markdown-rendered)
11
+ */
12
+
13
+ import type { BrowserContext, Page } from "playwright";
14
+
15
+ export interface ChatMessage {
16
+ role: "system" | "user" | "assistant";
17
+ content: string;
18
+ }
19
+
20
+ export interface GeminiBrowserOptions {
21
+ messages: ChatMessage[];
22
+ model?: string;
23
+ timeoutMs?: number;
24
+ }
25
+
26
+ export interface GeminiBrowserResult {
27
+ content: string;
28
+ model: string;
29
+ finishReason: string;
30
+ }
31
+
32
+ const DEFAULT_TIMEOUT_MS = 120_000;
33
+ const STABLE_CHECKS = 3;
34
+ const STABLE_INTERVAL_MS = 600; // slightly longer — Gemini streams slower
35
+ const GEMINI_HOME = "https://gemini.google.com/app";
36
+
37
+ const MODEL_MAP: Record<string, string> = {
38
+ "gemini-2-5-pro": "gemini-2.5-pro",
39
+ "gemini-2-5-flash": "gemini-2.5-flash",
40
+ "gemini-flash": "gemini-flash",
41
+ "gemini-pro": "gemini-pro",
42
+ "gemini-3-pro": "gemini-3-pro",
43
+ "gemini-3-flash": "gemini-3-flash",
44
+ };
45
+
46
+ function resolveModel(m?: string): string {
47
+ const clean = (m ?? "gemini-2-5-pro").replace("web-gemini/", "").replace(/\./g, "-");
48
+ return MODEL_MAP[clean] ?? "gemini-2.5-pro";
49
+ }
50
+
51
+ function flattenMessages(messages: ChatMessage[]): string {
52
+ if (messages.length === 1) return messages[0].content;
53
+ return messages
54
+ .map((m) => {
55
+ if (m.role === "system") return `[System]: ${m.content}`;
56
+ if (m.role === "assistant") return `[Assistant]: ${m.content}`;
57
+ return m.content;
58
+ })
59
+ .join("\n\n");
60
+ }
61
+
62
+ /**
63
+ * Get or create a Gemini page in the given context.
64
+ */
65
+ export async function getOrCreateGeminiPage(
66
+ context: BrowserContext
67
+ ): Promise<{ page: Page; owned: boolean }> {
68
+ const existing = context.pages().filter((p) => p.url().startsWith("https://gemini.google.com"));
69
+ if (existing.length > 0) return { page: existing[0], owned: false };
70
+ const page = await context.newPage();
71
+ await page.goto(GEMINI_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
72
+ await new Promise((r) => setTimeout(r, 2_000));
73
+ return { page, owned: true };
74
+ }
75
+
76
+ /**
77
+ * Count model-response elements on the page (= number of assistant turns).
78
+ */
79
+ async function countResponses(page: Page): Promise<number> {
80
+ return page.evaluate(() => document.querySelectorAll("model-response").length);
81
+ }
82
+
83
+ /**
84
+ * Get the text of the last model-response via message-content element.
85
+ * Uses message-content (cleanest, no "Gemini hat gesagt" prefix).
86
+ */
87
+ async function getLastResponseText(page: Page): Promise<string> {
88
+ return page.evaluate(() => {
89
+ const els = [...document.querySelectorAll("message-content")];
90
+ if (!els.length) return "";
91
+ return els[els.length - 1].textContent?.trim() ?? "";
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Check if Gemini is still generating (streaming indicator present).
97
+ */
98
+ async function isStreaming(page: Page): Promise<boolean> {
99
+ return page.evaluate(() => {
100
+ // Gemini shows a stop button while streaming
101
+ const stopBtn = document.querySelector('button[aria-label*="stop"], button[aria-label*="Stop"], button[aria-label*="stopp"]');
102
+ return !!stopBtn;
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Send a message and wait for a stable response via DOM-polling.
108
+ */
109
+ async function sendAndWait(
110
+ page: Page,
111
+ message: string,
112
+ timeoutMs: number,
113
+ log: (msg: string) => void
114
+ ): Promise<string> {
115
+ const countBefore = await countResponses(page);
116
+
117
+ // Quill editor: use page.type() (not execCommand — Quill ignores it)
118
+ const editor = page.locator(".ql-editor");
119
+ await editor.click();
120
+ await editor.type(message, { delay: 10 });
121
+ await new Promise((r) => setTimeout(r, 300));
122
+ await page.keyboard.press("Enter");
123
+
124
+ log(`gemini-browser: message sent (${message.length} chars), waiting…`);
125
+
126
+ const deadline = Date.now() + timeoutMs;
127
+ let lastText = "";
128
+ let stableCount = 0;
129
+
130
+ while (Date.now() < deadline) {
131
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
132
+
133
+ // Wait for new response to appear
134
+ const currentCount = await countResponses(page);
135
+ if (currentCount <= countBefore) continue;
136
+
137
+ // Still streaming? Don't start stable-check yet
138
+ const streaming = await isStreaming(page);
139
+ if (streaming) {
140
+ stableCount = 0;
141
+ lastText = await getLastResponseText(page);
142
+ continue;
143
+ }
144
+
145
+ const text = await getLastResponseText(page);
146
+ if (!text) continue;
147
+
148
+ if (text === lastText) {
149
+ stableCount++;
150
+ if (stableCount >= STABLE_CHECKS) {
151
+ log(`gemini-browser: response stable (${text.length} chars)`);
152
+ return text;
153
+ }
154
+ } else {
155
+ stableCount = 0;
156
+ lastText = text;
157
+ }
158
+ }
159
+
160
+ throw new Error(`gemini.google.com response timeout after ${timeoutMs}ms`);
161
+ }
162
+
163
+ // ─────────────────────────────────────────────────────────────────────────────
164
+
165
+ export async function geminiComplete(
166
+ context: BrowserContext,
167
+ opts: GeminiBrowserOptions,
168
+ log: (msg: string) => void
169
+ ): Promise<GeminiBrowserResult> {
170
+ const { page, owned } = await getOrCreateGeminiPage(context);
171
+ const model = resolveModel(opts.model);
172
+ const prompt = flattenMessages(opts.messages);
173
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
174
+
175
+ log(`gemini-browser: complete model=${model}`);
176
+
177
+ try {
178
+ const content = await sendAndWait(page, prompt, timeoutMs, log);
179
+ return { content, model, finishReason: "stop" };
180
+ } finally {
181
+ if (owned) await page.close().catch(() => {});
182
+ }
183
+ }
184
+
185
+ export async function geminiCompleteStream(
186
+ context: BrowserContext,
187
+ opts: GeminiBrowserOptions,
188
+ onToken: (token: string) => void,
189
+ log: (msg: string) => void
190
+ ): Promise<GeminiBrowserResult> {
191
+ const { page, owned } = await getOrCreateGeminiPage(context);
192
+ const model = resolveModel(opts.model);
193
+ const prompt = flattenMessages(opts.messages);
194
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
195
+
196
+ log(`gemini-browser: stream model=${model}`);
197
+
198
+ const countBefore = await countResponses(page);
199
+
200
+ const editor = page.locator(".ql-editor");
201
+ await editor.click();
202
+ await editor.type(prompt, { delay: 10 });
203
+ await new Promise((r) => setTimeout(r, 300));
204
+ await page.keyboard.press("Enter");
205
+
206
+ const deadline = Date.now() + timeoutMs;
207
+ let emittedLength = 0;
208
+ let lastText = "";
209
+ let stableCount = 0;
210
+
211
+ while (Date.now() < deadline) {
212
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
213
+
214
+ const currentCount = await countResponses(page);
215
+ if (currentCount <= countBefore) continue;
216
+
217
+ const text = await getLastResponseText(page);
218
+
219
+ if (text.length > emittedLength) {
220
+ onToken(text.slice(emittedLength));
221
+ emittedLength = text.length;
222
+ }
223
+
224
+ const streaming = await isStreaming(page);
225
+ if (streaming) { stableCount = 0; continue; }
226
+
227
+ if (text && text === lastText) {
228
+ stableCount++;
229
+ if (stableCount >= STABLE_CHECKS) {
230
+ log(`gemini-browser: stream done (${text.length} chars)`);
231
+ if (owned) await page.close().catch(() => {});
232
+ return { content: text, model, finishReason: "stop" };
233
+ }
234
+ } else {
235
+ stableCount = 0;
236
+ lastText = text;
237
+ }
238
+ }
239
+
240
+ if (owned) await page.close().catch(() => {});
241
+ throw new Error(`gemini.google.com stream timeout after ${timeoutMs}ms`);
242
+ }
@@ -13,6 +13,8 @@ import { randomBytes } from "node:crypto";
13
13
  import { type ChatMessage, routeToCliRunner } from "./cli-runner.js";
14
14
  import { scheduleTokenRefresh, setAuthLogger, stopTokenRefresh } from "./claude-auth.js";
15
15
  import { grokComplete, grokCompleteStream, type ChatMessage as GrokChatMessage } from "./grok-client.js";
16
+ import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowserChatMessage } from "./claude-browser.js";
17
+ import { geminiComplete, geminiCompleteStream, type ChatMessage as GeminiBrowserChatMessage } from "./gemini-browser.js";
16
18
  import type { BrowserContext } from "playwright";
17
19
 
18
20
  export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
@@ -33,6 +35,22 @@ export interface ProxyServerOptions {
33
35
  _grokComplete?: typeof grokComplete;
34
36
  /** Override for testing — replaces grokCompleteStream */
35
37
  _grokCompleteStream?: typeof grokCompleteStream;
38
+ /** Returns the current authenticated Claude BrowserContext (null if not logged in) */
39
+ getClaudeContext?: () => BrowserContext | null;
40
+ /** Async lazy connect — called when getClaudeContext returns null */
41
+ connectClaudeContext?: () => Promise<BrowserContext | null>;
42
+ /** Override for testing — replaces claudeComplete */
43
+ _claudeComplete?: typeof claudeComplete;
44
+ /** Override for testing — replaces claudeCompleteStream */
45
+ _claudeCompleteStream?: typeof claudeCompleteStream;
46
+ /** Returns the current authenticated Gemini BrowserContext (null if not logged in) */
47
+ getGeminiContext?: () => BrowserContext | null;
48
+ /** Async lazy connect — called when getGeminiContext returns null */
49
+ connectGeminiContext?: () => Promise<BrowserContext | null>;
50
+ /** Override for testing — replaces geminiComplete */
51
+ _geminiComplete?: typeof geminiComplete;
52
+ /** Override for testing — replaces geminiCompleteStream */
53
+ _geminiCompleteStream?: typeof geminiCompleteStream;
36
54
  }
37
55
 
38
56
  /** Available CLI bridge models for GET /v1/models */
@@ -78,6 +96,15 @@ export const CLI_MODELS = [
78
96
  { id: "web-grok/grok-3-fast", name: "Grok 3 Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
79
97
  { id: "web-grok/grok-3-mini", name: "Grok 3 Mini (web session)", contextWindow: 131_072, maxTokens: 131_072 },
80
98
  { id: "web-grok/grok-3-mini-fast", name: "Grok 3 Mini Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
99
+ // Claude web-session models (requires /claude-login)
100
+ { id: "web-claude/claude-sonnet", name: "Claude Sonnet (web session)", contextWindow: 200_000, maxTokens: 8192 },
101
+ { id: "web-claude/claude-opus", name: "Claude Opus (web session)", contextWindow: 200_000, maxTokens: 8192 },
102
+ { id: "web-claude/claude-haiku", name: "Claude Haiku (web session)", contextWindow: 200_000, maxTokens: 8192 },
103
+ // Gemini web-session models (requires /gemini-login)
104
+ { id: "web-gemini/gemini-2-5-pro", name: "Gemini 2.5 Pro (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
105
+ { id: "web-gemini/gemini-2-5-flash", name: "Gemini 2.5 Flash (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
106
+ { id: "web-gemini/gemini-3-pro", name: "Gemini 3 Pro (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
107
+ { id: "web-gemini/gemini-3-flash", name: "Gemini 3 Flash (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
81
108
  ];
82
109
 
83
110
  // ──────────────────────────────────────────────────────────────────────────────
@@ -249,6 +276,104 @@ async function handleRequest(
249
276
  }
250
277
  // ─────────────────────────────────────────────────────────────────────────
251
278
 
279
+ // ── Claude web-session routing ────────────────────────────────────────────
280
+ if (model.startsWith("web-claude/")) {
281
+ let claudeCtx = opts.getClaudeContext?.() ?? null;
282
+ if (!claudeCtx && opts.connectClaudeContext) {
283
+ claudeCtx = await opts.connectClaudeContext();
284
+ }
285
+ if (!claudeCtx) {
286
+ res.writeHead(503, { "Content-Type": "application/json" });
287
+ res.end(JSON.stringify({ error: { message: "No active claude.ai session. Use /claude-login to authenticate.", code: "no_claude_session" } }));
288
+ return;
289
+ }
290
+ const timeoutMs = opts.timeoutMs ?? 120_000;
291
+ const claudeMessages = messages as ClaudeBrowserChatMessage[];
292
+ const doClaudeComplete = opts._claudeComplete ?? claudeComplete;
293
+ const doClaudeCompleteStream = opts._claudeCompleteStream ?? claudeCompleteStream;
294
+ try {
295
+ if (stream) {
296
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
297
+ sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
298
+ const result = await doClaudeCompleteStream(
299
+ claudeCtx,
300
+ { messages: claudeMessages, model, timeoutMs },
301
+ (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
302
+ opts.log
303
+ );
304
+ sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
305
+ res.write("data: [DONE]\n\n");
306
+ res.end();
307
+ } else {
308
+ const result = await doClaudeComplete(claudeCtx, { messages: claudeMessages, model, timeoutMs }, opts.log);
309
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
310
+ res.end(JSON.stringify({
311
+ id, object: "chat.completion", created, model,
312
+ choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
313
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
314
+ }));
315
+ }
316
+ } catch (err) {
317
+ const msg = (err as Error).message;
318
+ opts.warn(`[cli-bridge] Claude browser error for ${model}: ${msg}`);
319
+ if (!res.headersSent) {
320
+ res.writeHead(500, { "Content-Type": "application/json" });
321
+ res.end(JSON.stringify({ error: { message: msg, type: "claude_browser_error" } }));
322
+ }
323
+ }
324
+ return;
325
+ }
326
+ // ─────────────────────────────────────────────────────────────────────────
327
+
328
+ // ── Gemini web-session routing ────────────────────────────────────────────
329
+ if (model.startsWith("web-gemini/")) {
330
+ let geminiCtx = opts.getGeminiContext?.() ?? null;
331
+ if (!geminiCtx && opts.connectGeminiContext) {
332
+ geminiCtx = await opts.connectGeminiContext();
333
+ }
334
+ if (!geminiCtx) {
335
+ res.writeHead(503, { "Content-Type": "application/json" });
336
+ res.end(JSON.stringify({ error: { message: "No active gemini.google.com session. Use /gemini-login to authenticate.", code: "no_gemini_session" } }));
337
+ return;
338
+ }
339
+ const timeoutMs = opts.timeoutMs ?? 120_000;
340
+ const geminiMessages = messages as GeminiBrowserChatMessage[];
341
+ const doGeminiComplete = opts._geminiComplete ?? geminiComplete;
342
+ const doGeminiCompleteStream = opts._geminiCompleteStream ?? geminiCompleteStream;
343
+ try {
344
+ if (stream) {
345
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
346
+ sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
347
+ const result = await doGeminiCompleteStream(
348
+ geminiCtx,
349
+ { messages: geminiMessages, model, timeoutMs },
350
+ (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
351
+ opts.log
352
+ );
353
+ sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
354
+ res.write("data: [DONE]\n\n");
355
+ res.end();
356
+ } else {
357
+ const result = await doGeminiComplete(geminiCtx, { messages: geminiMessages, model, timeoutMs }, opts.log);
358
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
359
+ res.end(JSON.stringify({
360
+ id, object: "chat.completion", created, model,
361
+ choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
362
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
363
+ }));
364
+ }
365
+ } catch (err) {
366
+ const msg = (err as Error).message;
367
+ opts.warn(`[cli-bridge] Gemini browser error for ${model}: ${msg}`);
368
+ if (!res.headersSent) {
369
+ res.writeHead(500, { "Content-Type": "application/json" });
370
+ res.end(JSON.stringify({ error: { message: msg, type: "gemini_browser_error" } }));
371
+ }
372
+ }
373
+ return;
374
+ }
375
+ // ─────────────────────────────────────────────────────────────────────────
376
+
252
377
  // ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
253
378
  let content: string;
254
379
  try {