openclaw-abacusai-auth 1.2.8 → 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.
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`.
@@ -581,12 +671,53 @@ async function handleProxyRequestInner(req: IncomingMessage, res: ServerResponse
581
671
  return;
582
672
  }
583
673
 
584
- const target = `${ROUTELLM_BASE}${path}`;
674
+ let targetPath = path === "/" ? "" : path;
675
+ if (targetPath.startsWith("/v1/")) {
676
+ targetPath = targetPath.slice(3); // e.g., "/v1/models" -> "/models"
677
+ }
678
+
585
679
  const headers: Record<string, string> = {
586
680
  Authorization: `Bearer ${proxyApiKey}`,
587
681
  "Content-Type": "application/json",
588
682
  };
589
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}`;
590
721
  let body: string | undefined;
591
722
  if (req.method === "POST") {
592
723
  const raw = await readBody(req);
@@ -594,13 +725,33 @@ async function handleProxyRequestInner(req: IncomingMessage, res: ServerResponse
594
725
  // Normalize tools for RouteLLM: remove `strict` field, clean schemas
595
726
  // (remove patternProperties, add additionalProperties: false, etc.)
596
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 */ }
597
733
 
598
734
  if (Array.isArray(parsed.tools)) {
599
735
  parsed.tools = normalizeToolsForRouteLLM(parsed.tools);
600
736
  }
601
737
  // Normalize tool_calls in messages: add top-level name/parameters for RouteLLM
602
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
+
603
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 */ }
604
755
  }
605
756
  body = JSON.stringify(parsed);
606
757
  }
@@ -882,7 +1033,85 @@ function updateBaseUrlInConfig(pluginApi: any): void {
882
1033
  }
883
1034
 
884
1035
  // ---------------------------------------------------------------------------
885
- // 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
886
1115
  // ---------------------------------------------------------------------------
887
1116
 
888
1117
  // Type definitions for plugin API
@@ -913,22 +1142,68 @@ const abacusaiPlugin = {
913
1142
  config?: {
914
1143
  models?: { providers?: { abacusai?: { compat?: { supportsStrictMode?: boolean } } } };
915
1144
  };
1145
+ registerCommand?: (command: { name: string; description: string; handler: Function; execute?: Function }) => void;
916
1146
  };
917
1147
 
918
1148
  // ================================================================
919
1149
  // Register gateway_stop hook for graceful proxy shutdown
920
1150
  // ================================================================
921
1151
  if (typeof pluginApi.registerHook === "function") {
922
- pluginApi.registerHook(
923
- "gateway_stop",
924
- async () => {
925
- await stopProxy();
926
- },
927
- { name: "openclaw-abacusai-auth:gateway-stop" },
928
- );
1152
+ pluginApi.registerHook("gateway_stop", async () => {
1153
+ console.log("[abacusai] gateway_stop hook triggered, stopping proxy gracefully...");
1154
+ await stopProxy();
1155
+ });
929
1156
  }
930
1157
 
931
- // Fallback: handle process signals if gateway_stop hook is unavailable
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
+ }
1202
+ }
1203
+
1204
+ // ================================================================
1205
+ // Process signal handlers for fallback proxy shutdown
1206
+ // ================================================================
932
1207
  const shutdownHandler = () => {
933
1208
  stopProxy().then(() => process.exit(0));
934
1209
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-abacusai-auth",
3
- "version": "1.2.8",
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",
Binary file