@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.
- package/.ai/handoff/HEADLESS_ROADMAP.md +81 -0
- package/.ai/handoff/STATUS.md +54 -78
- package/README.md +16 -1
- package/SKILL.md +1 -1
- package/index.ts +353 -39
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/claude-browser.ts +233 -0
- package/src/gemini-browser.ts +242 -0
- package/src/proxy-server.ts +125 -0
- package/test/claude-browser.test.ts +93 -0
- package/test/claude-proxy.test.ts +235 -0
- package/test/cli-runner.test.ts +27 -11
- package/test/gemini-proxy.test.ts +139 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test/claude-browser.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for claude-browser.ts helper functions.
|
|
5
|
+
* Does not require a real browser — tests the pure logic.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
|
|
10
|
+
// ─── Test the flatten + model resolution logic directly ──────────────────────
|
|
11
|
+
// We import internal helpers by re-exporting them from a test shim, or we just
|
|
12
|
+
// test the public surface via duck-typed mocks.
|
|
13
|
+
|
|
14
|
+
describe("claude-browser — model resolution", () => {
|
|
15
|
+
// We can test via the exported functions indirectly through proxy tests.
|
|
16
|
+
// Direct unit tests use small isolated helpers copied here.
|
|
17
|
+
|
|
18
|
+
function resolveModel(m?: string): string {
|
|
19
|
+
const clean = (m ?? "claude-sonnet").replace("web-claude/", "");
|
|
20
|
+
const allowed = ["claude-sonnet","claude-opus","claude-haiku","claude-sonnet-4-5","claude-sonnet-4-6","claude-opus-4-5"];
|
|
21
|
+
return allowed.includes(clean) ? clean : "claude-sonnet";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
it("strips web-claude/ prefix", () => {
|
|
25
|
+
expect(resolveModel("web-claude/claude-sonnet")).toBe("claude-sonnet");
|
|
26
|
+
expect(resolveModel("web-claude/claude-opus")).toBe("claude-opus");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("falls back to claude-sonnet for unknown models", () => {
|
|
30
|
+
expect(resolveModel("web-claude/unknown-model")).toBe("claude-sonnet");
|
|
31
|
+
expect(resolveModel(undefined)).toBe("claude-sonnet");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("accepts known models without prefix", () => {
|
|
35
|
+
expect(resolveModel("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
|
|
36
|
+
expect(resolveModel("claude-opus")).toBe("claude-opus");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("claude-browser — message flattening", () => {
|
|
41
|
+
function flattenMessages(messages: { role: string; content: string }[]): string {
|
|
42
|
+
if (messages.length === 1) return messages[0].content;
|
|
43
|
+
return messages.map((m) => {
|
|
44
|
+
if (m.role === "system") return `[System]: ${m.content}`;
|
|
45
|
+
if (m.role === "assistant") return `[Assistant]: ${m.content}`;
|
|
46
|
+
return m.content;
|
|
47
|
+
}).join("\n\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
it("returns content directly for single user message", () => {
|
|
51
|
+
expect(flattenMessages([{ role: "user", content: "hello" }])).toBe("hello");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("prefixes system messages", () => {
|
|
55
|
+
const result = flattenMessages([
|
|
56
|
+
{ role: "system", content: "Be brief" },
|
|
57
|
+
{ role: "user", content: "hi" },
|
|
58
|
+
]);
|
|
59
|
+
expect(result).toContain("[System]: Be brief");
|
|
60
|
+
expect(result).toContain("hi");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("prefixes assistant turns in multi-turn", () => {
|
|
64
|
+
const result = flattenMessages([
|
|
65
|
+
{ role: "user", content: "Hello" },
|
|
66
|
+
{ role: "assistant", content: "Hi there" },
|
|
67
|
+
{ role: "user", content: "How are you?" },
|
|
68
|
+
]);
|
|
69
|
+
expect(result).toContain("[Assistant]: Hi there");
|
|
70
|
+
expect(result).toContain("How are you?");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("claude-browser — proxy routing (DI override)", () => {
|
|
75
|
+
// Test that web-claude/* models route correctly through the proxy
|
|
76
|
+
// using the same DI pattern as grok-proxy.test.ts
|
|
77
|
+
|
|
78
|
+
it("web-claude/* model IDs follow naming convention", () => {
|
|
79
|
+
const validModels = [
|
|
80
|
+
"web-claude/claude-sonnet",
|
|
81
|
+
"web-claude/claude-opus",
|
|
82
|
+
"web-claude/claude-haiku",
|
|
83
|
+
];
|
|
84
|
+
for (const m of validModels) {
|
|
85
|
+
expect(m.startsWith("web-claude/")).toBe(true);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("distinguishes web-claude from web-grok", () => {
|
|
90
|
+
expect("web-claude/claude-sonnet".startsWith("web-claude/")).toBe(true);
|
|
91
|
+
expect("web-grok/grok-3".startsWith("web-claude/")).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
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
|
+
});
|
package/test/cli-runner.test.ts
CHANGED
|
@@ -173,15 +173,26 @@ describe("routeToCliRunner — model normalization", () => {
|
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
it("accepts cli-claude/ without vllm prefix (calls runClaude path)", async () => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
// Claude CLI may resolve (empty) or reject — what matters is it doesn't throw "Unknown CLI bridge model"
|
|
177
|
+
let errorMsg = "";
|
|
178
|
+
try {
|
|
179
|
+
await routeToCliRunner("cli-claude/claude-sonnet-4-6", [{ role: "user", content: "hi" }], 500);
|
|
180
|
+
} catch (e: any) {
|
|
181
|
+
errorMsg = (e as Error).message ?? String(e);
|
|
182
|
+
}
|
|
183
|
+
expect(errorMsg).not.toContain("Unknown CLI bridge model");
|
|
184
|
+
expect(errorMsg).not.toContain("CLI bridge model not allowed");
|
|
179
185
|
});
|
|
180
186
|
|
|
181
187
|
it("accepts vllm/cli-claude/ — strips vllm prefix before routing", async () => {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
188
|
+
let errorMsg = "";
|
|
189
|
+
try {
|
|
190
|
+
await routeToCliRunner("vllm/cli-claude/claude-sonnet-4-6", [{ role: "user", content: "hi" }], 500);
|
|
191
|
+
} catch (e: any) {
|
|
192
|
+
errorMsg = (e as Error).message ?? String(e);
|
|
193
|
+
}
|
|
194
|
+
expect(errorMsg).not.toContain("Unknown CLI bridge model");
|
|
195
|
+
expect(errorMsg).not.toContain("CLI bridge model not allowed");
|
|
185
196
|
});
|
|
186
197
|
|
|
187
198
|
// T-101: gemini routing paths
|
|
@@ -258,12 +269,17 @@ describe("routeToCliRunner — model allowlist (T-103)", () => {
|
|
|
258
269
|
});
|
|
259
270
|
|
|
260
271
|
it("allowedModels: null disables the check — only routing matters", async () => {
|
|
261
|
-
// With null allowlist,
|
|
262
|
-
|
|
263
|
-
|
|
272
|
+
// With null allowlist, the allowlist check is skipped — routing still happens
|
|
273
|
+
// Claude CLI may resolve (empty) or reject for other reasons — should NOT throw "CLI bridge model not allowed"
|
|
274
|
+
let errorMsg = "";
|
|
275
|
+
try {
|
|
276
|
+
await routeToCliRunner("vllm/cli-claude/any-model", [{ role: "user", content: "hi" }], 500, {
|
|
264
277
|
allowedModels: null,
|
|
265
|
-
})
|
|
266
|
-
|
|
278
|
+
});
|
|
279
|
+
} catch (e: any) {
|
|
280
|
+
errorMsg = (e as Error).message ?? String(e);
|
|
281
|
+
}
|
|
282
|
+
expect(errorMsg).not.toContain("CLI bridge model not allowed");
|
|
267
283
|
});
|
|
268
284
|
|
|
269
285
|
it("custom allowlist overrides defaults", async () => {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test/gemini-proxy.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for Gemini web-session routing in the cli-bridge proxy.
|
|
5
|
+
* Uses _geminiComplete/_geminiCompleteStream 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 GeminiCompleteOptions = { messages: { role: string; content: string }[]; model?: string; timeoutMs?: number };
|
|
15
|
+
type GeminiCompleteResult = { content: string; model: string; finishReason: string };
|
|
16
|
+
|
|
17
|
+
const stubGeminiComplete = vi.fn(async (
|
|
18
|
+
_ctx: BrowserContext,
|
|
19
|
+
opts: GeminiCompleteOptions,
|
|
20
|
+
_log: (msg: string) => void
|
|
21
|
+
): Promise<GeminiCompleteResult> => ({
|
|
22
|
+
content: `gemini mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
|
|
23
|
+
model: opts.model ?? "web-gemini/gemini-2-5-pro",
|
|
24
|
+
finishReason: "stop",
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const stubGeminiCompleteStream = vi.fn(async (
|
|
28
|
+
_ctx: BrowserContext,
|
|
29
|
+
opts: GeminiCompleteOptions,
|
|
30
|
+
onToken: (t: string) => void,
|
|
31
|
+
_log: (msg: string) => void
|
|
32
|
+
): Promise<GeminiCompleteResult> => {
|
|
33
|
+
const tokens = ["gemini ", "stream ", "mock"];
|
|
34
|
+
for (const t of tokens) onToken(t);
|
|
35
|
+
return { content: tokens.join(""), model: opts.model ?? "web-gemini/gemini-2-5-pro", 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
|
+
getGeminiContext: () => fakeCtx,
|
|
68
|
+
// @ts-expect-error — stub types close enough for testing
|
|
69
|
+
_geminiComplete: stubGeminiComplete,
|
|
70
|
+
// @ts-expect-error — stub types close enough for testing
|
|
71
|
+
_geminiCompleteStream: stubGeminiCompleteStream,
|
|
72
|
+
});
|
|
73
|
+
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
74
|
+
});
|
|
75
|
+
afterAll(() => server.close());
|
|
76
|
+
|
|
77
|
+
describe("Gemini web-session routing — model list", () => {
|
|
78
|
+
it("includes web-gemini/* 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-gemini/gemini-2-5-pro");
|
|
83
|
+
expect(ids).toContain("web-gemini/gemini-3-pro");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("web-gemini/* models listed in CLI_MODELS constant", () => {
|
|
87
|
+
expect(CLI_MODELS.some(m => m.id.startsWith("web-gemini/"))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("Gemini web-session routing — non-streaming", () => {
|
|
92
|
+
it("returns assistant message for web-gemini/gemini-2-5-pro", async () => {
|
|
93
|
+
const res = await httpPost(`${baseUrl}/v1/chat/completions`, {
|
|
94
|
+
model: "web-gemini/gemini-2-5-pro",
|
|
95
|
+
messages: [{ role: "user", content: "hello gemini" }],
|
|
96
|
+
stream: false,
|
|
97
|
+
});
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
const body = res.body as { choices: { message: { content: string } }[] };
|
|
100
|
+
expect(body.choices[0].message.content).toContain("gemini mock");
|
|
101
|
+
expect(body.choices[0].message.content).toContain("hello gemini");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("passes correct model to stub", async () => {
|
|
105
|
+
stubGeminiComplete.mockClear();
|
|
106
|
+
await httpPost(`${baseUrl}/v1/chat/completions`, {
|
|
107
|
+
model: "web-gemini/gemini-3-flash",
|
|
108
|
+
messages: [{ role: "user", content: "test" }],
|
|
109
|
+
});
|
|
110
|
+
expect(stubGeminiComplete).toHaveBeenCalledOnce();
|
|
111
|
+
expect(stubGeminiComplete.mock.calls[0][1].model).toBe("web-gemini/gemini-3-flash");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns 503 when no gemini context", async () => {
|
|
115
|
+
const s = await startProxyServer({ port: 0, log: () => {}, warn: () => {}, getGeminiContext: () => null });
|
|
116
|
+
const url = `http://127.0.0.1:${(s.address() as AddressInfo).port}`;
|
|
117
|
+
const res = await httpPost(`${url}/v1/chat/completions`, {
|
|
118
|
+
model: "web-gemini/gemini-2-5-pro",
|
|
119
|
+
messages: [{ role: "user", content: "hi" }],
|
|
120
|
+
});
|
|
121
|
+
expect(res.status).toBe(503);
|
|
122
|
+
expect((res.body as { error: { code: string } }).error.code).toBe("no_gemini_session");
|
|
123
|
+
s.close();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("Gemini web-session routing — streaming", () => {
|
|
128
|
+
it("returns SSE stream", async () => {
|
|
129
|
+
return new Promise<void>((resolve, reject) => {
|
|
130
|
+
const body = JSON.stringify({ model: "web-gemini/gemini-2-5-pro", messages: [{ role: "user", content: "stream" }], stream: true });
|
|
131
|
+
const u = new URL(`${baseUrl}/v1/chat/completions`);
|
|
132
|
+
const req = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
|
|
133
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
|
|
134
|
+
(res) => { expect(res.statusCode).toBe(200); let raw = ""; res.on("data", c => raw += c); res.on("end", () => { expect(raw).toContain("[DONE]"); resolve(); }); }
|
|
135
|
+
);
|
|
136
|
+
req.on("error", reject); req.write(body); req.end();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|