@elvatis_com/openclaw-cli-bridge-elvatis 1.3.4 → 1.5.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "1.3.4",
4
+ "version": "1.5.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": "1.3.4",
3
+ "version": "1.5.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": {
@@ -42,6 +42,8 @@ const MODEL_MAP: Record<string, string> = {
42
42
  "claude-sonnet-4-5": "claude-sonnet-4-5",
43
43
  "claude-sonnet-4-6": "claude-sonnet-4-6",
44
44
  "claude-opus-4-5": "claude-opus-4-5",
45
+ "claude-opus-4-6": "claude-opus-4-6",
46
+ "claude-haiku-4-5": "claude-haiku-4-5",
45
47
  };
46
48
 
47
49
  function resolveModel(m?: string): string {
@@ -13,9 +13,7 @@ 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
16
  import { geminiComplete, geminiCompleteStream, type ChatMessage as GeminiBrowserChatMessage } from "./gemini-browser.js";
18
- import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrowserChatMessage } from "./chatgpt-browser.js";
19
17
  import type { BrowserContext } from "playwright";
20
18
 
21
19
  export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
@@ -36,14 +34,6 @@ export interface ProxyServerOptions {
36
34
  _grokComplete?: typeof grokComplete;
37
35
  /** Override for testing — replaces grokCompleteStream */
38
36
  _grokCompleteStream?: typeof grokCompleteStream;
39
- /** Returns the current authenticated Claude BrowserContext (null if not logged in) */
40
- getClaudeContext?: () => BrowserContext | null;
41
- /** Async lazy connect — called when getClaudeContext returns null */
42
- connectClaudeContext?: () => Promise<BrowserContext | null>;
43
- /** Override for testing — replaces claudeComplete */
44
- _claudeComplete?: typeof claudeComplete;
45
- /** Override for testing — replaces claudeCompleteStream */
46
- _claudeCompleteStream?: typeof claudeCompleteStream;
47
37
  /** Returns the current authenticated Gemini BrowserContext (null if not logged in) */
48
38
  getGeminiContext?: () => BrowserContext | null;
49
39
  /** Async lazy connect — called when getGeminiContext returns null */
@@ -52,14 +42,6 @@ export interface ProxyServerOptions {
52
42
  _geminiComplete?: typeof geminiComplete;
53
43
  /** Override for testing — replaces geminiCompleteStream */
54
44
  _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;
63
45
  }
64
46
 
65
47
  /** Available CLI bridge models for GET /v1/models */
@@ -77,21 +59,11 @@ export const CLI_MODELS = [
77
59
  { id: "web-grok/grok-3-fast", name: "Grok 3 Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
78
60
  { id: "web-grok/grok-3-mini", name: "Grok 3 Mini (web session)", contextWindow: 131_072, maxTokens: 131_072 },
79
61
  { id: "web-grok/grok-3-mini-fast", name: "Grok 3 Mini Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
80
- // Claude web-session models (requires /claude-login)
81
- { id: "web-claude/claude-sonnet", name: "Claude Sonnet (web session)", contextWindow: 200_000, maxTokens: 8192 },
82
- { id: "web-claude/claude-opus", name: "Claude Opus (web session)", contextWindow: 200_000, maxTokens: 8192 },
83
- { id: "web-claude/claude-haiku", name: "Claude Haiku (web session)", contextWindow: 200_000, maxTokens: 8192 },
84
62
  // Gemini web-session models (requires /gemini-login)
85
63
  { id: "web-gemini/gemini-2-5-pro", name: "Gemini 2.5 Pro (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
86
64
  { id: "web-gemini/gemini-2-5-flash", name: "Gemini 2.5 Flash (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
87
65
  { id: "web-gemini/gemini-3-pro", name: "Gemini 3 Pro (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
88
66
  { id: "web-gemini/gemini-3-flash", name: "Gemini 3 Flash (web session)", contextWindow: 1_000_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: 16_384 },
91
- { id: "web-chatgpt/gpt-4o-mini", name: "GPT-4o Mini (web session)", contextWindow: 128_000, maxTokens: 16_384 },
92
- { id: "web-chatgpt/gpt-o3", name: "o3 (web session)", contextWindow: 200_000, maxTokens: 100_000 },
93
- { id: "web-chatgpt/gpt-o4-mini", name: "o4-mini (web session)", contextWindow: 200_000, maxTokens: 100_000 },
94
- { id: "web-chatgpt/gpt-5", name: "GPT-5 (web session)", contextWindow: 1_000_000, maxTokens: 32_768 },
95
67
  ];
96
68
 
97
69
  // ──────────────────────────────────────────────────────────────────────────────
@@ -263,55 +235,6 @@ async function handleRequest(
263
235
  }
264
236
  // ─────────────────────────────────────────────────────────────────────────
265
237
 
266
- // ── Claude web-session routing ────────────────────────────────────────────
267
- if (model.startsWith("web-claude/")) {
268
- let claudeCtx = opts.getClaudeContext?.() ?? null;
269
- if (!claudeCtx && opts.connectClaudeContext) {
270
- claudeCtx = await opts.connectClaudeContext();
271
- }
272
- if (!claudeCtx) {
273
- res.writeHead(503, { "Content-Type": "application/json" });
274
- res.end(JSON.stringify({ error: { message: "No active claude.ai session. Use /claude-login to authenticate.", code: "no_claude_session" } }));
275
- return;
276
- }
277
- const timeoutMs = opts.timeoutMs ?? 120_000;
278
- const claudeMessages = messages as ClaudeBrowserChatMessage[];
279
- const doClaudeComplete = opts._claudeComplete ?? claudeComplete;
280
- const doClaudeCompleteStream = opts._claudeCompleteStream ?? claudeCompleteStream;
281
- try {
282
- if (stream) {
283
- res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
284
- sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
285
- const result = await doClaudeCompleteStream(
286
- claudeCtx,
287
- { messages: claudeMessages, model, timeoutMs },
288
- (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
289
- opts.log
290
- );
291
- sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
292
- res.write("data: [DONE]\n\n");
293
- res.end();
294
- } else {
295
- const result = await doClaudeComplete(claudeCtx, { messages: claudeMessages, model, timeoutMs }, opts.log);
296
- res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
297
- res.end(JSON.stringify({
298
- id, object: "chat.completion", created, model,
299
- choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
300
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
301
- }));
302
- }
303
- } catch (err) {
304
- const msg = (err as Error).message;
305
- opts.warn(`[cli-bridge] Claude browser error for ${model}: ${msg}`);
306
- if (!res.headersSent) {
307
- res.writeHead(500, { "Content-Type": "application/json" });
308
- res.end(JSON.stringify({ error: { message: msg, type: "claude_browser_error" } }));
309
- }
310
- }
311
- return;
312
- }
313
- // ─────────────────────────────────────────────────────────────────────────
314
-
315
238
  // ── Gemini web-session routing ────────────────────────────────────────────
316
239
  if (model.startsWith("web-gemini/")) {
317
240
  let geminiCtx = opts.getGeminiContext?.() ?? null;
@@ -361,43 +284,6 @@ async function handleRequest(
361
284
  }
362
285
  // ─────────────────────────────────────────────────────────────────────────
363
286
 
364
- // ── ChatGPT web-session routing ───────────────────────────────────────────
365
- if (model.startsWith("web-chatgpt/")) {
366
- let chatgptCtx = opts.getChatGPTContext?.() ?? null;
367
- if (!chatgptCtx && opts.connectChatGPTContext) chatgptCtx = await opts.connectChatGPTContext();
368
- if (!chatgptCtx) {
369
- res.writeHead(503, { "Content-Type": "application/json" });
370
- res.end(JSON.stringify({ error: { message: "No active chatgpt.com session. Use /chatgpt-login to authenticate.", code: "no_chatgpt_session" } }));
371
- return;
372
- }
373
- const timeoutMs = opts.timeoutMs ?? 120_000;
374
- const msgs = messages as ChatGPTBrowserChatMessage[];
375
- const doComplete = opts._chatgptComplete ?? chatgptComplete;
376
- const doStream = opts._chatgptCompleteStream ?? chatgptCompleteStream;
377
- try {
378
- if (stream) {
379
- res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
380
- sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
381
- const result = await doStream(chatgptCtx, { messages: msgs, model, timeoutMs },
382
- (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }), opts.log);
383
- sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
384
- res.write("data: [DONE]\n\n"); res.end();
385
- } else {
386
- const result = await doComplete(chatgptCtx, { messages: msgs, model, timeoutMs }, opts.log);
387
- res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
388
- res.end(JSON.stringify({ id, object: "chat.completion", created, model,
389
- choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
390
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } }));
391
- }
392
- } catch (err) {
393
- const msg = (err as Error).message;
394
- opts.warn(`[cli-bridge] ChatGPT browser error for ${model}: ${msg}`);
395
- if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: { message: msg, type: "chatgpt_browser_error" } })); }
396
- }
397
- return;
398
- }
399
- // ─────────────────────────────────────────────────────────────────────────
400
-
401
287
  // ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
402
288
  let content: string;
403
289
  try {
@@ -1,107 +0,0 @@
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
- });
@@ -1,235 +0,0 @@
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
- // ── Stub types matching claude-browser exports ────────────────────────────────
15
- type ClaudeCompleteOptions = {
16
- messages: { role: string; content: string }[];
17
- model?: string;
18
- timeoutMs?: number;
19
- };
20
- type ClaudeCompleteResult = { content: string; model: string; finishReason: string };
21
-
22
- // ── Stubs ─────────────────────────────────────────────────────────────────────
23
- const stubClaudeComplete = vi.fn(async (
24
- _ctx: BrowserContext,
25
- opts: ClaudeCompleteOptions,
26
- _log: (msg: string) => void
27
- ): Promise<ClaudeCompleteResult> => ({
28
- content: `claude mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
29
- model: opts.model ?? "web-claude/claude-sonnet",
30
- finishReason: "stop",
31
- }));
32
-
33
- const stubClaudeCompleteStream = vi.fn(async (
34
- _ctx: BrowserContext,
35
- opts: ClaudeCompleteOptions,
36
- onToken: (t: string) => void,
37
- _log: (msg: string) => void
38
- ): Promise<ClaudeCompleteResult> => {
39
- const tokens = ["claude ", "stream ", "mock"];
40
- for (const t of tokens) onToken(t);
41
- return { content: tokens.join(""), model: opts.model ?? "web-claude/claude-sonnet", finishReason: "stop" };
42
- });
43
-
44
- // ── HTTP helper ───────────────────────────────────────────────────────────────
45
- async function httpPost(
46
- url: string,
47
- body: unknown,
48
- headers: Record<string, string> = {}
49
- ): Promise<{ status: number; body: unknown }> {
50
- return new Promise((resolve, reject) => {
51
- const data = JSON.stringify(body);
52
- const urlObj = new URL(url);
53
- const req = http.request(
54
- { hostname: urlObj.hostname, port: Number(urlObj.port), path: urlObj.pathname, method: "POST",
55
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data), ...headers } },
56
- (res) => {
57
- let raw = "";
58
- res.on("data", (c) => (raw += c));
59
- res.on("end", () => {
60
- try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); }
61
- catch { resolve({ status: res.statusCode ?? 0, body: raw }); }
62
- });
63
- }
64
- );
65
- req.on("error", reject);
66
- req.write(data);
67
- req.end();
68
- });
69
- }
70
-
71
- async function httpGet(url: string): Promise<{ status: number; body: unknown }> {
72
- return new Promise((resolve, reject) => {
73
- const urlObj = new URL(url);
74
- const req = http.request(
75
- { hostname: urlObj.hostname, port: Number(urlObj.port), path: urlObj.pathname, method: "GET" },
76
- (res) => {
77
- let raw = "";
78
- res.on("data", (c) => (raw += c));
79
- res.on("end", () => {
80
- try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); }
81
- catch { resolve({ status: res.statusCode ?? 0, body: raw }); }
82
- });
83
- }
84
- );
85
- req.on("error", reject);
86
- req.end();
87
- });
88
- }
89
-
90
- // ── Fake context ──────────────────────────────────────────────────────────────
91
- const fakeClaudeCtx = {} as BrowserContext;
92
-
93
- // ── Server setup ──────────────────────────────────────────────────────────────
94
- let server: http.Server;
95
- let baseUrl: string;
96
-
97
- beforeAll(async () => {
98
- server = await startProxyServer({
99
- port: 0,
100
- log: () => {},
101
- warn: () => {},
102
- getClaudeContext: () => fakeClaudeCtx,
103
- // @ts-expect-error — stub types close enough for testing
104
- _claudeComplete: stubClaudeComplete,
105
- // @ts-expect-error — stub types close enough for testing
106
- _claudeCompleteStream: stubClaudeCompleteStream,
107
- });
108
- const addr = server.address() as AddressInfo;
109
- baseUrl = `http://127.0.0.1:${addr.port}`;
110
- });
111
-
112
- afterAll(() => server.close());
113
-
114
- // ── Tests ─────────────────────────────────────────────────────────────────────
115
-
116
- describe("Claude web-session routing — model list", () => {
117
- it("includes web-claude/* models in /v1/models", async () => {
118
- const res = await httpGet(`${baseUrl}/v1/models`);
119
- expect(res.status).toBe(200);
120
- const data = res.body as { data: { id: string }[] };
121
- const ids = data.data.map((m) => m.id);
122
- expect(ids).toContain("web-claude/claude-sonnet");
123
- expect(ids).toContain("web-claude/claude-opus");
124
- expect(ids).toContain("web-claude/claude-haiku");
125
- });
126
-
127
- it("web-claude/* models listed in CLI_MODELS", () => {
128
- const ids = CLI_MODELS.map((m) => m.id);
129
- expect(ids.some((id) => id.startsWith("web-claude/"))).toBe(true);
130
- });
131
- });
132
-
133
- describe("Claude web-session routing — non-streaming", () => {
134
- it("returns assistant message for web-claude/claude-sonnet", async () => {
135
- const res = await httpPost(`${baseUrl}/v1/chat/completions`, {
136
- model: "web-claude/claude-sonnet",
137
- messages: [{ role: "user", content: "hello" }],
138
- stream: false,
139
- });
140
- expect(res.status).toBe(200);
141
- const body = res.body as { choices: { message: { content: string } }[] };
142
- expect(body.choices[0].message.content).toContain("claude mock");
143
- expect(body.choices[0].message.content).toContain("hello");
144
- });
145
-
146
- it("calls stubClaudeComplete with correct model and messages", async () => {
147
- stubClaudeComplete.mockClear();
148
- await httpPost(`${baseUrl}/v1/chat/completions`, {
149
- model: "web-claude/claude-opus",
150
- messages: [{ role: "user", content: "test" }],
151
- stream: false,
152
- });
153
- expect(stubClaudeComplete).toHaveBeenCalledOnce();
154
- const call = stubClaudeComplete.mock.calls[0];
155
- expect(call[0]).toBe(fakeClaudeCtx);
156
- expect(call[1].model).toBe("web-claude/claude-opus");
157
- });
158
-
159
- it("returns 503 when no claude context is available", async () => {
160
- const noCtxServer = await startProxyServer({
161
- port: 0, log: () => {}, warn: () => {},
162
- getClaudeContext: () => null,
163
- });
164
- const addr = noCtxServer.address() as AddressInfo;
165
- const noCtxUrl = `http://127.0.0.1:${addr.port}`;
166
- const res = await httpPost(`${noCtxUrl}/v1/chat/completions`, {
167
- model: "web-claude/claude-sonnet",
168
- messages: [{ role: "user", content: "hi" }],
169
- });
170
- expect(res.status).toBe(503);
171
- const body = res.body as { error: { code: string } };
172
- expect(body.error.code).toBe("no_claude_session");
173
- noCtxServer.close();
174
- });
175
- });
176
-
177
- describe("Claude web-session routing — streaming", () => {
178
- it("returns SSE stream for web-claude/claude-sonnet", async () => {
179
- return new Promise<void>((resolve, reject) => {
180
- const body = JSON.stringify({
181
- model: "web-claude/claude-sonnet",
182
- messages: [{ role: "user", content: "stream test" }],
183
- stream: true,
184
- });
185
- const urlObj = new URL(`${baseUrl}/v1/chat/completions`);
186
- const req = http.request(
187
- { hostname: urlObj.hostname, port: Number(urlObj.port), path: urlObj.pathname, method: "POST",
188
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
189
- (res) => {
190
- expect(res.statusCode).toBe(200);
191
- expect(res.headers["content-type"]).toContain("text/event-stream");
192
- let raw = "";
193
- res.on("data", (c) => (raw += c));
194
- res.on("end", () => {
195
- expect(raw).toContain("data:");
196
- expect(raw).toContain("[DONE]");
197
- resolve();
198
- });
199
- }
200
- );
201
- req.on("error", reject);
202
- req.write(body);
203
- req.end();
204
- });
205
- });
206
-
207
- it("streams tokens from stub", async () => {
208
- stubClaudeCompleteStream.mockClear();
209
- return new Promise<void>((resolve, reject) => {
210
- const body = JSON.stringify({
211
- model: "web-claude/claude-haiku",
212
- messages: [{ role: "user", content: "tokens" }],
213
- stream: true,
214
- });
215
- const urlObj = new URL(`${baseUrl}/v1/chat/completions`);
216
- const req = http.request(
217
- { hostname: urlObj.hostname, port: Number(urlObj.port), path: urlObj.pathname, method: "POST",
218
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
219
- (res) => {
220
- let raw = "";
221
- res.on("data", (c) => (raw += c));
222
- res.on("end", () => {
223
- expect(stubClaudeCompleteStream).toHaveBeenCalledOnce();
224
- // Verify stream contains token chunks
225
- expect(raw).toContain("claude stream mock".split(" ")[0]);
226
- resolve();
227
- });
228
- }
229
- );
230
- req.on("error", reject);
231
- req.write(body);
232
- req.end();
233
- });
234
- });
235
- });