openclaw-abacusai-auth 1.2.7 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/index.ts +322 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -298,6 +298,14 @@ If AbacusAI models are not available, ensure the plugin is installed:
298
298
  openclaw plugins install openclaw-abacusai-auth
299
299
  ```
300
300
 
301
+ ### Webchat no response (Zombie Proxy)
302
+
303
+ If you send messages in Webchat and receive no response, the Gateway may be attempting to communicate with a stale proxy port. This occurs when OpenClaw's internal `openclaw.json` registers `18862`, but a zombie plugin process from a previous run is holding `18862`, forcing the new plugin to dynamically increment its port to `18863` or higher. The Gateway sends requests to `18862` (the zombie proxy, which doesn't know how to route current requests), resulting in hanging chats.
304
+
305
+ **Solution (v1.2.8+)**:
306
+ The plugin now implements a self-healing `/__kill` HTTP endpoint. If the plugin detects `EADDRINUSE` during startup on `18862`, it aggressively kills the legacy zombie proxy holding the port, waits 1 second, and successfully binds to `18862`. You should no longer experience Webchat unresponsiveness due to port drifting.
307
+ If it ever hangs, simply restart your Gateway using standard `Ctrl+C` or `openclaw gateway stop` to ensure the hook securely cleans up the process.
308
+
301
309
  ---
302
310
 
303
311
  ## Getting an API Key
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync, writeFileSync, appendFileSync } from "node:fs";
2
2
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
@@ -480,6 +480,8 @@ function normalizeMessagesForRouteLLM(messages: unknown[]): unknown[] {
480
480
  return messages.map((msg) => {
481
481
  if (!msg || typeof msg !== "object") return msg;
482
482
  const m = msg as Record<string, unknown>;
483
+
484
+ // Handle assistant tool calls
483
485
  if (m.role !== "assistant" || !Array.isArray(m.tool_calls)) return msg;
484
486
 
485
487
  const normalized = { ...m };
@@ -510,6 +512,94 @@ function normalizeMessagesForRouteLLM(messages: unknown[]): unknown[] {
510
512
  });
511
513
  }
512
514
 
515
+ /**
516
+ * Flatten multi-turn conversation history for OpenAI models routed via RouteLLM.
517
+ *
518
+ * ROOT CAUSE: RouteLLM internally converts Chat Completions → OpenAI Responses
519
+ * API for newer OpenAI models (GPT-4o, GPT-5, o-series, etc.). Its converter
520
+ * incorrectly maps ALL message content blocks to `type: "input_text"`, but
521
+ * OpenAI's Responses API requires `type: "output_text"` for assistant/model
522
+ * output messages. This causes HTTP 400 on ANY multi-turn conversation.
523
+ *
524
+ * WORKAROUND: Embed the entire conversation history as plain text inside the
525
+ * system prompt, so the final payload contains ONLY `system` + `user` messages.
526
+ * With no `role: "assistant"` messages, RouteLLM's converter never produces the
527
+ * invalid `output_text` blocks. The model's current-turn tool definitions
528
+ * remain intact.
529
+ *
530
+ * This is only applied to OpenAI models; other providers (Claude, Gemini, etc.)
531
+ * pass through unchanged.
532
+ */
533
+ function flattenHistoryForOpenAIModels(messages: unknown[], model: string): unknown[] {
534
+ // Only apply to OpenAI models that hit the Responses API path in RouteLLM
535
+ const isOpenAIModel = /^(gpt-|o1-|o3-|o4-|chatgpt-)/i.test(model);
536
+ if (!isOpenAIModel) return messages;
537
+
538
+ const msgs = messages as Array<Record<string, unknown>>;
539
+
540
+ // Separate system messages from conversation
541
+ const systemMsgs = msgs.filter((m) => m.role === "system");
542
+ const convMsgs = msgs.filter((m) => m.role !== "system");
543
+
544
+ // If there are no assistant messages, nothing to flatten
545
+ const hasAssistant = convMsgs.some((m) => m.role === "assistant");
546
+ if (!hasAssistant) return messages;
547
+
548
+ // Find the LAST user message – this becomes the sole user message
549
+ let lastUserIdx = -1;
550
+ for (let i = convMsgs.length - 1; i >= 0; i--) {
551
+ if (convMsgs[i].role === "user") {
552
+ lastUserIdx = i;
553
+ break;
554
+ }
555
+ }
556
+ if (lastUserIdx < 0) return messages; // No user message, pass through
557
+
558
+ // Build textual history from all messages BEFORE the last user message
559
+ const historyParts: string[] = [];
560
+ for (let i = 0; i < lastUserIdx; i++) {
561
+ const m = convMsgs[i];
562
+ const role = m.role === "assistant" ? "Assistant" : m.role === "tool" ? "Tool-Result" : "User";
563
+ let content: string;
564
+ if (typeof m.content === "string") {
565
+ content = m.content;
566
+ } else if (m.content) {
567
+ content = JSON.stringify(m.content);
568
+ } else if (Array.isArray(m.tool_calls)) {
569
+ const calls = (m.tool_calls as Array<Record<string, unknown>>).map((tc) => {
570
+ const fn = tc.function as Record<string, unknown> | undefined;
571
+ return fn ? `${fn.name}(${fn.arguments || "{}"})` : JSON.stringify(tc);
572
+ });
573
+ content = `[Called tools: ${calls.join(", ")}]`;
574
+ } else {
575
+ content = "(empty)";
576
+ }
577
+ // Truncate very long messages
578
+ if (content.length > 3000) {
579
+ content = content.slice(0, 3000) + "... [truncated]";
580
+ }
581
+ historyParts.push(`${role}: ${content}`);
582
+ }
583
+
584
+ // Build the flattened system prompt
585
+ const originalSystem = systemMsgs.map((m) =>
586
+ typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "")
587
+ ).join("\n\n");
588
+
589
+ const historyBlock = historyParts.length > 0
590
+ ? "\n\n<conversation_history>\n" + historyParts.join("\n\n") + "\n</conversation_history>"
591
+ : "";
592
+
593
+ const lastUserContent = typeof convMsgs[lastUserIdx].content === "string"
594
+ ? convMsgs[lastUserIdx].content as string
595
+ : JSON.stringify(convMsgs[lastUserIdx].content ?? "");
596
+
597
+ return [
598
+ { role: "system", content: originalSystem + historyBlock },
599
+ { role: "user", content: lastUserContent },
600
+ ];
601
+ }
602
+
513
603
  /**
514
604
  * Normalize a single tool_call from RouteLLM response to OpenAI standard format.
515
605
  * RouteLLM may return tool_calls with flat `name`/`parameters` instead of nested `function`.
@@ -570,12 +660,64 @@ async function handleProxyRequest(req: IncomingMessage, res: ServerResponse) {
570
660
 
571
661
  async function handleProxyRequestInner(req: IncomingMessage, res: ServerResponse) {
572
662
  const path = req.url ?? "/";
573
- const target = `${ROUTELLM_BASE}${path}`;
663
+
664
+ if (path === "/__kill") {
665
+ console.log("[abacusai] Received /__kill command, stopping zombie proxy...");
666
+ sendJsonResponse(res, 200, { success: true });
667
+ // Execute stop proxy asynchronously after sending response
668
+ setTimeout(() => {
669
+ stopProxy().catch(() => process.exit(0));
670
+ }, 100);
671
+ return;
672
+ }
673
+
674
+ let targetPath = path === "/" ? "" : path;
675
+ if (targetPath.startsWith("/v1/")) {
676
+ targetPath = targetPath.slice(3); // e.g., "/v1/models" -> "/models"
677
+ }
678
+
574
679
  const headers: Record<string, string> = {
575
680
  Authorization: `Bearer ${proxyApiKey}`,
576
681
  "Content-Type": "application/json",
577
682
  };
578
683
 
684
+ // Intercept /models requests to return standard OpenAI format
685
+ if (targetPath === "/models") {
686
+ try {
687
+ const { response: upstream, release } = await fetchWithSsrFGuard({
688
+ url: `${ROUTELLM_BASE}/models`,
689
+ init: { method: "GET", headers },
690
+ timeoutMs: 30_000,
691
+ });
692
+ const data = await upstream.text();
693
+ await release();
694
+
695
+ let json;
696
+ try {
697
+ json = JSON.parse(data);
698
+ } catch (e) {
699
+ sendJsonResponse(res, 500, { error: { message: "Invalid JSON from RouteLLM /models" } });
700
+ return;
701
+ }
702
+
703
+ // Ensure it has { object: "list", data: [...] } where each model has { object: "model" }
704
+ const models = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
705
+ const normalizedModels = models.map((m: any) => ({
706
+ ...m,
707
+ object: "model"
708
+ }));
709
+
710
+ sendJsonResponse(res, 200, {
711
+ object: "list",
712
+ data: normalizedModels
713
+ });
714
+ } catch (err: any) {
715
+ sendJsonResponse(res, 500, { error: { message: `Failed to fetch models: ${err.message}` } });
716
+ }
717
+ return;
718
+ }
719
+
720
+ const target = `${ROUTELLM_BASE}${targetPath}`;
579
721
  let body: string | undefined;
580
722
  if (req.method === "POST") {
581
723
  const raw = await readBody(req);
@@ -583,13 +725,33 @@ async function handleProxyRequestInner(req: IncomingMessage, res: ServerResponse
583
725
  // Normalize tools for RouteLLM: remove `strict` field, clean schemas
584
726
  // (remove patternProperties, add additionalProperties: false, etc.)
585
727
 
728
+ // Log the request for debugging
729
+ try {
730
+ const logEntry = `[${new Date().toISOString()}] ${req.method} ${targetPath} model=${parsed.model || "?"}\n`;
731
+ appendFileSync("C:/tmp/proxy-requests.log", logEntry);
732
+ } catch (e) { /* ignore */ }
586
733
 
587
734
  if (Array.isArray(parsed.tools)) {
588
735
  parsed.tools = normalizeToolsForRouteLLM(parsed.tools);
589
736
  }
590
737
  // Normalize tool_calls in messages: add top-level name/parameters for RouteLLM
591
738
  if (Array.isArray(parsed.messages)) {
739
+ // Log message roles BEFORE transformation
740
+ const rolesBefore = (parsed.messages as any[]).map((m: any) => m?.role || "?").join(",");
741
+
592
742
  parsed.messages = normalizeMessagesForRouteLLM(parsed.messages);
743
+ // Flatten conversation history for OpenAI models to work around
744
+ // RouteLLM's buggy Chat Completions → Responses API converter
745
+ const modelName = typeof parsed.model === "string" ? parsed.model : "";
746
+ parsed.messages = flattenHistoryForOpenAIModels(parsed.messages as unknown[], modelName);
747
+
748
+ // Log message roles AFTER transformation
749
+ const rolesAfter = (parsed.messages as any[]).map((m: any) => m?.role || "?").join(",");
750
+ const hasAssistant = (parsed.messages as any[]).some((m: any) => m?.role === "assistant");
751
+ try {
752
+ appendFileSync("C:/tmp/proxy-requests.log",
753
+ ` BEFORE: ${rolesBefore}\n AFTER: ${rolesAfter}\n hasAssistant=${hasAssistant} model=${modelName}\n`);
754
+ } catch (e) { /* ignore */ }
593
755
  }
594
756
  body = JSON.stringify(parsed);
595
757
  }
@@ -703,33 +865,50 @@ function startProxy(apiKey: string): Promise<void> {
703
865
  });
704
866
  });
705
867
 
706
- // Try fixed port first, then retry with port+1, +2, etc.
707
- const tryListen = (port: number, attempt: number) => {
868
+ let killAttempts = 0;
869
+ const tryListen = (port: number) => {
708
870
  proxyServer!.listen(port, PROXY_HOST, () => {
709
871
  proxyPort = port;
710
872
  console.log(`[abacusai] proxy listening on http://${PROXY_HOST}:${proxyPort}`);
711
873
  resolve();
712
874
  });
713
875
  proxyServer!.once("error", (err: NodeJS.ErrnoException) => {
714
- if (err.code === "EADDRINUSE" && attempt < 10) {
715
- console.log(`[abacusai] port ${port} in use, trying ${port + 1}...`);
876
+ if (err.code === "EADDRINUSE") {
877
+ console.log(`[abacusai] port ${port} in use. Attempting to kill zombie proxy...`);
878
+ killAttempts++;
879
+ if (killAttempts > 5) {
880
+ console.error("[abacusai] Could not kill zombie proxy after multiple attempts.");
881
+ reject(new Error("EADDRINUSE on port 18862 and cannot kill zombie proxy."));
882
+ return;
883
+ }
716
884
  proxyServer!.removeAllListeners("error");
717
- proxyServer!.close(() => {
885
+
886
+ // Try to kill the zombie proxy by sending it the /__kill command
887
+ const { request } = require("node:http");
888
+ const req = request(`http://${PROXY_HOST}:${port}/__kill`, { method: 'GET' }, (res: IncomingMessage) => {
889
+ res.resume();
890
+ });
891
+ req.on('error', () => { }); // Ignore network errors
892
+ req.end();
893
+
894
+ console.log(`[abacusai] Waiting 1s for port ${port} to free up...`);
895
+ setTimeout(() => {
896
+ // Create fresh proxyServer to avoid closed state issues
718
897
  proxyServer = createServer((req, res) => {
719
898
  handleProxyRequest(req, res).catch((e) => {
720
899
  console.error("[abacusai] proxy error:", e);
721
900
  sendJsonResponse(res, 500, { error: { message: String(e) } });
722
901
  });
723
902
  });
724
- tryListen(port + 1, attempt + 1);
725
- });
903
+ tryListen(port);
904
+ }, 1000);
726
905
  } else {
727
906
  reject(err);
728
907
  }
729
908
  });
730
909
  };
731
910
 
732
- tryListen(PROXY_PORT_DEFAULT, 0);
911
+ tryListen(PROXY_PORT_DEFAULT);
733
912
  });
734
913
  }
735
914
 
@@ -854,7 +1033,85 @@ function updateBaseUrlInConfig(pluginApi: any): void {
854
1033
  }
855
1034
 
856
1035
  // ---------------------------------------------------------------------------
857
- // Plugin
1036
+ // Dynamic Model Updater
1037
+ // ---------------------------------------------------------------------------
1038
+
1039
+ async function updateRouteLlmModels(pluginApi: any): Promise<string> {
1040
+ try {
1041
+ const res = await fetch("https://routellm.abacus.ai/v1/models");
1042
+ if (!res.ok) {
1043
+ throw new Error(`HTTP error! status: ${res.status}`);
1044
+ }
1045
+ const data = await res.json();
1046
+ if (!data.data || !Array.isArray(data.data)) {
1047
+ throw new Error("Invalid response format from RouteLLM");
1048
+ }
1049
+
1050
+ const fetchedModelIds = data.data.map((m: any) => m.id);
1051
+ const newModels = fetchedModelIds.map(buildModelDefinition);
1052
+
1053
+ // 1. Update in-memory configuration
1054
+ if (pluginApi.config?.models?.providers?.abacusai) {
1055
+ pluginApi.config.models.providers.abacusai.models = newModels;
1056
+ }
1057
+
1058
+ // 2. Persist to openclaw.json
1059
+ const stateDir = process.env.OPENCLAW_STATE_DIR || join(homedir(), ".openclaw");
1060
+ const configPath = join(stateDir, "openclaw.json");
1061
+ if (existsSync(configPath)) {
1062
+ const configStr = readFileSync(configPath, "utf8");
1063
+ const configObj = JSON.parse(configStr);
1064
+ let updatedConfig = false;
1065
+
1066
+ if (configObj.models?.providers?.abacusai) {
1067
+ configObj.models.providers.abacusai.models = newModels;
1068
+ updatedConfig = true;
1069
+ }
1070
+
1071
+ // Sync with agents.defaults.models so they appear in chat dropdown immediately
1072
+ if (configObj.agents?.defaults) {
1073
+ if (!configObj.agents.defaults.models) {
1074
+ configObj.agents.defaults.models = {};
1075
+ }
1076
+
1077
+ const prefixedFetchedIds = fetchedModelIds.map((id: string) => `abacusai/${id}`);
1078
+
1079
+ // 2a. Purge outdated models
1080
+ const existingAgentModels = Object.keys(configObj.agents.defaults.models);
1081
+ for (const modelId of existingAgentModels) {
1082
+ if (modelId.startsWith("abacusai/") && !prefixedFetchedIds.includes(modelId)) {
1083
+ delete configObj.agents.defaults.models[modelId];
1084
+ updatedConfig = true;
1085
+ }
1086
+ }
1087
+
1088
+ // 2b. Add new models
1089
+ for (const modelId of prefixedFetchedIds) {
1090
+ if (!configObj.agents.defaults.models[modelId]) {
1091
+ configObj.agents.defaults.models[modelId] = {};
1092
+ updatedConfig = true;
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ if (updatedConfig) {
1098
+ writeFileSync(configPath, JSON.stringify(configObj, null, 2), "utf8");
1099
+
1100
+ // Tell OpenClaw to hot-reload the configuration so the model list reflects in the UI
1101
+ if (typeof (pluginApi as any).reloadConfig === "function") {
1102
+ (pluginApi as any).reloadConfig();
1103
+ }
1104
+ }
1105
+ }
1106
+ return `✅ Successfully fetched and updated ${newModels.length} models from AbacusAI RouteLLM.`;
1107
+ } catch (err: any) {
1108
+ console.error("[abacusai] Failed to update models:", err);
1109
+ return `❌ Failed to update models: ${err.message}`;
1110
+ }
1111
+ }
1112
+
1113
+ // ---------------------------------------------------------------------------
1114
+ // Plugin Entry Point
858
1115
  // ---------------------------------------------------------------------------
859
1116
 
860
1117
  // Type definitions for plugin API
@@ -885,22 +1142,68 @@ const abacusaiPlugin = {
885
1142
  config?: {
886
1143
  models?: { providers?: { abacusai?: { compat?: { supportsStrictMode?: boolean } } } };
887
1144
  };
1145
+ registerCommand?: (command: { name: string; description: string; handler: Function; execute?: Function }) => void;
888
1146
  };
889
1147
 
890
1148
  // ================================================================
891
1149
  // Register gateway_stop hook for graceful proxy shutdown
892
1150
  // ================================================================
893
1151
  if (typeof pluginApi.registerHook === "function") {
894
- pluginApi.registerHook(
895
- "gateway_stop",
896
- async () => {
897
- await stopProxy();
898
- },
899
- { name: "openclaw-abacusai-auth:gateway-stop" },
900
- );
1152
+ pluginApi.registerHook("gateway_stop", async () => {
1153
+ console.log("[abacusai] gateway_stop hook triggered, stopping proxy gracefully...");
1154
+ await stopProxy();
1155
+ });
1156
+ }
1157
+
1158
+ // ================================================================
1159
+ // Register chat command for updating models
1160
+ // ================================================================
1161
+ if (typeof pluginApi.registerCommand === "function") {
1162
+ try {
1163
+ pluginApi.registerCommand({
1164
+ name: "abacusai",
1165
+ description: "AbacusAI utilities (e.g. /abacusai pull-models)",
1166
+ // OpenClaw SDK typically accepts a handler function for the command
1167
+ handler: async (ctx: any) => {
1168
+ // Fallback string matching to capture args
1169
+ const contentStr = (ctx?.content || "").trim();
1170
+ const argsStr = ctx?.args ? ctx.args.join(" ") : contentStr;
1171
+ const isPull = argsStr === "pull-models" || argsStr === "" || argsStr.includes("pull-models");
1172
+
1173
+ // Dump api keys for debugging
1174
+ try {
1175
+ writeFileSync("C:/tmp/api-keys.txt", Object.keys(pluginApi).join(", "), "utf8");
1176
+ } catch (e) { }
1177
+
1178
+ if (isPull) {
1179
+ const res = await updateRouteLlmModels(pluginApi);
1180
+ try { appendFileSync("C:/tmp/abacus-update.log", res + "\n"); } catch (e) { }
1181
+ return { text: res };
1182
+ }
1183
+ return { text: `Unknown subcommand: ${argsStr}. Usage: /abacusai pull-models` };
1184
+ },
1185
+ // Just in case it's named 'execute' or 'run' in different SDK versions
1186
+ execute: async (ctx: any) => {
1187
+ const contentStr = (ctx?.content || "").trim();
1188
+ const argsStr = ctx?.args ? ctx.args.join(" ") : contentStr;
1189
+ const isPull = argsStr === "pull-models" || argsStr === "" || argsStr.includes("pull-models");
1190
+
1191
+ if (isPull) {
1192
+ const res = await updateRouteLlmModels(pluginApi);
1193
+ try { appendFileSync("C:/tmp/abacus-update.log", res + "\n"); } catch (e) { }
1194
+ return { text: res };
1195
+ }
1196
+ return { text: `Unknown subcommand: ${argsStr}. Usage: /abacusai pull-models` };
1197
+ }
1198
+ });
1199
+ } catch (e) {
1200
+ console.error("[abacusai] Error registering command:", e);
1201
+ }
901
1202
  }
902
1203
 
903
- // Fallback: handle process signals if gateway_stop hook is unavailable
1204
+ // ================================================================
1205
+ // Process signal handlers for fallback proxy shutdown
1206
+ // ================================================================
904
1207
  const shutdownHandler = () => {
905
1208
  stopProxy().then(() => process.exit(0));
906
1209
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-abacusai-auth",
3
- "version": "1.2.7",
3
+ "version": "1.3.1",
4
4
  "description": "OpenClaw AbacusAI provider plugin - Third-party plugin for AbacusAI RouteLLM integration",
5
5
  "type": "module",
6
6
  "main": "index.ts",