@elvatis_com/openclaw-cli-bridge-elvatis 0.2.29 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/SKILL.md +1 -1
- package/index.ts +325 -54
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/chatgpt-browser.ts +253 -0
- package/src/gemini-browser.ts +242 -0
- package/src/proxy-server.ts +115 -0
- package/test/chatgpt-proxy.test.ts +107 -0
- package/test/gemini-proxy.test.ts +139 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test/chatgpt-proxy.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for ChatGPT web-session routing in the cli-bridge proxy.
|
|
5
|
+
* Uses _chatgptComplete/_chatgptCompleteStream DI overrides (no real browser).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import type { AddressInfo } from "node:net";
|
|
11
|
+
import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
|
|
12
|
+
import type { BrowserContext } from "playwright";
|
|
13
|
+
|
|
14
|
+
type Opts = { messages: { role: string; content: string }[]; model?: string; timeoutMs?: number };
|
|
15
|
+
type Result = { content: string; model: string; finishReason: string };
|
|
16
|
+
|
|
17
|
+
const stubComplete = vi.fn(async (_ctx: BrowserContext, opts: Opts, _log: (m: string) => void): Promise<Result> => ({
|
|
18
|
+
content: `chatgpt mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
|
|
19
|
+
model: opts.model ?? "web-chatgpt/gpt-4o", finishReason: "stop",
|
|
20
|
+
}));
|
|
21
|
+
const stubStream = vi.fn(async (_ctx: BrowserContext, opts: Opts, onToken: (t: string) => void, _log: (m: string) => void): Promise<Result> => {
|
|
22
|
+
["chatgpt ", "stream ", "mock"].forEach(t => onToken(t));
|
|
23
|
+
return { content: "chatgpt stream mock", model: opts.model ?? "web-chatgpt/gpt-4o", finishReason: "stop" };
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function post(url: string, body: unknown): Promise<{ status: number; body: unknown }> {
|
|
27
|
+
return new Promise((res, rej) => {
|
|
28
|
+
const d = JSON.stringify(body); const u = new URL(url);
|
|
29
|
+
const r = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(d) } },
|
|
31
|
+
resp => { let raw = ""; resp.on("data", c => raw += c); resp.on("end", () => { try { res({ status: resp.statusCode ?? 0, body: JSON.parse(raw) }); } catch { res({ status: resp.statusCode ?? 0, body: raw }); } }); });
|
|
32
|
+
r.on("error", rej); r.write(d); r.end();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async function get(url: string): Promise<{ status: number; body: unknown }> {
|
|
36
|
+
return new Promise((res, rej) => {
|
|
37
|
+
const u = new URL(url);
|
|
38
|
+
const r = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "GET" },
|
|
39
|
+
resp => { let raw = ""; resp.on("data", c => raw += c); resp.on("end", () => { try { res({ status: resp.statusCode ?? 0, body: JSON.parse(raw) }); } catch { res({ status: resp.statusCode ?? 0, body: raw }); } }); });
|
|
40
|
+
r.on("error", rej); r.end();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fakeCtx = {} as BrowserContext;
|
|
45
|
+
let server: http.Server;
|
|
46
|
+
let baseUrl: string;
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
server = await startProxyServer({
|
|
50
|
+
port: 0, log: () => {}, warn: () => {},
|
|
51
|
+
getChatGPTContext: () => fakeCtx,
|
|
52
|
+
// @ts-expect-error stub
|
|
53
|
+
_chatgptComplete: stubComplete,
|
|
54
|
+
// @ts-expect-error stub
|
|
55
|
+
_chatgptCompleteStream: stubStream,
|
|
56
|
+
});
|
|
57
|
+
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
58
|
+
});
|
|
59
|
+
afterAll(() => server.close());
|
|
60
|
+
|
|
61
|
+
describe("ChatGPT routing — model list", () => {
|
|
62
|
+
it("includes web-chatgpt/* models in /v1/models", async () => {
|
|
63
|
+
const res = await get(`${baseUrl}/v1/models`);
|
|
64
|
+
expect(res.status).toBe(200);
|
|
65
|
+
const ids = (res.body as { data: { id: string }[] }).data.map(m => m.id);
|
|
66
|
+
expect(ids).toContain("web-chatgpt/gpt-4o");
|
|
67
|
+
expect(ids).toContain("web-chatgpt/gpt-5");
|
|
68
|
+
});
|
|
69
|
+
it("CLI_MODELS includes web-chatgpt/*", () => {
|
|
70
|
+
expect(CLI_MODELS.some(m => m.id.startsWith("web-chatgpt/"))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("ChatGPT routing — non-streaming", () => {
|
|
75
|
+
it("returns assistant message for web-chatgpt/gpt-4o", async () => {
|
|
76
|
+
const res = await post(`${baseUrl}/v1/chat/completions`, {
|
|
77
|
+
model: "web-chatgpt/gpt-4o", messages: [{ role: "user", content: "hello chatgpt" }], stream: false,
|
|
78
|
+
});
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
expect((res.body as any).choices[0].message.content).toContain("chatgpt mock");
|
|
81
|
+
});
|
|
82
|
+
it("passes correct model to stub", async () => {
|
|
83
|
+
stubComplete.mockClear();
|
|
84
|
+
await post(`${baseUrl}/v1/chat/completions`, { model: "web-chatgpt/gpt-o3", messages: [{ role: "user", content: "x" }] });
|
|
85
|
+
expect(stubComplete).toHaveBeenCalledOnce();
|
|
86
|
+
expect(stubComplete.mock.calls[0][1].model).toBe("web-chatgpt/gpt-o3");
|
|
87
|
+
});
|
|
88
|
+
it("returns 503 when no context", async () => {
|
|
89
|
+
const s = await startProxyServer({ port: 0, log: () => {}, warn: () => {}, getChatGPTContext: () => null });
|
|
90
|
+
const u = `http://127.0.0.1:${(s.address() as AddressInfo).port}`;
|
|
91
|
+
const r = await post(`${u}/v1/chat/completions`, { model: "web-chatgpt/gpt-4o", messages: [{ role: "user", content: "hi" }] });
|
|
92
|
+
expect(r.status).toBe(503);
|
|
93
|
+
expect((r.body as any).error.code).toBe("no_chatgpt_session");
|
|
94
|
+
s.close();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("ChatGPT routing — streaming", () => {
|
|
99
|
+
it("returns SSE stream with [DONE]", () => new Promise<void>((resolve, reject) => {
|
|
100
|
+
const body = JSON.stringify({ model: "web-chatgpt/gpt-4o", messages: [{ role: "user", content: "s" }], stream: true });
|
|
101
|
+
const u = new URL(`${baseUrl}/v1/chat/completions`);
|
|
102
|
+
const r = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
|
|
103
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
|
|
104
|
+
resp => { let raw = ""; resp.on("data", c => raw += c); resp.on("end", () => { expect(raw).toContain("[DONE]"); resolve(); }); });
|
|
105
|
+
r.on("error", reject); r.write(body); r.end();
|
|
106
|
+
}));
|
|
107
|
+
});
|
|
@@ -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
|
+
});
|