@elvatis_com/openclaw-cli-bridge-elvatis 1.5.1 → 1.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "1.5.1",
3
+ "version": "1.6.2",
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": {
@@ -14,6 +14,8 @@ 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
16
  import { geminiComplete, geminiCompleteStream, type ChatMessage as GeminiBrowserChatMessage } from "./gemini-browser.js";
17
+ import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowserChatMessage } from "./claude-browser.js";
18
+ import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrowserChatMessage } from "./chatgpt-browser.js";
17
19
  import type { BrowserContext } from "playwright";
18
20
 
19
21
  export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
@@ -42,6 +44,22 @@ export interface ProxyServerOptions {
42
44
  _geminiComplete?: typeof geminiComplete;
43
45
  /** Override for testing — replaces geminiCompleteStream */
44
46
  _geminiCompleteStream?: typeof geminiCompleteStream;
47
+ /** Returns the current authenticated Claude BrowserContext (null if not logged in) */
48
+ getClaudeContext?: () => BrowserContext | null;
49
+ /** Async lazy connect — called when getClaudeContext returns null */
50
+ connectClaudeContext?: () => Promise<BrowserContext | null>;
51
+ /** Override for testing — replaces claudeComplete */
52
+ _claudeComplete?: typeof claudeComplete;
53
+ /** Override for testing — replaces claudeCompleteStream */
54
+ _claudeCompleteStream?: typeof claudeCompleteStream;
55
+ /** Returns the current authenticated ChatGPT BrowserContext (null if not logged in) */
56
+ getChatGPTContext?: () => BrowserContext | null;
57
+ /** Async lazy connect — called when getChatGPTContext returns null */
58
+ connectChatGPTContext?: () => Promise<BrowserContext | null>;
59
+ /** Override for testing — replaces chatgptComplete */
60
+ _chatgptComplete?: typeof chatgptComplete;
61
+ /** Override for testing — replaces chatgptCompleteStream */
62
+ _chatgptCompleteStream?: typeof chatgptCompleteStream;
45
63
  }
46
64
 
47
65
  /** Available CLI bridge models for GET /v1/models */
@@ -64,6 +82,16 @@ export const CLI_MODELS = [
64
82
  { id: "web-gemini/gemini-2-5-flash", name: "Gemini 2.5 Flash (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
65
83
  { id: "web-gemini/gemini-3-pro", name: "Gemini 3 Pro (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
66
84
  { id: "web-gemini/gemini-3-flash", name: "Gemini 3 Flash (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
85
+ // Claude web-session models (requires /claude-login)
86
+ { id: "web-claude/claude-sonnet", name: "Claude Sonnet (web session)", contextWindow: 200_000, maxTokens: 8192 },
87
+ { id: "web-claude/claude-opus", name: "Claude Opus (web session)", contextWindow: 200_000, maxTokens: 8192 },
88
+ { id: "web-claude/claude-haiku", name: "Claude Haiku (web session)", contextWindow: 200_000, maxTokens: 8192 },
89
+ // ChatGPT web-session models (requires /chatgpt-login)
90
+ { id: "web-chatgpt/gpt-4o", name: "GPT-4o (web session)", contextWindow: 128_000, maxTokens: 8192 },
91
+ { id: "web-chatgpt/gpt-4o-mini", name: "GPT-4o Mini (web session)", contextWindow: 128_000, maxTokens: 8192 },
92
+ { id: "web-chatgpt/gpt-o3", name: "GPT o3 (web session)", contextWindow: 200_000, maxTokens: 8192 },
93
+ { id: "web-chatgpt/gpt-o4-mini", name: "GPT o4-mini (web session)", contextWindow: 200_000, maxTokens: 8192 },
94
+ { id: "web-chatgpt/gpt-5", name: "GPT-5 (web session)", contextWindow: 200_000, maxTokens: 8192 },
67
95
  ];
68
96
 
69
97
  // ──────────────────────────────────────────────────────────────────────────────
@@ -284,6 +312,105 @@ async function handleRequest(
284
312
  }
285
313
  // ─────────────────────────────────────────────────────────────────────────
286
314
 
315
+ // ── Claude web-session routing ────────────────────────────────────────────
316
+ if (model.startsWith("web-claude/")) {
317
+ let claudeCtx = opts.getClaudeContext?.() ?? null;
318
+ if (!claudeCtx && opts.connectClaudeContext) {
319
+ claudeCtx = await opts.connectClaudeContext();
320
+ }
321
+ if (!claudeCtx) {
322
+ res.writeHead(503, { "Content-Type": "application/json" });
323
+ res.end(JSON.stringify({ error: { message: "No active claude.ai session. Use /claude-login to authenticate.", code: "no_claude_session" } }));
324
+ return;
325
+ }
326
+ const timeoutMs = opts.timeoutMs ?? 120_000;
327
+ const claudeMessages = messages as ClaudeBrowserChatMessage[];
328
+ const doClaudeComplete = opts._claudeComplete ?? claudeComplete;
329
+ const doClaudeCompleteStream = opts._claudeCompleteStream ?? claudeCompleteStream;
330
+ try {
331
+ if (stream) {
332
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
333
+ sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
334
+ const result = await doClaudeCompleteStream(
335
+ claudeCtx,
336
+ { messages: claudeMessages, model, timeoutMs },
337
+ (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
338
+ opts.log
339
+ );
340
+ sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
341
+ res.write("data: [DONE]\n\n");
342
+ res.end();
343
+ } else {
344
+ const result = await doClaudeComplete(claudeCtx, { messages: claudeMessages, model, timeoutMs }, opts.log);
345
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
346
+ res.end(JSON.stringify({
347
+ id, object: "chat.completion", created, model,
348
+ choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
349
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
350
+ }));
351
+ }
352
+ } catch (err) {
353
+ const msg = (err as Error).message;
354
+ opts.warn(`[cli-bridge] Claude browser error for ${model}: ${msg}`);
355
+ if (!res.headersSent) {
356
+ res.writeHead(500, { "Content-Type": "application/json" });
357
+ res.end(JSON.stringify({ error: { message: msg, type: "claude_browser_error" } }));
358
+ }
359
+ }
360
+ return;
361
+ }
362
+ // ─────────────────────────────────────────────────────────────────────────
363
+
364
+ // ── ChatGPT web-session routing ──────────────────────────────────────────
365
+ if (model.startsWith("web-chatgpt/")) {
366
+ let chatgptCtx = opts.getChatGPTContext?.() ?? null;
367
+ if (!chatgptCtx && opts.connectChatGPTContext) {
368
+ chatgptCtx = await opts.connectChatGPTContext();
369
+ }
370
+ if (!chatgptCtx) {
371
+ res.writeHead(503, { "Content-Type": "application/json" });
372
+ res.end(JSON.stringify({ error: { message: "No active chatgpt.com session. Use /chatgpt-login to authenticate.", code: "no_chatgpt_session" } }));
373
+ return;
374
+ }
375
+ const chatgptModel = model.replace("web-chatgpt/", "");
376
+ const timeoutMs = opts.timeoutMs ?? 120_000;
377
+ const chatgptMessages = messages as ChatGPTBrowserChatMessage[];
378
+ const doChatGPTComplete = opts._chatgptComplete ?? chatgptComplete;
379
+ const doChatGPTCompleteStream = opts._chatgptCompleteStream ?? chatgptCompleteStream;
380
+ try {
381
+ if (stream) {
382
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
383
+ sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
384
+ const result = await doChatGPTCompleteStream(
385
+ chatgptCtx,
386
+ { messages: chatgptMessages, model: chatgptModel, timeoutMs },
387
+ (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
388
+ opts.log
389
+ );
390
+ sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
391
+ res.write("data: [DONE]\n\n");
392
+ res.end();
393
+ } else {
394
+ const result = await doChatGPTComplete(chatgptCtx, { messages: chatgptMessages, model: chatgptModel, timeoutMs }, opts.log);
395
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
396
+ res.end(JSON.stringify({
397
+ id, object: "chat.completion", created, model,
398
+ choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
399
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
400
+ }));
401
+ }
402
+ } catch (err) {
403
+ const msg = (err as Error).message;
404
+ opts.warn(`[cli-bridge] ChatGPT browser error for ${model}: ${msg}`);
405
+ if (!res.headersSent) {
406
+ res.writeHead(500, { "Content-Type": "application/json" });
407
+ res.end(JSON.stringify({ error: { message: msg, type: "chatgpt_browser_error" } }));
408
+ }
409
+ }
410
+ return;
411
+ }
412
+ // ─────────────────────────────────────────────────────────────────────────
413
+
287
414
  // ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
288
415
  let content: string;
289
416
  try {
@@ -0,0 +1,153 @@
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 ChatGPTCompleteOptions = { messages: { role: string; content: string }[]; model?: string; timeoutMs?: number };
15
+ type ChatGPTCompleteResult = { content: string; model: string; finishReason: string };
16
+
17
+ const stubChatGPTComplete = vi.fn(async (
18
+ _ctx: BrowserContext,
19
+ opts: ChatGPTCompleteOptions,
20
+ _log: (msg: string) => void
21
+ ): Promise<ChatGPTCompleteResult> => ({
22
+ content: `chatgpt mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
23
+ model: opts.model ?? "gpt-4o",
24
+ finishReason: "stop",
25
+ }));
26
+
27
+ const stubChatGPTCompleteStream = vi.fn(async (
28
+ _ctx: BrowserContext,
29
+ opts: ChatGPTCompleteOptions,
30
+ onToken: (t: string) => void,
31
+ _log: (msg: string) => void
32
+ ): Promise<ChatGPTCompleteResult> => {
33
+ const tokens = ["chatgpt ", "stream ", "mock"];
34
+ for (const t of tokens) onToken(t);
35
+ return { content: tokens.join(""), model: opts.model ?? "gpt-4o", finishReason: "stop" };
36
+ });
37
+
38
+ async function httpPost(url: string, body: unknown): Promise<{ status: number; body: unknown }> {
39
+ return new Promise((resolve, reject) => {
40
+ const data = JSON.stringify(body);
41
+ const u = new URL(url);
42
+ const req = http.request(
43
+ { hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
44
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } },
45
+ (res) => { let raw = ""; res.on("data", c => raw += c); res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, body: raw }); } }); }
46
+ );
47
+ req.on("error", reject); req.write(data); req.end();
48
+ });
49
+ }
50
+ async function httpGet(url: string): Promise<{ status: number; body: unknown }> {
51
+ return new Promise((resolve, reject) => {
52
+ const u = new URL(url);
53
+ const req = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "GET" },
54
+ (res) => { let raw = ""; res.on("data", c => raw += c); res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, body: raw }); } }); }
55
+ );
56
+ req.on("error", reject); req.end();
57
+ });
58
+ }
59
+
60
+ const fakeCtx = {} as BrowserContext;
61
+ let server: http.Server;
62
+ let baseUrl: string;
63
+
64
+ beforeAll(async () => {
65
+ server = await startProxyServer({
66
+ port: 0, log: () => {}, warn: () => {},
67
+ getChatGPTContext: () => fakeCtx,
68
+ // @ts-expect-error — stub types close enough for testing
69
+ _chatgptComplete: stubChatGPTComplete,
70
+ // @ts-expect-error — stub types close enough for testing
71
+ _chatgptCompleteStream: stubChatGPTCompleteStream,
72
+ });
73
+ baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
74
+ });
75
+ afterAll(() => server.close());
76
+
77
+ describe("ChatGPT web-session routing — model list", () => {
78
+ it("includes web-chatgpt/* models in /v1/models", async () => {
79
+ const res = await httpGet(`${baseUrl}/v1/models`);
80
+ expect(res.status).toBe(200);
81
+ const ids = (res.body as { data: { id: string }[] }).data.map(m => m.id);
82
+ expect(ids).toContain("web-chatgpt/gpt-4o");
83
+ expect(ids).toContain("web-chatgpt/gpt-4o-mini");
84
+ expect(ids).toContain("web-chatgpt/gpt-o3");
85
+ expect(ids).toContain("web-chatgpt/gpt-o4-mini");
86
+ expect(ids).toContain("web-chatgpt/gpt-5");
87
+ });
88
+
89
+ it("web-chatgpt/* models listed in CLI_MODELS constant", () => {
90
+ const chatgpt = CLI_MODELS.filter(m => m.id.startsWith("web-chatgpt/"));
91
+ expect(chatgpt).toHaveLength(5);
92
+ });
93
+ });
94
+
95
+ describe("ChatGPT web-session routing — non-streaming", () => {
96
+ it("returns assistant message for web-chatgpt/gpt-4o", async () => {
97
+ const res = await httpPost(`${baseUrl}/v1/chat/completions`, {
98
+ model: "web-chatgpt/gpt-4o",
99
+ messages: [{ role: "user", content: "hello chatgpt" }],
100
+ stream: false,
101
+ });
102
+ expect(res.status).toBe(200);
103
+ const body = res.body as { choices: { message: { content: string } }[] };
104
+ expect(body.choices[0].message.content).toContain("chatgpt mock");
105
+ expect(body.choices[0].message.content).toContain("hello chatgpt");
106
+ });
107
+
108
+ it("strips web-chatgpt/ prefix before passing to chatgptComplete", async () => {
109
+ stubChatGPTComplete.mockClear();
110
+ await httpPost(`${baseUrl}/v1/chat/completions`, {
111
+ model: "web-chatgpt/gpt-o3",
112
+ messages: [{ role: "user", content: "test" }],
113
+ });
114
+ expect(stubChatGPTComplete).toHaveBeenCalledOnce();
115
+ expect(stubChatGPTComplete.mock.calls[0][1].model).toBe("gpt-o3");
116
+ });
117
+
118
+ it("response model preserves web-chatgpt/ prefix", async () => {
119
+ const res = await httpPost(`${baseUrl}/v1/chat/completions`, {
120
+ model: "web-chatgpt/gpt-5",
121
+ messages: [{ role: "user", content: "hi" }],
122
+ });
123
+ expect(res.status).toBe(200);
124
+ const body = res.body as { model: string };
125
+ expect(body.model).toBe("web-chatgpt/gpt-5");
126
+ });
127
+
128
+ it("returns 503 when no chatgpt context", async () => {
129
+ const s = await startProxyServer({ port: 0, log: () => {}, warn: () => {}, getChatGPTContext: () => null });
130
+ const url = `http://127.0.0.1:${(s.address() as AddressInfo).port}`;
131
+ const res = await httpPost(`${url}/v1/chat/completions`, {
132
+ model: "web-chatgpt/gpt-4o",
133
+ messages: [{ role: "user", content: "hi" }],
134
+ });
135
+ expect(res.status).toBe(503);
136
+ expect((res.body as { error: { code: string } }).error.code).toBe("no_chatgpt_session");
137
+ s.close();
138
+ });
139
+ });
140
+
141
+ describe("ChatGPT web-session routing — streaming", () => {
142
+ it("returns SSE stream", async () => {
143
+ return new Promise<void>((resolve, reject) => {
144
+ const body = JSON.stringify({ model: "web-chatgpt/gpt-4o", messages: [{ role: "user", content: "stream" }], stream: true });
145
+ const u = new URL(`${baseUrl}/v1/chat/completions`);
146
+ const req = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
147
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
148
+ (res) => { expect(res.statusCode).toBe(200); let raw = ""; res.on("data", c => raw += c); res.on("end", () => { expect(raw).toContain("[DONE]"); resolve(); }); }
149
+ );
150
+ req.on("error", reject); req.write(body); req.end();
151
+ });
152
+ });
153
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * test/claude-proxy.test.ts
3
+ *
4
+ * Tests for Claude web-session routing in the cli-bridge proxy.
5
+ * Uses _claudeComplete/_claudeCompleteStream 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 ClaudeCompleteOptions = { messages: { role: string; content: string }[]; model?: string; timeoutMs?: number };
15
+ type ClaudeCompleteResult = { content: string; model: string; finishReason: string };
16
+
17
+ const stubClaudeComplete = vi.fn(async (
18
+ _ctx: BrowserContext,
19
+ opts: ClaudeCompleteOptions,
20
+ _log: (msg: string) => void
21
+ ): Promise<ClaudeCompleteResult> => ({
22
+ content: `claude mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
23
+ model: opts.model ?? "web-claude/claude-sonnet",
24
+ finishReason: "stop",
25
+ }));
26
+
27
+ const stubClaudeCompleteStream = vi.fn(async (
28
+ _ctx: BrowserContext,
29
+ opts: ClaudeCompleteOptions,
30
+ onToken: (t: string) => void,
31
+ _log: (msg: string) => void
32
+ ): Promise<ClaudeCompleteResult> => {
33
+ const tokens = ["claude ", "stream ", "mock"];
34
+ for (const t of tokens) onToken(t);
35
+ return { content: tokens.join(""), model: opts.model ?? "web-claude/claude-sonnet", finishReason: "stop" };
36
+ });
37
+
38
+ async function httpPost(url: string, body: unknown): Promise<{ status: number; body: unknown }> {
39
+ return new Promise((resolve, reject) => {
40
+ const data = JSON.stringify(body);
41
+ const u = new URL(url);
42
+ const req = http.request(
43
+ { hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
44
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } },
45
+ (res) => { let raw = ""; res.on("data", c => raw += c); res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, body: raw }); } }); }
46
+ );
47
+ req.on("error", reject); req.write(data); req.end();
48
+ });
49
+ }
50
+ async function httpGet(url: string): Promise<{ status: number; body: unknown }> {
51
+ return new Promise((resolve, reject) => {
52
+ const u = new URL(url);
53
+ const req = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "GET" },
54
+ (res) => { let raw = ""; res.on("data", c => raw += c); res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, body: raw }); } }); }
55
+ );
56
+ req.on("error", reject); req.end();
57
+ });
58
+ }
59
+
60
+ const fakeCtx = {} as BrowserContext;
61
+ let server: http.Server;
62
+ let baseUrl: string;
63
+
64
+ beforeAll(async () => {
65
+ server = await startProxyServer({
66
+ port: 0, log: () => {}, warn: () => {},
67
+ getClaudeContext: () => fakeCtx,
68
+ // @ts-expect-error — stub types close enough for testing
69
+ _claudeComplete: stubClaudeComplete,
70
+ // @ts-expect-error — stub types close enough for testing
71
+ _claudeCompleteStream: stubClaudeCompleteStream,
72
+ });
73
+ baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
74
+ });
75
+ afterAll(() => server.close());
76
+
77
+ describe("Claude web-session routing — model list", () => {
78
+ it("includes web-claude/* models in /v1/models", async () => {
79
+ const res = await httpGet(`${baseUrl}/v1/models`);
80
+ expect(res.status).toBe(200);
81
+ const ids = (res.body as { data: { id: string }[] }).data.map(m => m.id);
82
+ expect(ids).toContain("web-claude/claude-sonnet");
83
+ expect(ids).toContain("web-claude/claude-opus");
84
+ expect(ids).toContain("web-claude/claude-haiku");
85
+ });
86
+
87
+ it("web-claude/* models listed in CLI_MODELS constant", () => {
88
+ expect(CLI_MODELS.some(m => m.id.startsWith("web-claude/"))).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe("Claude web-session routing — non-streaming", () => {
93
+ it("returns assistant message for web-claude/claude-sonnet", async () => {
94
+ const res = await httpPost(`${baseUrl}/v1/chat/completions`, {
95
+ model: "web-claude/claude-sonnet",
96
+ messages: [{ role: "user", content: "hello claude" }],
97
+ stream: false,
98
+ });
99
+ expect(res.status).toBe(200);
100
+ const body = res.body as { choices: { message: { content: string } }[] };
101
+ expect(body.choices[0].message.content).toContain("claude mock");
102
+ expect(body.choices[0].message.content).toContain("hello claude");
103
+ });
104
+
105
+ it("passes model to stub unchanged", async () => {
106
+ stubClaudeComplete.mockClear();
107
+ await httpPost(`${baseUrl}/v1/chat/completions`, {
108
+ model: "web-claude/claude-opus",
109
+ messages: [{ role: "user", content: "test" }],
110
+ });
111
+ expect(stubClaudeComplete).toHaveBeenCalledOnce();
112
+ expect(stubClaudeComplete.mock.calls[0][1].model).toBe("web-claude/claude-opus");
113
+ });
114
+
115
+ it("returns 503 when no claude context", async () => {
116
+ const s = await startProxyServer({ port: 0, log: () => {}, warn: () => {}, getClaudeContext: () => null });
117
+ const url = `http://127.0.0.1:${(s.address() as AddressInfo).port}`;
118
+ const res = await httpPost(`${url}/v1/chat/completions`, {
119
+ model: "web-claude/claude-sonnet",
120
+ messages: [{ role: "user", content: "hi" }],
121
+ });
122
+ expect(res.status).toBe(503);
123
+ expect((res.body as { error: { code: string } }).error.code).toBe("no_claude_session");
124
+ s.close();
125
+ });
126
+ });
127
+
128
+ describe("Claude web-session routing — streaming", () => {
129
+ it("returns SSE stream", async () => {
130
+ return new Promise<void>((resolve, reject) => {
131
+ const body = JSON.stringify({ model: "web-claude/claude-sonnet", messages: [{ role: "user", content: "stream" }], stream: true });
132
+ const u = new URL(`${baseUrl}/v1/chat/completions`);
133
+ const req = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
134
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
135
+ (res) => { expect(res.statusCode).toBe(200); let raw = ""; res.on("data", c => raw += c); res.on("end", () => { expect(raw).toContain("[DONE]"); resolve(); }); }
136
+ );
137
+ req.on("error", reject); req.write(body); req.end();
138
+ });
139
+ });
140
+ });