@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 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.0.0`
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
@@ -68,4 +68,4 @@ On gateway restart, if any session has expired, a **WhatsApp alert** is sent aut
68
68
 
69
69
  See `README.md` for full configuration reference and architecture diagram.
70
70
 
71
- **Version:** 3.0.0
71
+ **Version:** 3.1.1
@@ -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.0.0",
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.0.0",
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 chain: when a primary model fails (timeout, error),
142
- * retry once with the lighter variant.
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-gemini/gemini-2.5-pro": "cli-gemini/gemini-2.5-flash",
146
- "cli-gemini/gemini-3-pro-preview": "cli-gemini/gemini-3-flash-preview",
147
- "cli-claude/claude-opus-4-6": "cli-claude/claude-sonnet-4-6",
148
- "cli-claude/claude-sonnet-4-6": "cli-claude/claude-haiku-4-5",
149
- "gemini-api/gemini-2.5-pro": "gemini-api/gemini-2.5-flash",
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
  }
@@ -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
- const fallbackModel = opts.modelFallbacks?.[model];
921
- if (fallbackModel) {
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}), falling back to ${fallbackModel}`);
925
- debugLog("FALLBACK", `${model} → ${fallbackModel}`, { reason: isTimeout ? "timeout" : "error", primaryDuration: Math.round(primaryDuration / 1000) });
926
- // Notify the user via SSE that we're retrying with a different model
927
- if (sseHeadersSent) {
928
- res.write(`: fallback ${model} ${isTimeout ? "timed out" : "failed"} after ${Math.round(primaryDuration / 1000)}s, retrying with ${fallbackModel}\n\n`);
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
- const fallbackStart = Date.now();
931
- try {
932
- result = await routeToCliRunner(fallbackModel, cleanMessages, effectiveTimeout, routeOpts);
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}: ${msg} | fallback ${fallbackModel}: ${fallbackMsg}`, type: "cli_error" } })}\n\n`);
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}: ${msg} | fallback ${fallbackModel}: ${fallbackMsg}`, type: "cli_error" } }));
979
+ res.end(JSON.stringify({ error: { message: `${model} and all fallbacks (${chainStr}) failed`, type: "cli_error" } }));
950
980
  }
951
981
  return;
952
982
  }
@@ -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
- logOutput.innerHTML += colorLogLine(line.replace(/</g, '&lt;').replace(/>/g, '&gt;')) + '\\n';
700
+ html = colorLogLine(line.replace(/</g, '&lt;').replace(/>/g, '&gt;')) + '\\n' + html;
699
701
  logLineCount++;
700
702
  });
701
- // Trim old lines
703
+ logOutput.innerHTML = html + logOutput.innerHTML;
704
+ // Trim old lines from bottom
702
705
  while (logLineCount > MAX_LOG_LINES) {
703
- var idx = logOutput.innerHTML.indexOf('\\n');
706
+ var idx = logOutput.innerHTML.lastIndexOf('\\n');
704
707
  if (idx === -1) break;
705
- logOutput.innerHTML = logOutput.innerHTML.slice(idx + 1);
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() {
@@ -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"]).toBe("cli-claude/claude-haiku-4-5");
76
- expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-opus-4-6"]).toBe("cli-claude/claude-sonnet-4-6");
77
- expect(DEFAULT_MODEL_FALLBACKS["cli-gemini/gemini-2.5-pro"]).toBe("cli-gemini/gemini-2.5-flash");
78
- expect(DEFAULT_MODEL_FALLBACKS["gemini-api/gemini-2.5-pro"]).toBe("gemini-api/gemini-2.5-flash");
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", () => {