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 +8 -0
- package/index.ts +286 -11
- package/package.json +1 -1
- package/openclaw_output.txt +0 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
925
|
-
|
|
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
|
-
//
|
|
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
package/openclaw_output.txt
DELETED
|
Binary file
|