@chrysb/alphaclaw 0.9.17 → 0.9.18
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 +25 -0
- package/lib/public/dist/app.bundle.js +1265 -1225
- package/lib/public/js/components/api-feature-panel.js +76 -0
- package/lib/public/js/components/general/index.js +6 -0
- package/lib/public/js/components/general/use-general-tab.js +69 -0
- package/lib/public/js/lib/api.js +19 -0
- package/lib/public/js/lib/storage-keys.js +4 -0
- package/lib/server/alphaclaw-config.js +99 -0
- package/lib/server/constants.js +48 -0
- package/lib/server/gateway.js +163 -1
- package/lib/server/init/register-server-routes.js +8 -0
- package/lib/server/login-throttle.js +41 -22
- package/lib/server/onboarding/openclaw.js +27 -3
- package/lib/server/routes/proxy.js +219 -1
- package/lib/server/routes/system.js +61 -0
- package/lib/server.js +35 -1
- package/package.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { h } from "preact";
|
|
2
|
+
import htm from "htm";
|
|
3
|
+
import { copyTextToClipboard } from "../lib/clipboard.js";
|
|
4
|
+
import { showToast } from "./toast.js";
|
|
5
|
+
import { FileCopyLineIcon } from "./icons.js";
|
|
6
|
+
import { InfoTooltip } from "./info-tooltip.js";
|
|
7
|
+
import { ToggleSwitch } from "./toggle-switch.js";
|
|
8
|
+
|
|
9
|
+
const html = htm.bind(h);
|
|
10
|
+
|
|
11
|
+
const getApiUrl = () => {
|
|
12
|
+
if (typeof window === "undefined" || !window.location?.origin) return "/v1";
|
|
13
|
+
return `${window.location.origin}/v1`;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const ApiFeaturePanel = ({
|
|
17
|
+
openAiCompatApi = { enabled: false },
|
|
18
|
+
savingOpenAiCompatApi = false,
|
|
19
|
+
onToggleOpenAiCompatApi = () => {},
|
|
20
|
+
}) => {
|
|
21
|
+
const apiHydrated = openAiCompatApi?.hydrated === true;
|
|
22
|
+
const apiEnabled = openAiCompatApi?.enabled === true;
|
|
23
|
+
const apiUrl = getApiUrl();
|
|
24
|
+
const handleCopy = async () => {
|
|
25
|
+
const copied = await copyTextToClipboard(apiUrl);
|
|
26
|
+
showToast(
|
|
27
|
+
copied ? "API URL copied" : "Could not copy API URL",
|
|
28
|
+
copied ? "success" : "error",
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return html`
|
|
33
|
+
<div class="bg-surface border border-border rounded-xl p-4">
|
|
34
|
+
<div class="flex items-center justify-between gap-3">
|
|
35
|
+
<div class="flex items-center gap-1.5 min-w-0">
|
|
36
|
+
<h2 class="card-label">API</h2>
|
|
37
|
+
<${InfoTooltip}
|
|
38
|
+
text="Allows trusted server-side clients to call OpenClaw via an OpenAI compatible API."
|
|
39
|
+
widthClass="w-72"
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
<${ToggleSwitch}
|
|
43
|
+
checked=${apiEnabled}
|
|
44
|
+
disabled=${savingOpenAiCompatApi || !apiHydrated}
|
|
45
|
+
label=${savingOpenAiCompatApi
|
|
46
|
+
? "Saving..."
|
|
47
|
+
: !apiHydrated
|
|
48
|
+
? "Loading..."
|
|
49
|
+
: apiEnabled
|
|
50
|
+
? "Enabled"
|
|
51
|
+
: "Disabled"}
|
|
52
|
+
onChange=${onToggleOpenAiCompatApi}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
${apiHydrated && apiEnabled
|
|
56
|
+
? html`
|
|
57
|
+
<div class="mt-4 text-xs text-fg-muted mb-2">OpenAI compatible URL</div>
|
|
58
|
+
<div class="flex items-center gap-2">
|
|
59
|
+
<code class="flex-1 min-w-0 bg-field border border-border rounded-lg px-3 py-2 text-xs text-body font-mono break-all">
|
|
60
|
+
${apiUrl}
|
|
61
|
+
</code>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
class="ac-btn-secondary text-xs p-2 rounded-lg shrink-0"
|
|
65
|
+
title="Copy URL"
|
|
66
|
+
aria-label="Copy API URL"
|
|
67
|
+
onclick=${handleCopy}
|
|
68
|
+
>
|
|
69
|
+
<${FileCopyLineIcon} className="w-4 h-4" />
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
`
|
|
73
|
+
: null}
|
|
74
|
+
</div>
|
|
75
|
+
`;
|
|
76
|
+
};
|
|
@@ -8,6 +8,7 @@ import { DevicePairings } from "../device-pairings.js";
|
|
|
8
8
|
import { ActionButton } from "../action-button.js";
|
|
9
9
|
import { Google } from "../google/index.js";
|
|
10
10
|
import { Features } from "../features.js";
|
|
11
|
+
import { ApiFeaturePanel } from "../api-feature-panel.js";
|
|
11
12
|
import { GeneralDoctorWarning } from "../doctor/general-warning.js";
|
|
12
13
|
import { ChevronDownIcon } from "../icons.js";
|
|
13
14
|
import { UpdateActionButton } from "../update-action-button.js";
|
|
@@ -136,6 +137,11 @@ export const GeneralTab = ({
|
|
|
136
137
|
onRestartRequired=${onRestartRequired}
|
|
137
138
|
onOpenGmailWebhook=${onOpenGmailWebhook}
|
|
138
139
|
/>
|
|
140
|
+
<${ApiFeaturePanel}
|
|
141
|
+
openAiCompatApi=${state.openAiCompatApi}
|
|
142
|
+
savingOpenAiCompatApi=${state.savingOpenAiCompatApi}
|
|
143
|
+
onToggleOpenAiCompatApi=${actions.handleOpenAiCompatApiToggle}
|
|
144
|
+
/>
|
|
139
145
|
|
|
140
146
|
${state.repo &&
|
|
141
147
|
html`
|
|
@@ -8,14 +8,36 @@ import {
|
|
|
8
8
|
rejectDevice,
|
|
9
9
|
rejectPairing,
|
|
10
10
|
triggerWatchdogRepair,
|
|
11
|
+
updateOpenAiCompatApiFeature,
|
|
11
12
|
updateSyncCron,
|
|
12
13
|
} from "../../lib/api.js";
|
|
13
14
|
import { usePolling } from "../../hooks/usePolling.js";
|
|
15
|
+
import {
|
|
16
|
+
kOpenAiCompatApiFeatureCacheKey,
|
|
17
|
+
} from "../../lib/storage-keys.js";
|
|
14
18
|
import { showToast } from "../toast.js";
|
|
15
19
|
import { ALL_CHANNELS } from "../channels.js";
|
|
16
20
|
|
|
17
21
|
const kDefaultSyncCronSchedule = "0 * * * *";
|
|
18
22
|
|
|
23
|
+
const readCachedOpenAiCompatApi = () => {
|
|
24
|
+
try {
|
|
25
|
+
const rawValue = window.localStorage.getItem(kOpenAiCompatApiFeatureCacheKey);
|
|
26
|
+
if (rawValue === "true") return { enabled: true, hydrated: true };
|
|
27
|
+
if (rawValue === "false") return { enabled: false, hydrated: true };
|
|
28
|
+
} catch {}
|
|
29
|
+
return { enabled: false, hydrated: false };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const writeCachedOpenAiCompatApi = (enabled) => {
|
|
33
|
+
try {
|
|
34
|
+
window.localStorage.setItem(
|
|
35
|
+
kOpenAiCompatApiFeatureCacheKey,
|
|
36
|
+
enabled ? "true" : "false",
|
|
37
|
+
);
|
|
38
|
+
} catch {}
|
|
39
|
+
};
|
|
40
|
+
|
|
19
41
|
export const useGeneralTab = ({
|
|
20
42
|
statusData = null,
|
|
21
43
|
watchdogData = null,
|
|
@@ -30,6 +52,14 @@ export const useGeneralTab = ({
|
|
|
30
52
|
const [syncCronSchedule, setSyncCronSchedule] = useState(kDefaultSyncCronSchedule);
|
|
31
53
|
const [savingSyncCron, setSavingSyncCron] = useState(false);
|
|
32
54
|
const [syncCronChoice, setSyncCronChoice] = useState(kDefaultSyncCronSchedule);
|
|
55
|
+
const [cachedOpenAiCompatApi] = useState(readCachedOpenAiCompatApi);
|
|
56
|
+
const [openAiCompatApiEnabled, setOpenAiCompatApiEnabled] = useState(
|
|
57
|
+
cachedOpenAiCompatApi.enabled,
|
|
58
|
+
);
|
|
59
|
+
const [openAiCompatApiHydrated, setOpenAiCompatApiHydrated] = useState(
|
|
60
|
+
cachedOpenAiCompatApi.hydrated,
|
|
61
|
+
);
|
|
62
|
+
const [savingOpenAiCompatApi, setSavingOpenAiCompatApi] = useState(false);
|
|
33
63
|
const [pairingStatusRefreshing, setPairingStatusRefreshing] = useState(false);
|
|
34
64
|
const [devicePollingEnabled, setDevicePollingEnabled] = useState(false);
|
|
35
65
|
const [cliAutoApproveComplete, setCliAutoApproveComplete] = useState(false);
|
|
@@ -42,6 +72,8 @@ export const useGeneralTab = ({
|
|
|
42
72
|
const channels = status?.channels ?? null;
|
|
43
73
|
const repo = status?.repo || null;
|
|
44
74
|
const syncCron = status?.syncCron || null;
|
|
75
|
+
const openAiCompatApi = status?.alphaclaw?.features?.openaiCompatApi || null;
|
|
76
|
+
const hasOpenAiCompatApiStatus = typeof openAiCompatApi?.enabled === "boolean";
|
|
45
77
|
const openclawVersion = status?.openclawVersion || null;
|
|
46
78
|
|
|
47
79
|
const hasUnpaired = ALL_CHANNELS.some((channel) => {
|
|
@@ -134,6 +166,14 @@ export const useGeneralTab = ({
|
|
|
134
166
|
);
|
|
135
167
|
}, [syncCron?.enabled, syncCron?.schedule]);
|
|
136
168
|
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!hasOpenAiCompatApiStatus) return;
|
|
171
|
+
const nextEnabled = openAiCompatApi.enabled === true;
|
|
172
|
+
setOpenAiCompatApiEnabled(nextEnabled);
|
|
173
|
+
setOpenAiCompatApiHydrated(true);
|
|
174
|
+
writeCachedOpenAiCompatApi(nextEnabled);
|
|
175
|
+
}, [hasOpenAiCompatApiStatus, openAiCompatApi?.enabled]);
|
|
176
|
+
|
|
137
177
|
useEffect(
|
|
138
178
|
() => () => {
|
|
139
179
|
if (pairingRefreshTimerRef.current) {
|
|
@@ -196,6 +236,28 @@ export const useGeneralTab = ({
|
|
|
196
236
|
});
|
|
197
237
|
};
|
|
198
238
|
|
|
239
|
+
const handleOpenAiCompatApiToggle = async (enabled) => {
|
|
240
|
+
if (savingOpenAiCompatApi) return;
|
|
241
|
+
const previousEnabled = openAiCompatApiEnabled;
|
|
242
|
+
setOpenAiCompatApiEnabled(enabled);
|
|
243
|
+
setSavingOpenAiCompatApi(true);
|
|
244
|
+
try {
|
|
245
|
+
const data = await updateOpenAiCompatApiFeature(enabled);
|
|
246
|
+
if (!data.ok) {
|
|
247
|
+
throw new Error(data.error || "Could not save API setting");
|
|
248
|
+
}
|
|
249
|
+
writeCachedOpenAiCompatApi(enabled);
|
|
250
|
+
setOpenAiCompatApiHydrated(true);
|
|
251
|
+
showToast(`API ${enabled ? "enabled" : "disabled"}`, "success");
|
|
252
|
+
onRefreshStatuses();
|
|
253
|
+
} catch (err) {
|
|
254
|
+
setOpenAiCompatApiEnabled(previousEnabled);
|
|
255
|
+
showToast(err.message || "Could not save API setting", "error");
|
|
256
|
+
} finally {
|
|
257
|
+
setSavingOpenAiCompatApi(false);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
199
261
|
const handleApprove = async (id, channel, accountId = "") => {
|
|
200
262
|
try {
|
|
201
263
|
const result = await approvePairing(id, channel, accountId);
|
|
@@ -288,12 +350,18 @@ export const useGeneralTab = ({
|
|
|
288
350
|
gatewayStatus,
|
|
289
351
|
hasUnpaired,
|
|
290
352
|
openclawVersion,
|
|
353
|
+
openAiCompatApi: {
|
|
354
|
+
...(openAiCompatApi || {}),
|
|
355
|
+
enabled: openAiCompatApiEnabled,
|
|
356
|
+
hydrated: openAiCompatApiHydrated,
|
|
357
|
+
},
|
|
291
358
|
pending,
|
|
292
359
|
pairingsPolling: pairingsPoll.isPolling,
|
|
293
360
|
pairingStatusRefreshing,
|
|
294
361
|
repairingWatchdog,
|
|
295
362
|
repo,
|
|
296
363
|
savingSyncCron,
|
|
364
|
+
savingOpenAiCompatApi,
|
|
297
365
|
syncCron,
|
|
298
366
|
syncCronChoice,
|
|
299
367
|
syncCronEnabled,
|
|
@@ -306,6 +374,7 @@ export const useGeneralTab = ({
|
|
|
306
374
|
handleDeviceApprove,
|
|
307
375
|
handleDeviceReject,
|
|
308
376
|
handleOpenDashboard,
|
|
377
|
+
handleOpenAiCompatApiToggle,
|
|
309
378
|
handleReject,
|
|
310
379
|
handleSyncCronChoiceChange,
|
|
311
380
|
handleWatchdogRepair,
|
package/lib/public/js/lib/api.js
CHANGED
|
@@ -550,6 +550,25 @@ export async function updateSyncCron(payload) {
|
|
|
550
550
|
return data;
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
export async function updateOpenAiCompatApiFeature(enabled) {
|
|
554
|
+
const res = await authFetch("/api/alphaclaw/config/features/openai-compat-api", {
|
|
555
|
+
method: "PUT",
|
|
556
|
+
headers: { "Content-Type": "application/json" },
|
|
557
|
+
body: JSON.stringify({ enabled }),
|
|
558
|
+
});
|
|
559
|
+
const text = await res.text();
|
|
560
|
+
let data;
|
|
561
|
+
try {
|
|
562
|
+
data = text ? JSON.parse(text) : {};
|
|
563
|
+
} catch {
|
|
564
|
+
throw new Error(text || "Could not parse AlphaClaw config response");
|
|
565
|
+
}
|
|
566
|
+
if (!res.ok) {
|
|
567
|
+
throw new Error(data.error || text || `HTTP ${res.status}`);
|
|
568
|
+
}
|
|
569
|
+
return data;
|
|
570
|
+
}
|
|
571
|
+
|
|
553
572
|
export async function fetchCronJobs({ sortBy = "nextRunAtMs", sortDir = "asc" } = {}) {
|
|
554
573
|
const params = new URLSearchParams();
|
|
555
574
|
if (sortBy) params.set("sortBy", String(sortBy));
|
|
@@ -31,3 +31,7 @@ export const kAgentLastSessionKey = "alphaclaw.agent.lastSessionKey";
|
|
|
31
31
|
|
|
32
32
|
// --- Chat ---
|
|
33
33
|
export const kChatSessionDraftsStorageKey = "alphaclaw.chat.sessionDrafts";
|
|
34
|
+
|
|
35
|
+
// --- Features ---
|
|
36
|
+
export const kOpenAiCompatApiFeatureCacheKey =
|
|
37
|
+
"alphaclaw.features.openAiCompatApi.enabled";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
const kConfigFileName = "alphaclaw.json";
|
|
5
|
+
const kDefaultAlphaclawConfig = Object.freeze({
|
|
6
|
+
features: Object.freeze({
|
|
7
|
+
openaiCompatApi: Object.freeze({
|
|
8
|
+
enabled: false,
|
|
9
|
+
}),
|
|
10
|
+
}),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const resolveAlphaclawConfigPath = ({ openclawDir } = {}) =>
|
|
14
|
+
path.join(openclawDir || process.cwd(), kConfigFileName);
|
|
15
|
+
|
|
16
|
+
const normalizeOpenAiCompatApiFeature = (feature = {}) => ({
|
|
17
|
+
...(feature && typeof feature === "object" ? feature : {}),
|
|
18
|
+
enabled: feature?.enabled === true,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const normalizeAlphaclawConfig = (raw = {}) => {
|
|
22
|
+
const base = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
23
|
+
const features =
|
|
24
|
+
base.features && typeof base.features === "object" && !Array.isArray(base.features)
|
|
25
|
+
? base.features
|
|
26
|
+
: {};
|
|
27
|
+
return {
|
|
28
|
+
...base,
|
|
29
|
+
features: {
|
|
30
|
+
...features,
|
|
31
|
+
openaiCompatApi: normalizeOpenAiCompatApiFeature(features.openaiCompatApi),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const readAlphaclawConfig = ({
|
|
37
|
+
fsModule = fs,
|
|
38
|
+
openclawDir,
|
|
39
|
+
fallback = kDefaultAlphaclawConfig,
|
|
40
|
+
} = {}) => {
|
|
41
|
+
try {
|
|
42
|
+
const configPath = resolveAlphaclawConfigPath({ openclawDir });
|
|
43
|
+
const raw = fsModule.readFileSync(configPath, "utf8");
|
|
44
|
+
return normalizeAlphaclawConfig(JSON.parse(raw));
|
|
45
|
+
} catch {
|
|
46
|
+
return normalizeAlphaclawConfig(fallback);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const writeAlphaclawConfig = ({
|
|
51
|
+
fsModule = fs,
|
|
52
|
+
openclawDir,
|
|
53
|
+
config,
|
|
54
|
+
spacing = 2,
|
|
55
|
+
} = {}) => {
|
|
56
|
+
const configPath = resolveAlphaclawConfigPath({ openclawDir });
|
|
57
|
+
fsModule.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
58
|
+
const normalized = normalizeAlphaclawConfig(config);
|
|
59
|
+
fsModule.writeFileSync(configPath, `${JSON.stringify(normalized, null, spacing)}\n`);
|
|
60
|
+
return normalized;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const isOpenAiCompatApiEnabled = (options = {}) =>
|
|
64
|
+
readAlphaclawConfig(options).features.openaiCompatApi.enabled === true;
|
|
65
|
+
|
|
66
|
+
const updateOpenAiCompatApiFeature = ({
|
|
67
|
+
fsModule = fs,
|
|
68
|
+
openclawDir,
|
|
69
|
+
enabled,
|
|
70
|
+
} = {}) => {
|
|
71
|
+
const current = readAlphaclawConfig({ fsModule, openclawDir });
|
|
72
|
+
const next = normalizeAlphaclawConfig({
|
|
73
|
+
...current,
|
|
74
|
+
features: {
|
|
75
|
+
...current.features,
|
|
76
|
+
openaiCompatApi: {
|
|
77
|
+
...current.features.openaiCompatApi,
|
|
78
|
+
enabled: enabled === true,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const changed =
|
|
83
|
+
current.features.openaiCompatApi.enabled !== next.features.openaiCompatApi.enabled;
|
|
84
|
+
return {
|
|
85
|
+
config: writeAlphaclawConfig({ fsModule, openclawDir, config: next }),
|
|
86
|
+
changed,
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
kConfigFileName,
|
|
92
|
+
kDefaultAlphaclawConfig,
|
|
93
|
+
isOpenAiCompatApiEnabled,
|
|
94
|
+
normalizeAlphaclawConfig,
|
|
95
|
+
readAlphaclawConfig,
|
|
96
|
+
resolveAlphaclawConfigPath,
|
|
97
|
+
updateOpenAiCompatApiFeature,
|
|
98
|
+
writeAlphaclawConfig,
|
|
99
|
+
};
|
package/lib/server/constants.js
CHANGED
|
@@ -81,6 +81,45 @@ const kLoginStateTtlMs = Math.max(
|
|
|
81
81
|
),
|
|
82
82
|
kLoginMaxLockMs,
|
|
83
83
|
);
|
|
84
|
+
const kOpenAiCompatApiRateWindowMs = parsePositiveInt(
|
|
85
|
+
process.env.OPENAI_COMPAT_API_RATE_WINDOW_MS,
|
|
86
|
+
kLoginWindowMs,
|
|
87
|
+
);
|
|
88
|
+
const kOpenAiCompatApiRateMaxAttempts = parsePositiveInt(
|
|
89
|
+
process.env.OPENAI_COMPAT_API_RATE_MAX_ATTEMPTS,
|
|
90
|
+
10,
|
|
91
|
+
);
|
|
92
|
+
const kOpenAiCompatApiRateBaseLockMs = parsePositiveInt(
|
|
93
|
+
process.env.OPENAI_COMPAT_API_RATE_BASE_LOCK_MS,
|
|
94
|
+
kLoginBaseLockMs,
|
|
95
|
+
);
|
|
96
|
+
const kOpenAiCompatApiRateMaxLockMs = parsePositiveInt(
|
|
97
|
+
process.env.OPENAI_COMPAT_API_RATE_MAX_LOCK_MS,
|
|
98
|
+
kLoginMaxLockMs,
|
|
99
|
+
);
|
|
100
|
+
const kOpenAiCompatApiRateGlobalWindowMs = parsePositiveInt(
|
|
101
|
+
process.env.OPENAI_COMPAT_API_RATE_GLOBAL_WINDOW_MS,
|
|
102
|
+
kOpenAiCompatApiRateWindowMs,
|
|
103
|
+
);
|
|
104
|
+
const kOpenAiCompatApiRateGlobalMaxAttempts = parsePositiveInt(
|
|
105
|
+
process.env.OPENAI_COMPAT_API_RATE_GLOBAL_MAX_ATTEMPTS,
|
|
106
|
+
Math.max(kOpenAiCompatApiRateMaxAttempts * 10, 100),
|
|
107
|
+
);
|
|
108
|
+
const kOpenAiCompatApiRateGlobalBaseLockMs = parsePositiveInt(
|
|
109
|
+
process.env.OPENAI_COMPAT_API_RATE_GLOBAL_BASE_LOCK_MS,
|
|
110
|
+
kOpenAiCompatApiRateBaseLockMs,
|
|
111
|
+
);
|
|
112
|
+
const kOpenAiCompatApiRateGlobalMaxLockMs = parsePositiveInt(
|
|
113
|
+
process.env.OPENAI_COMPAT_API_RATE_GLOBAL_MAX_LOCK_MS,
|
|
114
|
+
kOpenAiCompatApiRateMaxLockMs,
|
|
115
|
+
);
|
|
116
|
+
const kOpenAiCompatApiRateStateTtlMs = Math.max(
|
|
117
|
+
parsePositiveInt(
|
|
118
|
+
process.env.OPENAI_COMPAT_API_RATE_STATE_TTL_MS,
|
|
119
|
+
Math.max(kOpenAiCompatApiRateWindowMs, kOpenAiCompatApiRateMaxLockMs) * 3,
|
|
120
|
+
),
|
|
121
|
+
kOpenAiCompatApiRateMaxLockMs,
|
|
122
|
+
);
|
|
84
123
|
|
|
85
124
|
const kOnboardingModelProviders = new Set([
|
|
86
125
|
"anthropic",
|
|
@@ -472,6 +511,15 @@ module.exports = {
|
|
|
472
511
|
kLoginGlobalMaxLockMs,
|
|
473
512
|
kLoginCleanupIntervalMs,
|
|
474
513
|
kLoginStateTtlMs,
|
|
514
|
+
kOpenAiCompatApiRateWindowMs,
|
|
515
|
+
kOpenAiCompatApiRateMaxAttempts,
|
|
516
|
+
kOpenAiCompatApiRateBaseLockMs,
|
|
517
|
+
kOpenAiCompatApiRateMaxLockMs,
|
|
518
|
+
kOpenAiCompatApiRateGlobalWindowMs,
|
|
519
|
+
kOpenAiCompatApiRateGlobalMaxAttempts,
|
|
520
|
+
kOpenAiCompatApiRateGlobalBaseLockMs,
|
|
521
|
+
kOpenAiCompatApiRateGlobalMaxLockMs,
|
|
522
|
+
kOpenAiCompatApiRateStateTtlMs,
|
|
475
523
|
kOnboardingModelProviders,
|
|
476
524
|
kFallbackOnboardingModels,
|
|
477
525
|
kVersionCacheTtlMs,
|
package/lib/server/gateway.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
kRootDir,
|
|
13
13
|
} = require("./constants");
|
|
14
14
|
const { withOpenclawStartupEnv } = require("./openclaw-runtime-env");
|
|
15
|
+
const { isOpenAiCompatApiEnabled } = require("./alphaclaw-config");
|
|
15
16
|
|
|
16
17
|
let gatewayChild = null;
|
|
17
18
|
let gatewayExitHandler = null;
|
|
@@ -505,6 +506,31 @@ const ensureGatewayProxyConfig = (origin) => {
|
|
|
505
506
|
if (!cfg.gateway) cfg.gateway = {};
|
|
506
507
|
let changed = false;
|
|
507
508
|
|
|
509
|
+
if (isOpenAiCompatApiEnabled({ fsModule: fs, openclawDir: OPENCLAW_DIR })) {
|
|
510
|
+
if (!cfg.gateway.http) cfg.gateway.http = {};
|
|
511
|
+
if (!cfg.gateway.http.endpoints) cfg.gateway.http.endpoints = {};
|
|
512
|
+
|
|
513
|
+
const chatCompletions = cfg.gateway.http.endpoints.chatCompletions || {};
|
|
514
|
+
if (chatCompletions.enabled !== true) {
|
|
515
|
+
cfg.gateway.http.endpoints.chatCompletions = {
|
|
516
|
+
...chatCompletions,
|
|
517
|
+
enabled: true,
|
|
518
|
+
};
|
|
519
|
+
console.log("[alphaclaw] Enabled gateway OpenAI chat completions endpoint");
|
|
520
|
+
changed = true;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const responses = cfg.gateway.http.endpoints.responses || {};
|
|
524
|
+
if (responses.enabled !== true) {
|
|
525
|
+
cfg.gateway.http.endpoints.responses = {
|
|
526
|
+
...responses,
|
|
527
|
+
enabled: true,
|
|
528
|
+
};
|
|
529
|
+
console.log("[alphaclaw] Enabled gateway OpenResponses endpoint");
|
|
530
|
+
changed = true;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
508
534
|
if (!Array.isArray(cfg.gateway.trustedProxies)) {
|
|
509
535
|
cfg.gateway.trustedProxies = [];
|
|
510
536
|
}
|
|
@@ -526,8 +552,144 @@ const ensureGatewayProxyConfig = (origin) => {
|
|
|
526
552
|
}
|
|
527
553
|
}
|
|
528
554
|
|
|
555
|
+
// Managed remote MCP server entry. Env-driven so any AlphaClaw operator
|
|
556
|
+
// (Render, Fly, fly.io-style PaaS, plain VPS) can wire OpenClaw to a
|
|
557
|
+
// remote MCP server without hand-editing /data/.openclaw/openclaw.json.
|
|
558
|
+
//
|
|
559
|
+
// REMOTE_MCP_URL upstream MCP endpoint (streamable-http).
|
|
560
|
+
// REMOTE_MCP_API_TOKEN Bearer token the remote MCP expects. Persisted
|
|
561
|
+
// as the ${REMOTE_MCP_API_TOKEN} reference, not
|
|
562
|
+
// raw, so the openclaw.json that gets
|
|
563
|
+
// git-committed never holds the plaintext.
|
|
564
|
+
// REMOTE_MCP_NAME Key under mcp.servers.<name>. Default "remote".
|
|
565
|
+
// REMOTE_MCP_PROXY_URL When set, OpenClaw connects here instead of
|
|
566
|
+
// REMOTE_MCP_URL. Intended for a same-host
|
|
567
|
+
// scanning proxy (e.g. `pipelock mcp proxy
|
|
568
|
+
// --listen ... --upstream <REMOTE_MCP_URL>`),
|
|
569
|
+
// but the implementation is proxy-agnostic.
|
|
570
|
+
// The supervisor that starts that proxy is
|
|
571
|
+
// responsible for unsetting this env var when
|
|
572
|
+
// the proxy is not running, so AlphaClaw never
|
|
573
|
+
// points OpenClaw at a dead listener.
|
|
574
|
+
const remoteMcpUrl = String(process.env.REMOTE_MCP_URL || "").trim();
|
|
575
|
+
const remoteMcpToken = String(
|
|
576
|
+
process.env.REMOTE_MCP_API_TOKEN || "",
|
|
577
|
+
).trim();
|
|
578
|
+
const remoteMcpProxyUrl = String(
|
|
579
|
+
process.env.REMOTE_MCP_PROXY_URL || "",
|
|
580
|
+
).trim();
|
|
581
|
+
const remoteMcpNameRaw = String(process.env.REMOTE_MCP_NAME || "").trim();
|
|
582
|
+
// Constrain the managed key. OpenClaw sanitizes names later for tool
|
|
583
|
+
// prefixes, but the config-key itself must be safe to use as an object
|
|
584
|
+
// key and to read back in `openclaw mcp` CLI commands. Reject names
|
|
585
|
+
// with prototype-pollution shapes, spaces, or path-like names; fall
|
|
586
|
+
// back to "remote" with a warning so a typo doesn't silently misroute.
|
|
587
|
+
const kRemoteMcpNamePattern = /^[A-Za-z0-9_-]{1,64}$/;
|
|
588
|
+
const kReservedRemoteMcpNames = new Set([
|
|
589
|
+
"__proto__",
|
|
590
|
+
"constructor",
|
|
591
|
+
"prototype",
|
|
592
|
+
]);
|
|
593
|
+
let remoteMcpName = "remote";
|
|
594
|
+
if (remoteMcpNameRaw) {
|
|
595
|
+
if (
|
|
596
|
+
kRemoteMcpNamePattern.test(remoteMcpNameRaw) &&
|
|
597
|
+
!kReservedRemoteMcpNames.has(remoteMcpNameRaw)
|
|
598
|
+
) {
|
|
599
|
+
remoteMcpName = remoteMcpNameRaw;
|
|
600
|
+
} else {
|
|
601
|
+
console.warn(
|
|
602
|
+
`[alphaclaw] REMOTE_MCP_NAME=${JSON.stringify(remoteMcpNameRaw)} is invalid (must match ${kRemoteMcpNamePattern} and not be a reserved key); falling back to "remote"`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const placeholderAuth = "Bearer ${REMOTE_MCP_API_TOKEN}";
|
|
607
|
+
const desiredAuth = `Bearer ${remoteMcpToken}`;
|
|
608
|
+
const kManagedMarker = "_alphaclawManaged";
|
|
609
|
+
let mcpChanged = false;
|
|
610
|
+
|
|
611
|
+
// Clean up any managed entries left over from a prior REMOTE_MCP_NAME
|
|
612
|
+
// value. Without this, renaming REMOTE_MCP_NAME from "sure" to "notion"
|
|
613
|
+
// would leave the old "sure" entry behind, duplicating MCP tools or
|
|
614
|
+
// routing callbacks to a stale target. The marker scopes the cleanup so
|
|
615
|
+
// user-managed entries (no marker) are never touched.
|
|
616
|
+
if (cfg.mcp?.servers) {
|
|
617
|
+
for (const [key, entry] of Object.entries(cfg.mcp.servers)) {
|
|
618
|
+
if (
|
|
619
|
+
entry &&
|
|
620
|
+
typeof entry === "object" &&
|
|
621
|
+
entry[kManagedMarker] === true &&
|
|
622
|
+
key !== remoteMcpName
|
|
623
|
+
) {
|
|
624
|
+
delete cfg.mcp.servers[key];
|
|
625
|
+
mcpChanged = true;
|
|
626
|
+
console.log(
|
|
627
|
+
`[alphaclaw] Removed stale managed MCP server "${key}" (REMOTE_MCP_NAME is now "${remoteMcpName}")`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (remoteMcpUrl && remoteMcpToken) {
|
|
634
|
+
if (!cfg.mcp) cfg.mcp = {};
|
|
635
|
+
if (!cfg.mcp.servers) cfg.mcp.servers = {};
|
|
636
|
+
const existing = cfg.mcp.servers[remoteMcpName] || {};
|
|
637
|
+
const effectiveUrl = remoteMcpProxyUrl || remoteMcpUrl;
|
|
638
|
+
const existingHeaders = existing.headers || {};
|
|
639
|
+
const existingAuth = existingHeaders.Authorization;
|
|
640
|
+
// Only the placeholder counts as "already sanitized". A plaintext
|
|
641
|
+
// Bearer (even one that matches the current desiredAuth) must trigger a
|
|
642
|
+
// rewrite so the substitution loop below scrubs it back to the
|
|
643
|
+
// ${REMOTE_MCP_API_TOKEN} reference.
|
|
644
|
+
const authIsPlaceholder = existingAuth === placeholderAuth;
|
|
645
|
+
const hasManagedMarker = existing[kManagedMarker] === true;
|
|
646
|
+
if (
|
|
647
|
+
existing.url !== effectiveUrl ||
|
|
648
|
+
existing.transport !== "streamable-http" ||
|
|
649
|
+
!authIsPlaceholder ||
|
|
650
|
+
!hasManagedMarker
|
|
651
|
+
) {
|
|
652
|
+
cfg.mcp.servers[remoteMcpName] = {
|
|
653
|
+
...existing,
|
|
654
|
+
url: effectiveUrl,
|
|
655
|
+
transport: "streamable-http",
|
|
656
|
+
headers: {
|
|
657
|
+
...existingHeaders,
|
|
658
|
+
Authorization: desiredAuth,
|
|
659
|
+
},
|
|
660
|
+
[kManagedMarker]: true,
|
|
661
|
+
};
|
|
662
|
+
mcpChanged = true;
|
|
663
|
+
console.log(
|
|
664
|
+
`[alphaclaw] Configured remote MCP server "${remoteMcpName}" (url=${effectiveUrl}, via_proxy=${Boolean(remoteMcpProxyUrl)})`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
} else if (
|
|
668
|
+
cfg.mcp?.servers?.[remoteMcpName] &&
|
|
669
|
+
cfg.mcp.servers[remoteMcpName][kManagedMarker] === true
|
|
670
|
+
) {
|
|
671
|
+
delete cfg.mcp.servers[remoteMcpName];
|
|
672
|
+
mcpChanged = true;
|
|
673
|
+
console.log(
|
|
674
|
+
`[alphaclaw] Removed remote MCP server "${remoteMcpName}" entry (REMOTE_MCP_URL / REMOTE_MCP_API_TOKEN unset)`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
if (cfg.mcp?.servers && Object.keys(cfg.mcp.servers).length === 0) {
|
|
678
|
+
delete cfg.mcp.servers;
|
|
679
|
+
}
|
|
680
|
+
if (cfg.mcp && Object.keys(cfg.mcp).length === 0) {
|
|
681
|
+
delete cfg.mcp;
|
|
682
|
+
}
|
|
683
|
+
if (mcpChanged) changed = true;
|
|
684
|
+
|
|
529
685
|
if (changed) {
|
|
530
|
-
|
|
686
|
+
let content = JSON.stringify(cfg, null, 2);
|
|
687
|
+
if (remoteMcpToken) {
|
|
688
|
+
const jsonValue = JSON.stringify(desiredAuth);
|
|
689
|
+
const jsonPlaceholder = JSON.stringify(placeholderAuth);
|
|
690
|
+
content = content.split(jsonValue).join(jsonPlaceholder);
|
|
691
|
+
}
|
|
692
|
+
fs.writeFileSync(configPath, content);
|
|
531
693
|
}
|
|
532
694
|
return changed;
|
|
533
695
|
} catch (e) {
|
|
@@ -42,6 +42,8 @@ const registerServerRoutes = ({
|
|
|
42
42
|
resolveGithubRepoUrl,
|
|
43
43
|
resolveModelProvider,
|
|
44
44
|
ensureGatewayProxyConfig,
|
|
45
|
+
isOpenAiCompatApiEnabled,
|
|
46
|
+
openAiCompatApiThrottle,
|
|
45
47
|
getBaseUrl,
|
|
46
48
|
startGateway,
|
|
47
49
|
ensureManagedExecDefaults,
|
|
@@ -133,6 +135,8 @@ const registerServerRoutes = ({
|
|
|
133
135
|
authProfiles,
|
|
134
136
|
watchdog,
|
|
135
137
|
doctorService,
|
|
138
|
+
ensureGatewayProxyConfig,
|
|
139
|
+
getBaseUrl,
|
|
136
140
|
});
|
|
137
141
|
registerBrowseRoutes({
|
|
138
142
|
app,
|
|
@@ -274,6 +278,10 @@ const registerServerRoutes = ({
|
|
|
274
278
|
app,
|
|
275
279
|
proxy,
|
|
276
280
|
getGatewayUrl,
|
|
281
|
+
getGatewayToken: () =>
|
|
282
|
+
process.env.OPENCLAW_GATEWAY_TOKEN || constants.GATEWAY_TOKEN || "",
|
|
283
|
+
isOpenAiCompatApiEnabled,
|
|
284
|
+
openAiCompatApiThrottle,
|
|
277
285
|
SETUP_API_PREFIXES,
|
|
278
286
|
requireAuth,
|
|
279
287
|
oauthCallbackMiddleware,
|