@elvatis_com/openclaw-cli-bridge-elvatis 3.0.0 → 3.1.1
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 +11 -1
- package/SKILL.md +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/config.ts +14 -8
- package/src/debug-log.ts +1 -1
- package/src/proxy-server.ts +53 -23
- package/src/status-template.ts +7 -5
- package/test/config.test.ts +9 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code, OpenCode, Pi) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
|
|
4
4
|
|
|
5
|
-
**Current version:** `3.
|
|
5
|
+
**Current version:** `3.1.1`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -406,6 +406,16 @@ npm run ci # lint + typecheck + test
|
|
|
406
406
|
|
|
407
407
|
## Changelog
|
|
408
408
|
|
|
409
|
+
### v3.1.1
|
|
410
|
+
- **fix:** empty-response detection — models returning zero content now trigger the next fallback instead of silently stopping the chain. Previously Haiku would return empty (0 bytes) and the bridge treated it as success, leaving the user with no response.
|
|
411
|
+
- **feat:** `[EMPTY]` and `[FALLBACK-EMPTY]` debug log categories for diagnosing empty model responses
|
|
412
|
+
|
|
413
|
+
### v3.1.0
|
|
414
|
+
- **feat:** cross-provider fallback chains — Sonnet → Haiku → Gemini Flash → Codex (was single-model fallback only)
|
|
415
|
+
- **feat:** fallback chain loop — tries each model in order until one succeeds, logs each attempt
|
|
416
|
+
- **fix:** live logs newest-on-top — latest entries now appear at the top of the log viewer
|
|
417
|
+
- **feat:** SSE fallback notifications for each chain attempt so user sees what's happening
|
|
418
|
+
|
|
409
419
|
### v3.0.0
|
|
410
420
|
- **feat:** dashboard v2 — sidebar navigation with 9 sections (Overview, Providers, Active, Requests, Fallbacks, Sessions, Live Logs, Timeouts, Models)
|
|
411
421
|
- **feat:** live log viewer — SSE-powered real-time log streaming with color-coded categories, auto-scroll, pause/resume, 500-line client buffer
|
package/SKILL.md
CHANGED
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"slug": "openclaw-cli-bridge-elvatis",
|
|
4
4
|
"name": "OpenClaw CLI Bridge",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.1.1",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
|
|
8
8
|
"providers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
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": {
|
package/src/config.ts
CHANGED
|
@@ -138,15 +138,21 @@ export const DEFAULT_MODEL_TIMEOUTS: Record<string, number> = {
|
|
|
138
138
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
|
-
* Default fallback
|
|
142
|
-
*
|
|
141
|
+
* Default fallback chains: when a primary model fails (timeout, stale, error),
|
|
142
|
+
* try each fallback in order. Cross-provider chains ensure we use all available
|
|
143
|
+
* models instead of just falling back within one provider.
|
|
144
|
+
*
|
|
145
|
+
* Strategy: same-provider fast model first, then cross-provider alternatives.
|
|
143
146
|
*/
|
|
144
|
-
export const DEFAULT_MODEL_FALLBACKS: Record<string, string> = {
|
|
145
|
-
"cli-
|
|
146
|
-
"cli-
|
|
147
|
-
"cli-claude/claude-
|
|
148
|
-
"cli-
|
|
149
|
-
"gemini
|
|
147
|
+
export const DEFAULT_MODEL_FALLBACKS: Record<string, string[]> = {
|
|
148
|
+
"cli-claude/claude-opus-4-6": ["cli-claude/claude-sonnet-4-6", "cli-gemini/gemini-2.5-pro", "cli-claude/claude-haiku-4-5"],
|
|
149
|
+
"cli-claude/claude-sonnet-4-6": ["cli-claude/claude-haiku-4-5", "cli-gemini/gemini-2.5-flash", "openai-codex/gpt-5.3-codex"],
|
|
150
|
+
"cli-claude/claude-haiku-4-5": ["cli-gemini/gemini-2.5-flash", "openai-codex/gpt-5.1-codex-mini"],
|
|
151
|
+
"cli-gemini/gemini-2.5-pro": ["cli-gemini/gemini-2.5-flash", "cli-claude/claude-haiku-4-5"],
|
|
152
|
+
"cli-gemini/gemini-3-pro-preview": ["cli-gemini/gemini-3-flash-preview", "cli-gemini/gemini-2.5-flash"],
|
|
153
|
+
"openai-codex/gpt-5.4": ["openai-codex/gpt-5.3-codex", "cli-claude/claude-haiku-4-5"],
|
|
154
|
+
"openai-codex/gpt-5.3-codex": ["openai-codex/gpt-5.1-codex-mini", "cli-gemini/gemini-2.5-flash"],
|
|
155
|
+
"gemini-api/gemini-2.5-pro": ["gemini-api/gemini-2.5-flash"],
|
|
150
156
|
};
|
|
151
157
|
|
|
152
158
|
// ──────────────────────────────────────────────────────────────────────────────
|
package/src/debug-log.ts
CHANGED
|
@@ -62,7 +62,7 @@ export function getLogTail(lines = 100): string | null {
|
|
|
62
62
|
try {
|
|
63
63
|
const content = readFileSync(LOG_FILE, "utf8");
|
|
64
64
|
const allLines = content.split("\n").filter(Boolean);
|
|
65
|
-
return allLines.slice(-lines).join("\n");
|
|
65
|
+
return allLines.slice(-lines).reverse().join("\n");
|
|
66
66
|
} catch {
|
|
67
67
|
return null;
|
|
68
68
|
}
|
package/src/proxy-server.ts
CHANGED
|
@@ -117,7 +117,7 @@ export interface ProxyServerOptions {
|
|
|
117
117
|
* When a CLI model fails (timeout, error), the request is retried once
|
|
118
118
|
* with the fallback model. Example: "cli-gemini/gemini-2.5-pro" → "cli-gemini/gemini-2.5-flash"
|
|
119
119
|
*/
|
|
120
|
-
modelFallbacks?: Record<string, string>;
|
|
120
|
+
modelFallbacks?: Record<string, string | string[]>;
|
|
121
121
|
/**
|
|
122
122
|
* Per-model timeout overrides (ms). Keys are model IDs (without "vllm/" prefix).
|
|
123
123
|
* Use this to give heavy models more time or limit fast models.
|
|
@@ -905,6 +905,12 @@ async function handleRequest(
|
|
|
905
905
|
try {
|
|
906
906
|
result = await routeToCliRunner(usedModel, cleanMessages, effectiveTimeout, routeOpts);
|
|
907
907
|
const latencyMs = Date.now() - cliStart;
|
|
908
|
+
const hasContent = !!(result.content?.trim()) || !!(result.tool_calls?.length);
|
|
909
|
+
// Empty response = model returned nothing useful. Treat as error to trigger fallback.
|
|
910
|
+
if (!hasContent) {
|
|
911
|
+
debugLog("EMPTY", `${usedModel} returned empty after ${(latencyMs / 1000).toFixed(1)}s`, {});
|
|
912
|
+
throw new Error(`empty response: ${usedModel} returned no content and no tool_calls`);
|
|
913
|
+
}
|
|
908
914
|
const estCompletionTokens = estimateTokens(result.content ?? "");
|
|
909
915
|
metrics.recordRequest(usedModel, latencyMs, true, estPromptTokens, estCompletionTokens, promptPreview);
|
|
910
916
|
providerSessions.recordRun(session.id, false);
|
|
@@ -917,36 +923,60 @@ async function handleRequest(
|
|
|
917
923
|
debugLog("FAIL", `${model} failed after ${(primaryDuration / 1000).toFixed(1)}s`, { isTimeout, error: msg.slice(0, 200) });
|
|
918
924
|
// Record the run (with timeout flag) — session is preserved, not deleted
|
|
919
925
|
providerSessions.recordRun(session.id, isTimeout);
|
|
920
|
-
|
|
921
|
-
|
|
926
|
+
// ── Multi-model fallback chain: try each fallback in order ──────────
|
|
927
|
+
// Chains cross providers: Sonnet → Haiku → Gemini Flash → Codex
|
|
928
|
+
const rawFallbacks = opts.modelFallbacks?.[model];
|
|
929
|
+
const fallbackChain: string[] = Array.isArray(rawFallbacks) ? rawFallbacks
|
|
930
|
+
: typeof rawFallbacks === "string" ? [rawFallbacks]
|
|
931
|
+
: [];
|
|
932
|
+
|
|
933
|
+
if (fallbackChain.length > 0) {
|
|
922
934
|
metrics.recordRequest(model, primaryDuration, false, estPromptTokens, undefined, promptPreview);
|
|
923
935
|
const reason = isTimeout ? `timeout by supervisor, session=${session.id} preserved` : msg;
|
|
924
|
-
opts.warn(`[cli-bridge] ${model} failed (${reason}),
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
936
|
+
opts.warn(`[cli-bridge] ${model} failed (${reason}), trying fallback chain: ${fallbackChain.join(" → ")}`);
|
|
937
|
+
|
|
938
|
+
let chainSuccess = false;
|
|
939
|
+
for (const fallbackModel of fallbackChain) {
|
|
940
|
+
debugLog("FALLBACK", `${model} → ${fallbackModel}`, { reason: isTimeout ? "timeout" : "error", primaryDuration: Math.round(primaryDuration / 1000), chain: fallbackChain });
|
|
941
|
+
if (sseHeadersSent) {
|
|
942
|
+
res.write(`: fallback — trying ${fallbackModel}\n\n`);
|
|
943
|
+
}
|
|
944
|
+
const fallbackStart = Date.now();
|
|
945
|
+
try {
|
|
946
|
+
result = await routeToCliRunner(fallbackModel, cleanMessages, effectiveTimeout, routeOpts);
|
|
947
|
+
const fbHasContent = !!(result.content?.trim()) || !!(result.tool_calls?.length);
|
|
948
|
+
if (!fbHasContent) {
|
|
949
|
+
debugLog("FALLBACK-EMPTY", `${fallbackModel} returned empty`, {});
|
|
950
|
+
throw new Error(`empty response from ${fallbackModel}`);
|
|
951
|
+
}
|
|
952
|
+
const fbCompTokens = estimateTokens(result.content ?? "");
|
|
953
|
+
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens, promptPreview);
|
|
954
|
+
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, true);
|
|
955
|
+
usedModel = fallbackModel;
|
|
956
|
+
debugLog("FALLBACK-OK", `${fallbackModel} succeeded in ${((Date.now() - fallbackStart) / 1000).toFixed(1)}s`, { toolCalls: result.tool_calls?.length ?? 0 });
|
|
957
|
+
opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded`);
|
|
958
|
+
chainSuccess = true;
|
|
959
|
+
break;
|
|
960
|
+
} catch (fallbackErr) {
|
|
961
|
+
const fbDuration = Date.now() - fallbackStart;
|
|
962
|
+
metrics.recordRequest(fallbackModel, fbDuration, false, estPromptTokens, undefined, promptPreview);
|
|
963
|
+
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, false);
|
|
964
|
+
const fallbackMsg = (fallbackErr as Error).message;
|
|
965
|
+
debugLog("FALLBACK-FAIL", `${fallbackModel} failed after ${(fbDuration / 1000).toFixed(1)}s`, { error: fallbackMsg.slice(0, 150) });
|
|
966
|
+
opts.warn(`[cli-bridge] fallback ${fallbackModel} failed: ${fallbackMsg.slice(0, 100)}`);
|
|
967
|
+
// Continue to next fallback in chain
|
|
968
|
+
}
|
|
929
969
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
const fbCompTokens = estimateTokens(result.content ?? "");
|
|
934
|
-
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens, promptPreview);
|
|
935
|
-
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, true);
|
|
936
|
-
usedModel = fallbackModel;
|
|
937
|
-
opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded (response will report original model: ${model})`);
|
|
938
|
-
} catch (fallbackErr) {
|
|
939
|
-
metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens, undefined, promptPreview);
|
|
940
|
-
metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, false);
|
|
941
|
-
const fallbackMsg = (fallbackErr as Error).message;
|
|
942
|
-
opts.warn(`[cli-bridge] fallback ${fallbackModel} also failed: ${fallbackMsg}`);
|
|
970
|
+
|
|
971
|
+
if (!chainSuccess) {
|
|
972
|
+
const chainStr = fallbackChain.join(", ");
|
|
943
973
|
if (sseHeadersSent) {
|
|
944
|
-
res.write(`data: ${JSON.stringify({ error: { message: `${model}
|
|
974
|
+
res.write(`data: ${JSON.stringify({ error: { message: `${model} and all fallbacks (${chainStr}) failed`, type: "cli_error" } })}\n\n`);
|
|
945
975
|
res.write("data: [DONE]\n\n");
|
|
946
976
|
res.end();
|
|
947
977
|
} else {
|
|
948
978
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
949
|
-
res.end(JSON.stringify({ error: { message: `${model}
|
|
979
|
+
res.end(JSON.stringify({ error: { message: `${model} and all fallbacks (${chainStr}) failed`, type: "cli_error" } }));
|
|
950
980
|
}
|
|
951
981
|
return;
|
|
952
982
|
}
|
package/src/status-template.ts
CHANGED
|
@@ -694,18 +694,20 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
694
694
|
function appendLog(text) {
|
|
695
695
|
if (!logOutput) return;
|
|
696
696
|
var lines = text.split('\\n').filter(function(l) { return l.trim(); });
|
|
697
|
+
// Newest on top — prepend lines in reverse order
|
|
698
|
+
var html = '';
|
|
697
699
|
lines.forEach(function(line) {
|
|
698
|
-
|
|
700
|
+
html = colorLogLine(line.replace(/</g, '<').replace(/>/g, '>')) + '\\n' + html;
|
|
699
701
|
logLineCount++;
|
|
700
702
|
});
|
|
701
|
-
|
|
703
|
+
logOutput.innerHTML = html + logOutput.innerHTML;
|
|
704
|
+
// Trim old lines from bottom
|
|
702
705
|
while (logLineCount > MAX_LOG_LINES) {
|
|
703
|
-
var idx = logOutput.innerHTML.
|
|
706
|
+
var idx = logOutput.innerHTML.lastIndexOf('\\n');
|
|
704
707
|
if (idx === -1) break;
|
|
705
|
-
logOutput.innerHTML = logOutput.innerHTML.slice(idx
|
|
708
|
+
logOutput.innerHTML = logOutput.innerHTML.slice(0, idx);
|
|
706
709
|
logLineCount--;
|
|
707
710
|
}
|
|
708
|
-
if (autoScroll) logOutput.scrollTop = logOutput.scrollHeight;
|
|
709
711
|
}
|
|
710
712
|
|
|
711
713
|
function connectLog() {
|
package/test/config.test.ts
CHANGED
|
@@ -71,11 +71,15 @@ describe("config.ts exports", () => {
|
|
|
71
71
|
expect(DEFAULT_MODEL_TIMEOUTS["gemini-api/gemini-2.5-flash"]).toBe(180_000);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("exports model fallback chains", () => {
|
|
75
|
-
expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-sonnet-4-6"]).
|
|
76
|
-
expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-opus-4-6"]).
|
|
77
|
-
expect(DEFAULT_MODEL_FALLBACKS["cli-gemini/gemini-2.5-pro"]).
|
|
78
|
-
expect(DEFAULT_MODEL_FALLBACKS["gemini-api/gemini-2.5-pro"]).
|
|
74
|
+
it("exports model fallback chains as arrays", () => {
|
|
75
|
+
expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-sonnet-4-6"]).toEqual(["cli-claude/claude-haiku-4-5", "cli-gemini/gemini-2.5-flash", "openai-codex/gpt-5.3-codex"]);
|
|
76
|
+
expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-opus-4-6"]).toContain("cli-claude/claude-sonnet-4-6");
|
|
77
|
+
expect(DEFAULT_MODEL_FALLBACKS["cli-gemini/gemini-2.5-pro"]).toContain("cli-gemini/gemini-2.5-flash");
|
|
78
|
+
expect(DEFAULT_MODEL_FALLBACKS["gemini-api/gemini-2.5-pro"]).toContain("gemini-api/gemini-2.5-flash");
|
|
79
|
+
// All values must be arrays
|
|
80
|
+
for (const chain of Object.values(DEFAULT_MODEL_FALLBACKS)) {
|
|
81
|
+
expect(Array.isArray(chain)).toBe(true);
|
|
82
|
+
}
|
|
79
83
|
});
|
|
80
84
|
|
|
81
85
|
it("exports path constants rooted in ~/.openclaw", () => {
|