@elvatis_com/openclaw-cli-bridge-elvatis 1.9.0 → 2.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/.ai/handoff/STATUS.md +53 -50
- package/CONTRIBUTING.md +18 -0
- package/README.md +20 -2
- package/SKILL.md +1 -1
- package/index.ts +152 -1
- package/openclaw.plugin.json +2 -2
- package/package.json +1 -1
- package/src/cli-runner.ts +178 -19
- package/src/grok-client.ts +1 -1
- package/src/proxy-server.ts +141 -22
- package/src/session-manager.ts +307 -0
- package/test/chatgpt-proxy.test.ts +2 -2
- package/test/claude-proxy.test.ts +2 -2
- package/test/cli-runner-extended.test.ts +267 -0
- package/test/grok-proxy.test.ts +2 -2
- package/test/proxy-e2e.test.ts +274 -2
- package/test/session-manager.test.ts +339 -0
package/src/cli-runner.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* cli-runner.ts
|
|
3
3
|
*
|
|
4
|
-
* Spawns CLI subprocesses (gemini, claude) and captures their output.
|
|
5
|
-
* Input: OpenAI-format messages → formatted prompt string → CLI stdin.
|
|
4
|
+
* Spawns CLI subprocesses (gemini, claude, codex, opencode, pi) and captures their output.
|
|
5
|
+
* Input: OpenAI-format messages → formatted prompt string → CLI stdin (or CLI arg).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
7
|
+
* Prompt delivery:
|
|
8
|
+
* - Gemini/Claude/Codex receive the prompt via stdin to avoid E2BIG and agentic mode.
|
|
9
|
+
* - OpenCode receives the prompt as a CLI argument (`opencode run "prompt"`).
|
|
10
|
+
* - Pi receives the prompt via `-p "prompt"` flag.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* Workdir isolation:
|
|
13
|
+
* - Gemini: defaults to tmpdir() (prevents agentic workspace scanning).
|
|
14
|
+
* - Claude/Codex: defaults to homedir().
|
|
15
|
+
* - OpenCode/Pi: defaults to homedir().
|
|
16
|
+
* - All runners accept an explicit `workdir` override via RouteOptions.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
|
-
import { spawn } from "node:child_process";
|
|
19
|
+
import { spawn, execSync } from "node:child_process";
|
|
16
20
|
import { tmpdir, homedir } from "node:os";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
17
23
|
import { ensureClaudeToken, refreshClaudeToken } from "./claude-auth.js";
|
|
18
24
|
|
|
19
25
|
/** Max messages to include in the prompt sent to the CLI. */
|
|
@@ -198,6 +204,41 @@ export function runCli(
|
|
|
198
204
|
});
|
|
199
205
|
}
|
|
200
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Spawn a CLI with the prompt delivered as a CLI argument (not stdin).
|
|
209
|
+
* Used by OpenCode which expects `opencode run "prompt"`.
|
|
210
|
+
*/
|
|
211
|
+
export function runCliWithArg(
|
|
212
|
+
cmd: string,
|
|
213
|
+
args: string[],
|
|
214
|
+
timeoutMs = 120_000,
|
|
215
|
+
opts: RunCliOptions = {}
|
|
216
|
+
): Promise<CliRunResult> {
|
|
217
|
+
const cwd = opts.cwd ?? homedir();
|
|
218
|
+
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
const proc = spawn(cmd, args, {
|
|
221
|
+
timeout: timeoutMs,
|
|
222
|
+
env: buildMinimalEnv(),
|
|
223
|
+
cwd,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
let stdout = "";
|
|
227
|
+
let stderr = "";
|
|
228
|
+
|
|
229
|
+
proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
|
|
230
|
+
proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
|
|
231
|
+
|
|
232
|
+
proc.on("close", (code) => {
|
|
233
|
+
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 0 });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
proc.on("error", (err) => {
|
|
237
|
+
reject(new Error(`Failed to spawn '${cmd}': ${err.message}`));
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
201
242
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
202
243
|
// Gemini CLI
|
|
203
244
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -215,17 +256,20 @@ export function runCli(
|
|
|
215
256
|
* Gemini CLI: -p "" triggers headless mode; stdin content is the actual prompt
|
|
216
257
|
* (per Gemini docs: "prompt is appended to input on stdin (if any)").
|
|
217
258
|
*
|
|
218
|
-
* cwd = tmpdir() — neutral empty-ish dir, prevents workspace context scanning.
|
|
259
|
+
* cwd = tmpdir() by default — neutral empty-ish dir, prevents workspace context scanning.
|
|
260
|
+
* Override with explicit workdir.
|
|
219
261
|
*/
|
|
220
262
|
export async function runGemini(
|
|
221
263
|
prompt: string,
|
|
222
264
|
modelId: string,
|
|
223
|
-
timeoutMs: number
|
|
265
|
+
timeoutMs: number,
|
|
266
|
+
workdir?: string
|
|
224
267
|
): Promise<string> {
|
|
225
268
|
const model = stripPrefix(modelId);
|
|
226
269
|
// -p "" = headless mode trigger; actual prompt arrives via stdin
|
|
227
270
|
const args = ["-m", model, "-p", ""];
|
|
228
|
-
const
|
|
271
|
+
const cwd = workdir ?? tmpdir();
|
|
272
|
+
const result = await runCli("gemini", args, prompt, timeoutMs, { cwd });
|
|
229
273
|
|
|
230
274
|
// Filter out [WARN] lines from stderr (Gemini emits noisy permission warnings)
|
|
231
275
|
const cleanStderr = result.stderr
|
|
@@ -248,11 +292,13 @@ export async function runGemini(
|
|
|
248
292
|
/**
|
|
249
293
|
* Run Claude Code CLI in headless mode with prompt delivered via stdin.
|
|
250
294
|
* Strips the model prefix ("cli-claude/claude-opus-4-6" → "claude-opus-4-6").
|
|
295
|
+
* cwd = homedir() by default. Override with explicit workdir.
|
|
251
296
|
*/
|
|
252
297
|
export async function runClaude(
|
|
253
298
|
prompt: string,
|
|
254
299
|
modelId: string,
|
|
255
|
-
timeoutMs: number
|
|
300
|
+
timeoutMs: number,
|
|
301
|
+
workdir?: string
|
|
256
302
|
): Promise<string> {
|
|
257
303
|
// Proactively refresh OAuth token if it's about to expire (< 5 min remaining).
|
|
258
304
|
// No-op for API-key users.
|
|
@@ -267,7 +313,8 @@ export async function runClaude(
|
|
|
267
313
|
"--model", model,
|
|
268
314
|
];
|
|
269
315
|
|
|
270
|
-
const
|
|
316
|
+
const cwd = workdir ?? homedir();
|
|
317
|
+
const result = await runCli("claude", args, prompt, timeoutMs, { cwd });
|
|
271
318
|
|
|
272
319
|
// On 401: attempt one token refresh + retry before giving up.
|
|
273
320
|
if (result.exitCode !== 0 && result.stdout.length === 0) {
|
|
@@ -275,7 +322,7 @@ export async function runClaude(
|
|
|
275
322
|
if (stderr.includes("401") || stderr.includes("Invalid authentication credentials") || stderr.includes("authentication_error")) {
|
|
276
323
|
// Refresh and retry once
|
|
277
324
|
await refreshClaudeToken();
|
|
278
|
-
const retry = await runCli("claude", args, prompt, timeoutMs);
|
|
325
|
+
const retry = await runCli("claude", args, prompt, timeoutMs, { cwd });
|
|
279
326
|
if (retry.exitCode !== 0 && retry.stdout.length === 0) {
|
|
280
327
|
const retryStderr = retry.stderr || "(no output)";
|
|
281
328
|
if (retryStderr.includes("401") || retryStderr.includes("authentication_error") || retryStderr.includes("Invalid authentication credentials")) {
|
|
@@ -294,6 +341,97 @@ export async function runClaude(
|
|
|
294
341
|
return result.stdout;
|
|
295
342
|
}
|
|
296
343
|
|
|
344
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
345
|
+
// Codex CLI
|
|
346
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Ensure the workdir is a git repository. Codex CLI requires a git repo.
|
|
350
|
+
* If the directory exists but is not a git repo, run `git init`.
|
|
351
|
+
*/
|
|
352
|
+
function ensureGitRepo(dir: string): void {
|
|
353
|
+
if (!existsSync(join(dir, ".git"))) {
|
|
354
|
+
execSync("git init", { cwd: dir, stdio: "ignore" });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Run Codex CLI in non-interactive mode with prompt via stdin.
|
|
360
|
+
* cwd = homedir() by default. Override with explicit workdir.
|
|
361
|
+
* Auto-initializes git if workdir is not already a git repo.
|
|
362
|
+
*/
|
|
363
|
+
export async function runCodex(
|
|
364
|
+
prompt: string,
|
|
365
|
+
modelId: string,
|
|
366
|
+
timeoutMs: number,
|
|
367
|
+
workdir?: string
|
|
368
|
+
): Promise<string> {
|
|
369
|
+
const model = stripPrefix(modelId);
|
|
370
|
+
const args = ["--model", model, "--quiet", "--full-auto"];
|
|
371
|
+
const cwd = workdir ?? homedir();
|
|
372
|
+
|
|
373
|
+
// Codex requires a git repo in the working directory
|
|
374
|
+
ensureGitRepo(cwd);
|
|
375
|
+
|
|
376
|
+
const result = await runCli("codex", args, prompt, timeoutMs, { cwd });
|
|
377
|
+
|
|
378
|
+
if (result.exitCode !== 0 && result.stdout.length === 0) {
|
|
379
|
+
throw new Error(`codex exited ${result.exitCode}: ${result.stderr || "(no output)"}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return result.stdout || result.stderr;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
386
|
+
// OpenCode CLI
|
|
387
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Run OpenCode CLI. Prompt is passed as a CLI argument: `opencode run "prompt"`.
|
|
391
|
+
* cwd = homedir() by default. Override with explicit workdir.
|
|
392
|
+
*/
|
|
393
|
+
export async function runOpenCode(
|
|
394
|
+
prompt: string,
|
|
395
|
+
_modelId: string,
|
|
396
|
+
timeoutMs: number,
|
|
397
|
+
workdir?: string
|
|
398
|
+
): Promise<string> {
|
|
399
|
+
const args = ["run", prompt];
|
|
400
|
+
const cwd = workdir ?? homedir();
|
|
401
|
+
const result = await runCliWithArg("opencode", args, timeoutMs, { cwd });
|
|
402
|
+
|
|
403
|
+
if (result.exitCode !== 0 && result.stdout.length === 0) {
|
|
404
|
+
throw new Error(`opencode exited ${result.exitCode}: ${result.stderr || "(no output)"}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return result.stdout || result.stderr;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
411
|
+
// Pi CLI
|
|
412
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Run Pi CLI in non-interactive mode: `pi -p "prompt"`.
|
|
416
|
+
* cwd = homedir() by default. Override with explicit workdir.
|
|
417
|
+
*/
|
|
418
|
+
export async function runPi(
|
|
419
|
+
prompt: string,
|
|
420
|
+
_modelId: string,
|
|
421
|
+
timeoutMs: number,
|
|
422
|
+
workdir?: string
|
|
423
|
+
): Promise<string> {
|
|
424
|
+
const args = ["-p", prompt];
|
|
425
|
+
const cwd = workdir ?? homedir();
|
|
426
|
+
const result = await runCliWithArg("pi", args, timeoutMs, { cwd });
|
|
427
|
+
|
|
428
|
+
if (result.exitCode !== 0 && result.stdout.length === 0) {
|
|
429
|
+
throw new Error(`pi exited ${result.exitCode}: ${result.stderr || "(no output)"}`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return result.stdout || result.stderr;
|
|
433
|
+
}
|
|
434
|
+
|
|
297
435
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
298
436
|
// Model allowlist (T-103)
|
|
299
437
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -319,6 +457,16 @@ export const DEFAULT_ALLOWED_CLI_MODELS: ReadonlySet<string> = new Set([
|
|
|
319
457
|
// Aliases (map to preview variants internally)
|
|
320
458
|
"cli-gemini/gemini-3-pro", // alias → gemini-3-pro-preview
|
|
321
459
|
"cli-gemini/gemini-3-flash", // alias → gemini-3-flash-preview
|
|
460
|
+
// Codex CLI
|
|
461
|
+
"openai-codex/gpt-5.3-codex",
|
|
462
|
+
"openai-codex/gpt-5.3-codex-spark",
|
|
463
|
+
"openai-codex/gpt-5.2-codex",
|
|
464
|
+
"openai-codex/gpt-5.4",
|
|
465
|
+
"openai-codex/gpt-5.1-codex-mini",
|
|
466
|
+
// OpenCode CLI
|
|
467
|
+
"opencode/default",
|
|
468
|
+
// Pi CLI
|
|
469
|
+
"pi/default",
|
|
322
470
|
]);
|
|
323
471
|
|
|
324
472
|
/** Normalize model aliases to their canonical CLI model names. */
|
|
@@ -341,12 +489,20 @@ export interface RouteOptions {
|
|
|
341
489
|
* Defaults to DEFAULT_ALLOWED_CLI_MODELS.
|
|
342
490
|
*/
|
|
343
491
|
allowedModels?: ReadonlySet<string> | null;
|
|
492
|
+
/**
|
|
493
|
+
* Working directory for the CLI subprocess.
|
|
494
|
+
* Overrides the per-runner default (tmpdir for gemini, homedir for others).
|
|
495
|
+
*/
|
|
496
|
+
workdir?: string;
|
|
344
497
|
}
|
|
345
498
|
|
|
346
499
|
/**
|
|
347
500
|
* Route a chat completion to the correct CLI based on model prefix.
|
|
348
|
-
* cli-gemini/<id>
|
|
349
|
-
* cli-claude/<id>
|
|
501
|
+
* cli-gemini/<id> → gemini CLI
|
|
502
|
+
* cli-claude/<id> → claude CLI
|
|
503
|
+
* openai-codex/<id> → codex CLI
|
|
504
|
+
* opencode/<id> → opencode CLI
|
|
505
|
+
* pi/<id> → pi CLI
|
|
350
506
|
*
|
|
351
507
|
* Enforces DEFAULT_ALLOWED_CLI_MODELS by default (T-103).
|
|
352
508
|
* Pass `allowedModels: null` to skip the allowlist check.
|
|
@@ -379,11 +535,14 @@ export async function routeToCliRunner(
|
|
|
379
535
|
// Resolve aliases (e.g. gemini-3-pro → gemini-3-pro-preview) after allowlist check
|
|
380
536
|
const resolved = normalizeModelAlias(normalized);
|
|
381
537
|
|
|
382
|
-
if (resolved.startsWith("cli-gemini/"))
|
|
383
|
-
if (resolved.startsWith("cli-claude/"))
|
|
538
|
+
if (resolved.startsWith("cli-gemini/")) return runGemini(prompt, resolved, timeoutMs, opts.workdir);
|
|
539
|
+
if (resolved.startsWith("cli-claude/")) return runClaude(prompt, resolved, timeoutMs, opts.workdir);
|
|
540
|
+
if (resolved.startsWith("openai-codex/")) return runCodex(prompt, resolved, timeoutMs, opts.workdir);
|
|
541
|
+
if (resolved.startsWith("opencode/")) return runOpenCode(prompt, resolved, timeoutMs, opts.workdir);
|
|
542
|
+
if (resolved.startsWith("pi/")) return runPi(prompt, resolved, timeoutMs, opts.workdir);
|
|
384
543
|
|
|
385
544
|
throw new Error(
|
|
386
|
-
`Unknown CLI bridge model: "${model}". Use "vllm/cli-gemini/<model>"
|
|
545
|
+
`Unknown CLI bridge model: "${model}". Use "vllm/cli-gemini/<model>", "vllm/cli-claude/<model>", "openai-codex/<model>", "opencode/<model>", or "pi/<model>".`
|
|
387
546
|
);
|
|
388
547
|
}
|
|
389
548
|
|
package/src/grok-client.ts
CHANGED
|
@@ -51,7 +51,7 @@ const STABLE_INTERVAL_MS = 500; // ms between stability checks
|
|
|
51
51
|
|
|
52
52
|
function resolveModel(m?: string): string {
|
|
53
53
|
const clean = (m ?? "grok-3").replace("web-grok/", "");
|
|
54
|
-
const allowed = ["grok-3", "grok-3-fast", "grok-3-mini", "grok-3-mini-fast"];
|
|
54
|
+
const allowed = ["grok-4", "grok-3", "grok-3-fast", "grok-3-mini", "grok-3-mini-fast"];
|
|
55
55
|
return allowed.includes(clean) ? clean : "grok-3";
|
|
56
56
|
}
|
|
57
57
|
|
package/src/proxy-server.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowser
|
|
|
18
18
|
import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrowserChatMessage } from "./chatgpt-browser.js";
|
|
19
19
|
import type { BrowserContext } from "playwright";
|
|
20
20
|
import { renderStatusPage, type StatusProvider } from "./status-template.js";
|
|
21
|
+
import { sessionManager } from "./session-manager.js";
|
|
21
22
|
|
|
22
23
|
export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
|
|
23
24
|
export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
|
|
@@ -85,32 +86,38 @@ export interface ProxyServerOptions {
|
|
|
85
86
|
/** Available CLI bridge models for GET /v1/models */
|
|
86
87
|
export const CLI_MODELS = [
|
|
87
88
|
// ── Claude Code CLI ───────────────────────────────────────────────────────
|
|
88
|
-
{ id: "cli-claude/claude-sonnet-4-6", name: "Claude Sonnet 4.6 (CLI)", contextWindow:
|
|
89
|
-
{ id: "cli-claude/claude-opus-4-6", name: "Claude Opus 4.6 (CLI)", contextWindow:
|
|
90
|
-
{ id: "cli-claude/claude-haiku-4-5", name: "Claude Haiku 4.5 (CLI)", contextWindow: 200_000, maxTokens:
|
|
89
|
+
{ id: "cli-claude/claude-sonnet-4-6", name: "Claude Sonnet 4.6 (CLI)", contextWindow: 1_000_000, maxTokens: 64_000 },
|
|
90
|
+
{ id: "cli-claude/claude-opus-4-6", name: "Claude Opus 4.6 (CLI)", contextWindow: 1_000_000, maxTokens: 128_000 },
|
|
91
|
+
{ id: "cli-claude/claude-haiku-4-5", name: "Claude Haiku 4.5 (CLI)", contextWindow: 200_000, maxTokens: 64_000 },
|
|
91
92
|
// ── Gemini CLI ────────────────────────────────────────────────────────────
|
|
92
|
-
{ id: "cli-gemini/gemini-2.5-pro", name: "Gemini 2.5 Pro (CLI)", contextWindow:
|
|
93
|
-
{ id: "cli-gemini/gemini-2.5-flash", name: "Gemini 2.5 Flash (CLI)", contextWindow:
|
|
94
|
-
{ id: "cli-gemini/gemini-3-pro-preview", name: "Gemini 3 Pro Preview (CLI)", contextWindow:
|
|
95
|
-
{ id: "cli-gemini/gemini-3-flash-preview", name: "Gemini 3 Flash Preview (CLI)", contextWindow:
|
|
93
|
+
{ id: "cli-gemini/gemini-2.5-pro", name: "Gemini 2.5 Pro (CLI)", contextWindow: 1_048_576, maxTokens: 65_535 },
|
|
94
|
+
{ id: "cli-gemini/gemini-2.5-flash", name: "Gemini 2.5 Flash (CLI)", contextWindow: 1_048_576, maxTokens: 65_535 },
|
|
95
|
+
{ id: "cli-gemini/gemini-3-pro-preview", name: "Gemini 3 Pro Preview (CLI)", contextWindow: 1_048_576, maxTokens: 65_536 },
|
|
96
|
+
{ id: "cli-gemini/gemini-3-flash-preview", name: "Gemini 3 Flash Preview (CLI)", contextWindow: 1_048_576, maxTokens: 65_536 },
|
|
96
97
|
// Codex CLI models (via openai-codex provider, OAuth auth)
|
|
97
|
-
|
|
98
|
-
{ id: "openai-codex/gpt-5.
|
|
99
|
-
{ id: "openai-codex/gpt-5.
|
|
100
|
-
{ id: "openai-codex/gpt-5.
|
|
101
|
-
{ id: "openai-codex/gpt-5.
|
|
98
|
+
// GPT-5.4: 1M ctx, 128K out | GPT-5.3: 400K ctx, 128K out | GPT-5.2: 200K, 32K | Mini: 128K, 16K
|
|
99
|
+
{ id: "openai-codex/gpt-5.4", name: "GPT-5.4", contextWindow: 1_050_000, maxTokens: 128_000 },
|
|
100
|
+
{ id: "openai-codex/gpt-5.3-codex", name: "GPT-5.3 Codex", contextWindow: 400_000, maxTokens: 128_000 },
|
|
101
|
+
{ id: "openai-codex/gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark", contextWindow: 400_000, maxTokens: 64_000 },
|
|
102
|
+
{ id: "openai-codex/gpt-5.2-codex", name: "GPT-5.2 Codex", contextWindow: 200_000, maxTokens: 32_768 },
|
|
103
|
+
{ id: "openai-codex/gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini", contextWindow: 128_000, maxTokens: 16_384 },
|
|
102
104
|
// Grok web-session models (requires /grok-login)
|
|
105
|
+
{ id: "web-grok/grok-4", name: "Grok 4 (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
103
106
|
{ id: "web-grok/grok-3", name: "Grok 3 (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
104
107
|
{ id: "web-grok/grok-3-fast", name: "Grok 3 Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
105
108
|
{ id: "web-grok/grok-3-mini", name: "Grok 3 Mini (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
106
109
|
{ id: "web-grok/grok-3-mini-fast", name: "Grok 3 Mini Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
107
110
|
// Gemini web-session models (requires /gemini-login)
|
|
108
|
-
{ id: "web-gemini/gemini-2-5-pro", name: "Gemini 2.5 Pro (web session)", contextWindow:
|
|
109
|
-
{ id: "web-gemini/gemini-2-5-flash", name: "Gemini 2.5 Flash (web session)", contextWindow:
|
|
110
|
-
{ id: "web-gemini/gemini-3-pro", name: "Gemini 3 Pro (web session)", contextWindow:
|
|
111
|
-
{ id: "web-gemini/gemini-3-flash", name: "Gemini 3 Flash (web session)", contextWindow:
|
|
111
|
+
{ id: "web-gemini/gemini-2-5-pro", name: "Gemini 2.5 Pro (web session)", contextWindow: 1_048_576, maxTokens: 65_535 },
|
|
112
|
+
{ id: "web-gemini/gemini-2-5-flash", name: "Gemini 2.5 Flash (web session)", contextWindow: 1_048_576, maxTokens: 65_535 },
|
|
113
|
+
{ id: "web-gemini/gemini-3-pro", name: "Gemini 3 Pro (web session)", contextWindow: 1_048_576, maxTokens: 65_536 },
|
|
114
|
+
{ id: "web-gemini/gemini-3-flash", name: "Gemini 3 Flash (web session)", contextWindow: 1_048_576, maxTokens: 65_536 },
|
|
112
115
|
// Claude → use cli-claude/* instead (web-claude removed in v1.6.x)
|
|
113
116
|
// ChatGPT → use openai-codex/* or copilot-proxy instead (web-chatgpt removed in v1.6.x)
|
|
117
|
+
// ── OpenCode CLI ──────────────────────────────────────────────────────────
|
|
118
|
+
{ id: "opencode/default", name: "OpenCode (CLI)", contextWindow: 128_000, maxTokens: 16_384 },
|
|
119
|
+
// ── Pi CLI ──────────────────────────────────────────────────────────────
|
|
120
|
+
{ id: "pi/default", name: "Pi (CLI)", contextWindow: 128_000, maxTokens: 16_384 },
|
|
114
121
|
// ── Local BitNet inference ──────────────────────────────────────────────────
|
|
115
122
|
{ id: "local-bitnet/bitnet-2b", name: "BitNet b1.58 2B (local CPU inference)", contextWindow: 4_096, maxTokens: 2_048 },
|
|
116
123
|
];
|
|
@@ -131,9 +138,10 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
|
|
|
131
138
|
});
|
|
132
139
|
});
|
|
133
140
|
|
|
134
|
-
// Stop the token refresh interval when the server closes (timer-leak prevention)
|
|
141
|
+
// Stop the token refresh interval and session manager when the server closes (timer-leak prevention)
|
|
135
142
|
server.on("close", () => {
|
|
136
143
|
stopTokenRefresh();
|
|
144
|
+
sessionManager.stop();
|
|
137
145
|
});
|
|
138
146
|
|
|
139
147
|
server.on("error", (err) => reject(err));
|
|
@@ -236,7 +244,7 @@ async function handleRequest(
|
|
|
236
244
|
owned_by: "openclaw-cli-bridge",
|
|
237
245
|
// CLI-proxy models stream plain text — no tool/function call support
|
|
238
246
|
capabilities: {
|
|
239
|
-
tools: !(m.id.startsWith("cli-gemini/") || m.id.startsWith("cli-claude/") || m.id.startsWith("local-bitnet/")),
|
|
247
|
+
tools: !(m.id.startsWith("cli-gemini/") || m.id.startsWith("cli-claude/") || m.id.startsWith("openai-codex/") || m.id.startsWith("opencode/") || m.id.startsWith("pi/") || m.id.startsWith("local-bitnet/")),
|
|
240
248
|
},
|
|
241
249
|
})),
|
|
242
250
|
})
|
|
@@ -272,7 +280,8 @@ async function handleRequest(
|
|
|
272
280
|
return;
|
|
273
281
|
}
|
|
274
282
|
|
|
275
|
-
const { model, messages, stream = false } = parsed as { model: string; messages: ChatMessage[]; stream?: boolean; tools?: unknown };
|
|
283
|
+
const { model, messages, stream = false } = parsed as { model: string; messages: ChatMessage[]; stream?: boolean; tools?: unknown; workdir?: string };
|
|
284
|
+
const workdir = (parsed as { workdir?: string }).workdir;
|
|
276
285
|
const hasTools = Array.isArray((parsed as { tools?: unknown }).tools) && (parsed as { tools?: unknown[] }).tools!.length > 0;
|
|
277
286
|
|
|
278
287
|
if (!model || !messages?.length) {
|
|
@@ -284,7 +293,7 @@ async function handleRequest(
|
|
|
284
293
|
// CLI-proxy models (cli-gemini/*, cli-claude/*) are plain text completions —
|
|
285
294
|
// they cannot process tool/function call schemas. Return a clear 400 so
|
|
286
295
|
// OpenClaw can surface a meaningful error instead of getting a garbled response.
|
|
287
|
-
const isCliModel = model.startsWith("cli-gemini/") || model.startsWith("cli-claude/"); // local-bitnet/* exempt: llama-server silently ignores tools
|
|
296
|
+
const isCliModel = model.startsWith("cli-gemini/") || model.startsWith("cli-claude/") || model.startsWith("openai-codex/") || model.startsWith("opencode/") || model.startsWith("pi/"); // local-bitnet/* exempt: llama-server silently ignores tools
|
|
288
297
|
if (hasTools && isCliModel) {
|
|
289
298
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
290
299
|
res.end(JSON.stringify({
|
|
@@ -591,7 +600,7 @@ async function handleRequest(
|
|
|
591
600
|
let content: string;
|
|
592
601
|
let usedModel = model;
|
|
593
602
|
try {
|
|
594
|
-
content = await routeToCliRunner(model, messages, opts.timeoutMs ?? 120_000);
|
|
603
|
+
content = await routeToCliRunner(model, messages, opts.timeoutMs ?? 120_000, { workdir });
|
|
595
604
|
} catch (err) {
|
|
596
605
|
const msg = (err as Error).message;
|
|
597
606
|
// ── Model fallback: retry once with a lighter model if configured ────
|
|
@@ -599,7 +608,7 @@ async function handleRequest(
|
|
|
599
608
|
if (fallbackModel) {
|
|
600
609
|
opts.warn(`[cli-bridge] ${model} failed (${msg}), falling back to ${fallbackModel}`);
|
|
601
610
|
try {
|
|
602
|
-
content = await routeToCliRunner(fallbackModel, messages, opts.timeoutMs ?? 120_000);
|
|
611
|
+
content = await routeToCliRunner(fallbackModel, messages, opts.timeoutMs ?? 120_000, { workdir });
|
|
603
612
|
usedModel = fallbackModel;
|
|
604
613
|
opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded`);
|
|
605
614
|
} catch (fallbackErr) {
|
|
@@ -667,6 +676,116 @@ async function handleRequest(
|
|
|
667
676
|
return;
|
|
668
677
|
}
|
|
669
678
|
|
|
679
|
+
// ── Session Manager endpoints ──────────────────────────────────────────────
|
|
680
|
+
|
|
681
|
+
// POST /v1/sessions/spawn
|
|
682
|
+
if (url === "/v1/sessions/spawn" && req.method === "POST") {
|
|
683
|
+
const body = await readBody(req);
|
|
684
|
+
let parsed: { model: string; messages: ChatMessage[]; workdir?: string; timeout?: number };
|
|
685
|
+
try {
|
|
686
|
+
parsed = JSON.parse(body) as typeof parsed;
|
|
687
|
+
} catch {
|
|
688
|
+
res.writeHead(400, { "Content-Type": "application/json", ...corsHeaders() });
|
|
689
|
+
res.end(JSON.stringify({ error: { message: "Invalid JSON body", type: "invalid_request_error" } }));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (!parsed.model || !parsed.messages?.length) {
|
|
693
|
+
res.writeHead(400, { "Content-Type": "application/json", ...corsHeaders() });
|
|
694
|
+
res.end(JSON.stringify({ error: { message: "model and messages are required", type: "invalid_request_error" } }));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const sessionId = sessionManager.spawn(parsed.model, parsed.messages, {
|
|
698
|
+
workdir: parsed.workdir,
|
|
699
|
+
timeout: parsed.timeout,
|
|
700
|
+
});
|
|
701
|
+
opts.log(`[cli-bridge] session spawned: ${sessionId} (${parsed.model})`);
|
|
702
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
703
|
+
res.end(JSON.stringify({ sessionId }));
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// GET /v1/sessions — list all sessions
|
|
708
|
+
if (url === "/v1/sessions" && req.method === "GET") {
|
|
709
|
+
const sessions = sessionManager.list();
|
|
710
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
711
|
+
res.end(JSON.stringify({ sessions }));
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Session-specific endpoints: /v1/sessions/:id/*
|
|
716
|
+
const sessionMatch = url.match(/^\/v1\/sessions\/([a-f0-9]+)\/(poll|log|write|kill)$/);
|
|
717
|
+
if (sessionMatch) {
|
|
718
|
+
const sessionId = sessionMatch[1];
|
|
719
|
+
const action = sessionMatch[2];
|
|
720
|
+
|
|
721
|
+
if (action === "poll" && req.method === "GET") {
|
|
722
|
+
const result = sessionManager.poll(sessionId);
|
|
723
|
+
if (!result) {
|
|
724
|
+
res.writeHead(404, { "Content-Type": "application/json", ...corsHeaders() });
|
|
725
|
+
res.end(JSON.stringify({ error: { message: "Session not found", type: "not_found" } }));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
729
|
+
res.end(JSON.stringify(result));
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (action === "log" && req.method === "GET") {
|
|
734
|
+
// Parse ?offset=N from URL
|
|
735
|
+
const urlObj = new URL(url, `http://127.0.0.1:${opts.port}`);
|
|
736
|
+
const offset = parseInt(urlObj.searchParams.get("offset") ?? "0", 10) || 0;
|
|
737
|
+
const result = sessionManager.log(sessionId, offset);
|
|
738
|
+
if (!result) {
|
|
739
|
+
res.writeHead(404, { "Content-Type": "application/json", ...corsHeaders() });
|
|
740
|
+
res.end(JSON.stringify({ error: { message: "Session not found", type: "not_found" } }));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
744
|
+
res.end(JSON.stringify(result));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (action === "write" && req.method === "POST") {
|
|
749
|
+
const body = await readBody(req);
|
|
750
|
+
let parsed: { data: string };
|
|
751
|
+
try {
|
|
752
|
+
parsed = JSON.parse(body) as typeof parsed;
|
|
753
|
+
} catch {
|
|
754
|
+
res.writeHead(400, { "Content-Type": "application/json", ...corsHeaders() });
|
|
755
|
+
res.end(JSON.stringify({ error: { message: "Invalid JSON body", type: "invalid_request_error" } }));
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const ok = sessionManager.write(sessionId, parsed.data ?? "");
|
|
759
|
+
res.writeHead(ok ? 200 : 404, { "Content-Type": "application/json", ...corsHeaders() });
|
|
760
|
+
res.end(JSON.stringify({ ok }));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (action === "kill" && req.method === "POST") {
|
|
765
|
+
const ok = sessionManager.kill(sessionId);
|
|
766
|
+
res.writeHead(ok ? 200 : 404, { "Content-Type": "application/json", ...corsHeaders() });
|
|
767
|
+
res.end(JSON.stringify({ ok }));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Also handle /v1/sessions/:id/log with query params (URL match above doesn't capture query strings)
|
|
773
|
+
const logMatch = url.match(/^\/v1\/sessions\/([a-f0-9]+)\/log\?/);
|
|
774
|
+
if (logMatch && req.method === "GET") {
|
|
775
|
+
const sessionId = logMatch[1];
|
|
776
|
+
const urlObj = new URL(url, `http://127.0.0.1:${opts.port}`);
|
|
777
|
+
const offset = parseInt(urlObj.searchParams.get("offset") ?? "0", 10) || 0;
|
|
778
|
+
const result = sessionManager.log(sessionId, offset);
|
|
779
|
+
if (!result) {
|
|
780
|
+
res.writeHead(404, { "Content-Type": "application/json", ...corsHeaders() });
|
|
781
|
+
res.end(JSON.stringify({ error: { message: "Session not found", type: "not_found" } }));
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
785
|
+
res.end(JSON.stringify(result));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
670
789
|
// 404
|
|
671
790
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
672
791
|
res.end(JSON.stringify({ error: { message: `Not found: ${url}`, type: "not_found" } }));
|