@chrysb/alphaclaw 0.9.17 → 0.9.19

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.
@@ -11,7 +11,12 @@ const {
11
11
  kOnboardingMarkerPath,
12
12
  kRootDir,
13
13
  } = require("./constants");
14
+ const {
15
+ normalizeChannelAccountId,
16
+ readPairedCountsByAccount,
17
+ } = require("./agents/shared");
14
18
  const { withOpenclawStartupEnv } = require("./openclaw-runtime-env");
19
+ const { isOpenAiCompatApiEnabled } = require("./alphaclaw-config");
15
20
 
16
21
  let gatewayChild = null;
17
22
  let gatewayExitHandler = null;
@@ -202,18 +207,6 @@ const getGatewayPort = () => {
202
207
 
203
208
  const getGatewayUrl = () => `http://${GATEWAY_HOST}:${getGatewayPort()}`;
204
209
 
205
- const normalizeChannelAccountId = (value) => String(value || "").trim() || "default";
206
-
207
- const resolveCredentialPairingAccountId = ({ channel, fileName }) => {
208
- const prefix = `${String(channel || "").trim()}-`;
209
- const suffix = "-allowFrom.json";
210
- if (!String(fileName || "").startsWith(prefix) || !String(fileName || "").endsWith(suffix)) {
211
- return "";
212
- }
213
- const rawAccountId = String(fileName || "").slice(prefix.length, -suffix.length);
214
- return normalizeChannelAccountId(rawAccountId);
215
- };
216
-
217
210
  const isGatewayRunning = () =>
218
211
  new Promise((resolve) => {
219
212
  const sock = net.createConnection(getGatewayPort(), GATEWAY_HOST);
@@ -505,6 +498,31 @@ const ensureGatewayProxyConfig = (origin) => {
505
498
  if (!cfg.gateway) cfg.gateway = {};
506
499
  let changed = false;
507
500
 
501
+ if (isOpenAiCompatApiEnabled({ fsModule: fs, openclawDir: OPENCLAW_DIR })) {
502
+ if (!cfg.gateway.http) cfg.gateway.http = {};
503
+ if (!cfg.gateway.http.endpoints) cfg.gateway.http.endpoints = {};
504
+
505
+ const chatCompletions = cfg.gateway.http.endpoints.chatCompletions || {};
506
+ if (chatCompletions.enabled !== true) {
507
+ cfg.gateway.http.endpoints.chatCompletions = {
508
+ ...chatCompletions,
509
+ enabled: true,
510
+ };
511
+ console.log("[alphaclaw] Enabled gateway OpenAI chat completions endpoint");
512
+ changed = true;
513
+ }
514
+
515
+ const responses = cfg.gateway.http.endpoints.responses || {};
516
+ if (responses.enabled !== true) {
517
+ cfg.gateway.http.endpoints.responses = {
518
+ ...responses,
519
+ enabled: true,
520
+ };
521
+ console.log("[alphaclaw] Enabled gateway OpenResponses endpoint");
522
+ changed = true;
523
+ }
524
+ }
525
+
508
526
  if (!Array.isArray(cfg.gateway.trustedProxies)) {
509
527
  cfg.gateway.trustedProxies = [];
510
528
  }
@@ -526,8 +544,144 @@ const ensureGatewayProxyConfig = (origin) => {
526
544
  }
527
545
  }
528
546
 
547
+ // Managed remote MCP server entry. Env-driven so any AlphaClaw operator
548
+ // (Render, Fly, fly.io-style PaaS, plain VPS) can wire OpenClaw to a
549
+ // remote MCP server without hand-editing /data/.openclaw/openclaw.json.
550
+ //
551
+ // REMOTE_MCP_URL upstream MCP endpoint (streamable-http).
552
+ // REMOTE_MCP_API_TOKEN Bearer token the remote MCP expects. Persisted
553
+ // as the ${REMOTE_MCP_API_TOKEN} reference, not
554
+ // raw, so the openclaw.json that gets
555
+ // git-committed never holds the plaintext.
556
+ // REMOTE_MCP_NAME Key under mcp.servers.<name>. Default "remote".
557
+ // REMOTE_MCP_PROXY_URL When set, OpenClaw connects here instead of
558
+ // REMOTE_MCP_URL. Intended for a same-host
559
+ // scanning proxy (e.g. `pipelock mcp proxy
560
+ // --listen ... --upstream <REMOTE_MCP_URL>`),
561
+ // but the implementation is proxy-agnostic.
562
+ // The supervisor that starts that proxy is
563
+ // responsible for unsetting this env var when
564
+ // the proxy is not running, so AlphaClaw never
565
+ // points OpenClaw at a dead listener.
566
+ const remoteMcpUrl = String(process.env.REMOTE_MCP_URL || "").trim();
567
+ const remoteMcpToken = String(
568
+ process.env.REMOTE_MCP_API_TOKEN || "",
569
+ ).trim();
570
+ const remoteMcpProxyUrl = String(
571
+ process.env.REMOTE_MCP_PROXY_URL || "",
572
+ ).trim();
573
+ const remoteMcpNameRaw = String(process.env.REMOTE_MCP_NAME || "").trim();
574
+ // Constrain the managed key. OpenClaw sanitizes names later for tool
575
+ // prefixes, but the config-key itself must be safe to use as an object
576
+ // key and to read back in `openclaw mcp` CLI commands. Reject names
577
+ // with prototype-pollution shapes, spaces, or path-like names; fall
578
+ // back to "remote" with a warning so a typo doesn't silently misroute.
579
+ const kRemoteMcpNamePattern = /^[A-Za-z0-9_-]{1,64}$/;
580
+ const kReservedRemoteMcpNames = new Set([
581
+ "__proto__",
582
+ "constructor",
583
+ "prototype",
584
+ ]);
585
+ let remoteMcpName = "remote";
586
+ if (remoteMcpNameRaw) {
587
+ if (
588
+ kRemoteMcpNamePattern.test(remoteMcpNameRaw) &&
589
+ !kReservedRemoteMcpNames.has(remoteMcpNameRaw)
590
+ ) {
591
+ remoteMcpName = remoteMcpNameRaw;
592
+ } else {
593
+ console.warn(
594
+ `[alphaclaw] REMOTE_MCP_NAME=${JSON.stringify(remoteMcpNameRaw)} is invalid (must match ${kRemoteMcpNamePattern} and not be a reserved key); falling back to "remote"`,
595
+ );
596
+ }
597
+ }
598
+ const placeholderAuth = "Bearer ${REMOTE_MCP_API_TOKEN}";
599
+ const desiredAuth = `Bearer ${remoteMcpToken}`;
600
+ const kManagedMarker = "_alphaclawManaged";
601
+ let mcpChanged = false;
602
+
603
+ // Clean up any managed entries left over from a prior REMOTE_MCP_NAME
604
+ // value. Without this, renaming REMOTE_MCP_NAME from "sure" to "notion"
605
+ // would leave the old "sure" entry behind, duplicating MCP tools or
606
+ // routing callbacks to a stale target. The marker scopes the cleanup so
607
+ // user-managed entries (no marker) are never touched.
608
+ if (cfg.mcp?.servers) {
609
+ for (const [key, entry] of Object.entries(cfg.mcp.servers)) {
610
+ if (
611
+ entry &&
612
+ typeof entry === "object" &&
613
+ entry[kManagedMarker] === true &&
614
+ key !== remoteMcpName
615
+ ) {
616
+ delete cfg.mcp.servers[key];
617
+ mcpChanged = true;
618
+ console.log(
619
+ `[alphaclaw] Removed stale managed MCP server "${key}" (REMOTE_MCP_NAME is now "${remoteMcpName}")`,
620
+ );
621
+ }
622
+ }
623
+ }
624
+
625
+ if (remoteMcpUrl && remoteMcpToken) {
626
+ if (!cfg.mcp) cfg.mcp = {};
627
+ if (!cfg.mcp.servers) cfg.mcp.servers = {};
628
+ const existing = cfg.mcp.servers[remoteMcpName] || {};
629
+ const effectiveUrl = remoteMcpProxyUrl || remoteMcpUrl;
630
+ const existingHeaders = existing.headers || {};
631
+ const existingAuth = existingHeaders.Authorization;
632
+ // Only the placeholder counts as "already sanitized". A plaintext
633
+ // Bearer (even one that matches the current desiredAuth) must trigger a
634
+ // rewrite so the substitution loop below scrubs it back to the
635
+ // ${REMOTE_MCP_API_TOKEN} reference.
636
+ const authIsPlaceholder = existingAuth === placeholderAuth;
637
+ const hasManagedMarker = existing[kManagedMarker] === true;
638
+ if (
639
+ existing.url !== effectiveUrl ||
640
+ existing.transport !== "streamable-http" ||
641
+ !authIsPlaceholder ||
642
+ !hasManagedMarker
643
+ ) {
644
+ cfg.mcp.servers[remoteMcpName] = {
645
+ ...existing,
646
+ url: effectiveUrl,
647
+ transport: "streamable-http",
648
+ headers: {
649
+ ...existingHeaders,
650
+ Authorization: desiredAuth,
651
+ },
652
+ [kManagedMarker]: true,
653
+ };
654
+ mcpChanged = true;
655
+ console.log(
656
+ `[alphaclaw] Configured remote MCP server "${remoteMcpName}" (url=${effectiveUrl}, via_proxy=${Boolean(remoteMcpProxyUrl)})`,
657
+ );
658
+ }
659
+ } else if (
660
+ cfg.mcp?.servers?.[remoteMcpName] &&
661
+ cfg.mcp.servers[remoteMcpName][kManagedMarker] === true
662
+ ) {
663
+ delete cfg.mcp.servers[remoteMcpName];
664
+ mcpChanged = true;
665
+ console.log(
666
+ `[alphaclaw] Removed remote MCP server "${remoteMcpName}" entry (REMOTE_MCP_URL / REMOTE_MCP_API_TOKEN unset)`,
667
+ );
668
+ }
669
+ if (cfg.mcp?.servers && Object.keys(cfg.mcp.servers).length === 0) {
670
+ delete cfg.mcp.servers;
671
+ }
672
+ if (cfg.mcp && Object.keys(cfg.mcp).length === 0) {
673
+ delete cfg.mcp;
674
+ }
675
+ if (mcpChanged) changed = true;
676
+
529
677
  if (changed) {
530
- fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
678
+ let content = JSON.stringify(cfg, null, 2);
679
+ if (remoteMcpToken) {
680
+ const jsonValue = JSON.stringify(desiredAuth);
681
+ const jsonPlaceholder = JSON.stringify(placeholderAuth);
682
+ content = content.split(jsonValue).join(jsonPlaceholder);
683
+ }
684
+ fs.writeFileSync(configPath, content);
531
685
  }
532
686
  return changed;
533
687
  } catch (e) {
@@ -619,32 +773,6 @@ const getChannelStatus = () => {
619
773
  );
620
774
  const credDir = `${OPENCLAW_DIR}/credentials`;
621
775
  const channels = {};
622
- const hasImplicitWhatsAppSelfPairing = ({ accountId, accountConfig }) => {
623
- if (!accountConfig || typeof accountConfig !== "object") return false;
624
- if (accountConfig.selfChatMode === false) return false;
625
- if (String(accountConfig.dmPolicy || "").trim().toLowerCase() === "disabled") {
626
- return false;
627
- }
628
- const candidatePaths = [
629
- `${credDir}/whatsapp/${accountId}/creds.json`,
630
- ...(accountId === "default" ? [`${credDir}/creds.json`] : []),
631
- ];
632
- const matches = candidatePaths.map((targetPath) => {
633
- try {
634
- return {
635
- path: targetPath,
636
- exists: !!String(fs.readFileSync(targetPath, "utf8") || "").trim(),
637
- };
638
- } catch (error) {
639
- return {
640
- path: targetPath,
641
- exists: false,
642
- error: String(error?.message || error || "read failed"),
643
- };
644
- }
645
- });
646
- return matches.some((entry) => entry.exists);
647
- };
648
776
 
649
777
  for (const ch of Object.keys(kChannelDefs)) {
650
778
  const channelConfig =
@@ -674,55 +802,14 @@ const getChannelStatus = () => {
674
802
  });
675
803
  if (!hasConfiguredToken) continue;
676
804
 
677
- const pairedByAccount = new Map(
678
- Array.from(configuredAccountIds).map((accountId) => [accountId, 0]),
679
- );
680
- try {
681
- if (ch !== "whatsapp") {
682
- const files = fs
683
- .readdirSync(credDir)
684
- .filter(
685
- (f) => f.startsWith(`${ch}-`) && f.endsWith("-allowFrom.json"),
686
- );
687
- for (const file of files) {
688
- const accountId = resolveCredentialPairingAccountId({
689
- channel: ch,
690
- fileName: file,
691
- });
692
- if (!accountId || !configuredAccountIds.has(accountId)) continue;
693
- const data = JSON.parse(
694
- fs.readFileSync(`${credDir}/${file}`, "utf8"),
695
- );
696
- const nextCount =
697
- Number(pairedByAccount.get(accountId) || 0)
698
- + (Array.isArray(data.allowFrom) ? data.allowFrom.length : 0);
699
- pairedByAccount.set(accountId, nextCount);
700
- }
701
- }
702
- } catch {}
703
- for (const [accountId, accountConfig] of accountEntries) {
704
- if (ch === "whatsapp") continue;
705
- const inlineAllowFrom = accountConfig?.allowFrom;
706
- if (!Array.isArray(inlineAllowFrom)) continue;
707
- const normalizedAccountId = normalizeChannelAccountId(accountId);
708
- const nextCount =
709
- Number(pairedByAccount.get(normalizedAccountId) || 0) + inlineAllowFrom.length;
710
- pairedByAccount.set(normalizedAccountId, nextCount);
711
- }
712
- if (ch === "whatsapp") {
713
- for (const [accountId, accountConfig] of accountEntries) {
714
- const normalizedAccountId = normalizeChannelAccountId(accountId);
715
- if (Number(pairedByAccount.get(normalizedAccountId) || 0) > 0) continue;
716
- if (
717
- hasImplicitWhatsAppSelfPairing({
718
- accountId: normalizedAccountId,
719
- accountConfig,
720
- })
721
- ) {
722
- pairedByAccount.set(normalizedAccountId, 1);
723
- }
724
- }
725
- }
805
+ const pairedByAccount = readPairedCountsByAccount({
806
+ fsImpl: fs,
807
+ OPENCLAW_DIR,
808
+ channelId: ch,
809
+ accountIds: Array.from(configuredAccountIds),
810
+ config: channelConfig,
811
+ });
812
+
726
813
  const accounts = Object.fromEntries(
727
814
  Array.from(pairedByAccount.entries()).map(([accountId, paired]) => [
728
815
  accountId,
@@ -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,
@@ -11,6 +11,7 @@ const {
11
11
  } = require("./constants");
12
12
 
13
13
  const kGlobalStateKey = "global:login";
14
+ const kLoginThrottleScope = "login";
14
15
 
15
16
  const createLoginAttemptState = (now) => ({
16
17
  attempts: 0,
@@ -48,8 +49,15 @@ const createMemoryLoginThrottleStore = () => {
48
49
  };
49
50
  };
50
51
 
51
- const getClientStateKey = (clientKey) =>
52
- `client:${String(clientKey || "unknown")}`;
52
+ const getClientStateKey = (clientKey, scope = kLoginThrottleScope) => {
53
+ const normalizedClientKey = String(clientKey || "unknown");
54
+ return scope === kLoginThrottleScope
55
+ ? `client:${normalizedClientKey}`
56
+ : `client:${scope}:${normalizedClientKey}`;
57
+ };
58
+
59
+ const getGlobalStateKey = (scope = kLoginThrottleScope) =>
60
+ scope === kLoginThrottleScope ? kGlobalStateKey : `global:${scope}`;
53
61
 
54
62
  const getOrCreateState = (store, stateKey, now) => {
55
63
  const existing = store.get(stateKey);
@@ -153,6 +161,16 @@ const chooseFailureResult = (...results) => {
153
161
 
154
162
  const createLoginThrottle = ({
155
163
  store = createMemoryLoginThrottleStore(),
164
+ scope = kLoginThrottleScope,
165
+ windowMs = kLoginWindowMs,
166
+ maxAttempts = kLoginMaxAttempts,
167
+ baseLockMs = kLoginBaseLockMs,
168
+ maxLockMs = kLoginMaxLockMs,
169
+ globalWindowMs = kLoginGlobalWindowMs,
170
+ globalMaxAttempts = kLoginGlobalMaxAttempts,
171
+ globalBaseLockMs = kLoginGlobalBaseLockMs,
172
+ globalMaxLockMs = kLoginGlobalMaxLockMs,
173
+ stateTtlMs = kLoginStateTtlMs,
156
174
  } = {}) => {
157
175
  const runExclusive =
158
176
  typeof store.runExclusive === "function"
@@ -161,13 +179,14 @@ const createLoginThrottle = ({
161
179
 
162
180
  const getOrCreateLoginAttemptState = (clientKey, now) =>
163
181
  runExclusive(() => {
164
- const clientStateKey = getClientStateKey(clientKey);
182
+ const clientStateKey = getClientStateKey(clientKey, scope);
183
+ const globalStateKey = getGlobalStateKey(scope);
165
184
  return {
166
185
  clientKey,
167
186
  clientStateKey,
168
- globalStateKey: kGlobalStateKey,
187
+ globalStateKey,
169
188
  client: getOrCreateState(store, clientStateKey, now),
170
- global: getOrCreateState(store, kGlobalStateKey, now),
189
+ global: getOrCreateState(store, globalStateKey, now),
171
190
  };
172
191
  });
173
192
 
@@ -175,19 +194,19 @@ const createLoginThrottle = ({
175
194
  runExclusive(() => {
176
195
  const clientStateKey =
177
196
  stateBundle?.clientStateKey ||
178
- getClientStateKey(stateBundle?.clientKey);
179
- const globalStateKey = stateBundle?.globalStateKey || kGlobalStateKey;
197
+ getClientStateKey(stateBundle?.clientKey, scope);
198
+ const globalStateKey = stateBundle?.globalStateKey || getGlobalStateKey(scope);
180
199
  const clientResult = evaluateState({
181
200
  store,
182
201
  stateKey: clientStateKey,
183
202
  now,
184
- windowMs: kLoginWindowMs,
203
+ windowMs,
185
204
  });
186
205
  const globalResult = evaluateState({
187
206
  store,
188
207
  stateKey: globalStateKey,
189
208
  now,
190
- windowMs: kLoginGlobalWindowMs,
209
+ windowMs: globalWindowMs,
191
210
  });
192
211
  if (stateBundle) {
193
212
  stateBundle.client = clientResult.state;
@@ -200,25 +219,25 @@ const createLoginThrottle = ({
200
219
  runExclusive(() => {
201
220
  const clientStateKey =
202
221
  stateBundle?.clientStateKey ||
203
- getClientStateKey(stateBundle?.clientKey);
204
- const globalStateKey = stateBundle?.globalStateKey || kGlobalStateKey;
222
+ getClientStateKey(stateBundle?.clientKey, scope);
223
+ const globalStateKey = stateBundle?.globalStateKey || getGlobalStateKey(scope);
205
224
  const clientResult = recordStateFailure({
206
225
  store,
207
226
  stateKey: clientStateKey,
208
227
  now,
209
- windowMs: kLoginWindowMs,
210
- maxAttempts: kLoginMaxAttempts,
211
- baseLockMs: kLoginBaseLockMs,
212
- maxLockMs: kLoginMaxLockMs,
228
+ windowMs,
229
+ maxAttempts,
230
+ baseLockMs,
231
+ maxLockMs,
213
232
  });
214
233
  const globalResult = recordStateFailure({
215
234
  store,
216
235
  stateKey: globalStateKey,
217
236
  now,
218
- windowMs: kLoginGlobalWindowMs,
219
- maxAttempts: kLoginGlobalMaxAttempts,
220
- baseLockMs: kLoginGlobalBaseLockMs,
221
- maxLockMs: kLoginGlobalMaxLockMs,
237
+ windowMs: globalWindowMs,
238
+ maxAttempts: globalMaxAttempts,
239
+ baseLockMs: globalBaseLockMs,
240
+ maxLockMs: globalMaxLockMs,
222
241
  });
223
242
  if (stateBundle) {
224
243
  stateBundle.client = clientResult.state;
@@ -230,8 +249,8 @@ const createLoginThrottle = ({
230
249
  const recordLoginSuccess = (clientKey) => {
231
250
  if (!clientKey) return;
232
251
  runExclusive(() => {
233
- store.delete(getClientStateKey(clientKey));
234
- store.delete(kGlobalStateKey);
252
+ store.delete(getClientStateKey(clientKey, scope));
253
+ store.delete(getGlobalStateKey(scope));
235
254
  });
236
255
  };
237
256
 
@@ -245,7 +264,7 @@ const createLoginThrottle = ({
245
264
  }
246
265
  const state = normalizeState(rawState, now);
247
266
  if (state.lockUntil > now) continue;
248
- if (now - state.lastSeenAt > kLoginStateTtlMs) {
267
+ if (now - state.lastSeenAt > stateTtlMs) {
249
268
  store.delete(stateKey);
250
269
  }
251
270
  }
@@ -1,6 +1,7 @@
1
1
  const path = require("path");
2
2
  const { kSetupDir } = require("../constants");
3
3
  const { buildManagedPaths } = require("../internal-files-migration");
4
+ const { shouldSkipSystemCronInstall } = require("../../cli/git-runtime");
4
5
 
5
6
  const kHourlyGitSyncTemplatePath = path.join(kSetupDir, "hourly-git-sync.sh");
6
7
  const kSystemCronPath = "/etc/cron.d/openclaw-hourly-sync";
@@ -37,6 +38,13 @@ const installHourlyGitSyncCron = async ({ fs, openclawDir }) => {
37
38
  fs.mkdirSync(configDir, { recursive: true });
38
39
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
39
40
 
41
+ if (shouldSkipSystemCronInstall()) {
42
+ console.log(
43
+ "[onboard] System cron install skipped by ALPHACLAW_SKIP_SYSTEM_CRON_INSTALL",
44
+ );
45
+ return false;
46
+ }
47
+
40
48
  const cronContent = buildSystemCronFile({
41
49
  schedule: config.schedule,
42
50
  scriptPath: hourlyGitSyncPath,
@@ -4,6 +4,7 @@ const {
4
4
  ensurePluginAllowed,
5
5
  ensureUsageTrackerPluginEntry,
6
6
  } = require("../usage-tracker-config");
7
+ const { isOpenAiCompatApiEnabled } = require("../alphaclaw-config");
7
8
 
8
9
  const kDefaultToolsProfile = "full";
9
10
  const kBootstrapExtraFiles = [
@@ -133,16 +134,29 @@ const buildOnboardArgs = ({
133
134
  return onboardArgs;
134
135
  };
135
136
 
136
- const ensureManagedConfigShell = (cfg) => {
137
+ const ensureManagedConfigShell = (cfg, { openAiCompatApiEnabled = false } = {}) => {
137
138
  if (!cfg.channels) cfg.channels = {};
138
139
  ensurePluginsShell(cfg);
139
140
  if (!cfg.commands) cfg.commands = {};
140
141
  if (!cfg.tools) cfg.tools = {};
142
+ if (!cfg.gateway) cfg.gateway = {};
141
143
  if (!cfg.hooks) cfg.hooks = {};
142
144
  if (!cfg.hooks.internal) cfg.hooks.internal = {};
143
145
  if (!cfg.hooks.internal.entries) cfg.hooks.internal.entries = {};
144
146
  cfg.commands.restart = true;
145
147
  cfg.tools.profile = kDefaultToolsProfile;
148
+ if (openAiCompatApiEnabled) {
149
+ if (!cfg.gateway.http) cfg.gateway.http = {};
150
+ if (!cfg.gateway.http.endpoints) cfg.gateway.http.endpoints = {};
151
+ cfg.gateway.http.endpoints.chatCompletions = {
152
+ ...(cfg.gateway.http.endpoints.chatCompletions || {}),
153
+ enabled: true,
154
+ };
155
+ cfg.gateway.http.endpoints.responses = {
156
+ ...(cfg.gateway.http.endpoints.responses || {}),
157
+ enabled: true,
158
+ };
159
+ }
146
160
  cfg.hooks.internal.enabled = true;
147
161
  cfg.hooks.internal.entries["bootstrap-extra-files"] = {
148
162
  ...(cfg.hooks.internal.entries["bootstrap-extra-files"] || {}),
@@ -224,7 +238,12 @@ const writeSanitizedOpenclawConfig = ({
224
238
  }) => {
225
239
  const configPath = `${openclawDir}/openclaw.json`;
226
240
  const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
227
- ensureManagedConfigShell(cfg);
241
+ ensureManagedConfigShell(cfg, {
242
+ openAiCompatApiEnabled: isOpenAiCompatApiEnabled({
243
+ fsModule: fs,
244
+ openclawDir,
245
+ }),
246
+ });
228
247
  applyFreshOnboardingChannels({
229
248
  cfg,
230
249
  varMap,
@@ -256,7 +275,12 @@ const writeManagedImportOpenclawConfig = ({
256
275
  }) => {
257
276
  const configPath = `${openclawDir}/openclaw.json`;
258
277
  const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
259
- ensureManagedConfigShell(cfg);
278
+ ensureManagedConfigShell(cfg, {
279
+ openAiCompatApiEnabled: isOpenAiCompatApiEnabled({
280
+ fsModule: fs,
281
+ openclawDir,
282
+ }),
283
+ });
260
284
 
261
285
  ensureUsageTrackerPluginEntry(cfg);
262
286
 
@@ -0,0 +1,77 @@
1
+ const isRecord = (value) =>
2
+ !!value && typeof value === "object" && !Array.isArray(value);
3
+
4
+ const parseStreamingMode = (value) => {
5
+ if (typeof value === "boolean") return value ? "partial" : "off";
6
+ const normalized = String(value || "").trim().toLowerCase();
7
+ return ["off", "partial", "block", "progress"].includes(normalized)
8
+ ? normalized
9
+ : null;
10
+ };
11
+
12
+ const migrateLegacyTelegramStreamingEntry = (entry) => {
13
+ if (!isRecord(entry)) return false;
14
+
15
+ const legacyStreaming = entry.streaming;
16
+ const hasLegacyStreaming =
17
+ typeof legacyStreaming === "boolean" || typeof legacyStreaming === "string";
18
+ const hasLegacyFields =
19
+ entry.streamMode !== undefined ||
20
+ entry.chunkMode !== undefined ||
21
+ entry.blockStreaming !== undefined ||
22
+ entry.blockStreamingCoalesce !== undefined ||
23
+ entry.draftChunk !== undefined;
24
+ if (!hasLegacyStreaming && !hasLegacyFields) return false;
25
+
26
+ const streaming = isRecord(legacyStreaming) ? { ...legacyStreaming } : {};
27
+ const resolvedMode =
28
+ parseStreamingMode(isRecord(legacyStreaming) ? legacyStreaming.mode : legacyStreaming) ||
29
+ parseStreamingMode(entry.streamMode) ||
30
+ "partial";
31
+ if (streaming.mode === undefined) streaming.mode = resolvedMode;
32
+
33
+ if (entry.chunkMode !== undefined && streaming.chunkMode === undefined) {
34
+ streaming.chunkMode = entry.chunkMode;
35
+ }
36
+ if (entry.draftChunk !== undefined) {
37
+ const preview = isRecord(streaming.preview) ? { ...streaming.preview } : {};
38
+ if (preview.chunk === undefined) preview.chunk = entry.draftChunk;
39
+ streaming.preview = preview;
40
+ }
41
+ if (entry.blockStreaming !== undefined || entry.blockStreamingCoalesce !== undefined) {
42
+ const block = isRecord(streaming.block) ? { ...streaming.block } : {};
43
+ if (entry.blockStreaming !== undefined && block.enabled === undefined) {
44
+ block.enabled = entry.blockStreaming;
45
+ }
46
+ if (entry.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
47
+ block.coalesce = entry.blockStreamingCoalesce;
48
+ }
49
+ streaming.block = block;
50
+ }
51
+
52
+ entry.streaming = streaming;
53
+ delete entry.streamMode;
54
+ delete entry.chunkMode;
55
+ delete entry.blockStreaming;
56
+ delete entry.blockStreamingCoalesce;
57
+ delete entry.draftChunk;
58
+ return true;
59
+ };
60
+
61
+ const migrateLegacyTelegramStreamingConfig = (cfg = {}) => {
62
+ const telegram = cfg.channels?.telegram;
63
+ if (!isRecord(telegram)) return false;
64
+
65
+ let changed = migrateLegacyTelegramStreamingEntry(telegram);
66
+ if (isRecord(telegram.accounts)) {
67
+ for (const account of Object.values(telegram.accounts)) {
68
+ if (migrateLegacyTelegramStreamingEntry(account)) changed = true;
69
+ }
70
+ }
71
+ return changed;
72
+ };
73
+
74
+ module.exports = {
75
+ migrateLegacyTelegramStreamingConfig,
76
+ migrateLegacyTelegramStreamingEntry,
77
+ };
@@ -27,6 +27,31 @@ const loadThinkingModule = async () => {
27
27
  return thinkingModulePromise;
28
28
  };
29
29
 
30
+ const isNormalizeThinkLevel = (candidate) => {
31
+ if (typeof candidate !== "function") return false;
32
+ try {
33
+ return (
34
+ candidate("off") === "off" &&
35
+ candidate("low") === "low" &&
36
+ candidate("high") === "high"
37
+ );
38
+ } catch {
39
+ return false;
40
+ }
41
+ };
42
+
43
+ const resolveNormalizeThinkLevel = (mod) => {
44
+ const candidates = [
45
+ mod.normalizeThinkLevel,
46
+ mod.f,
47
+ mod.p,
48
+ ...Object.values(mod),
49
+ ];
50
+ const match = candidates.find(isNormalizeThinkLevel);
51
+ if (!match) throw new Error("OpenClaw normalizeThinkLevel export not found");
52
+ return match;
53
+ };
54
+
30
55
  const splitModelKey = (modelKey = "") => {
31
56
  const normalized = String(modelKey || "").trim();
32
57
  const slashIndex = normalized.indexOf("/");
@@ -55,7 +80,7 @@ const resolveThinkingApi = async () => {
55
80
  return {
56
81
  listThinkingLevelOptions: mod.listThinkingLevelOptions || mod.i,
57
82
  resolveThinkingDefaultForModel: mod.resolveThinkingDefaultForModel || mod.s,
58
- normalizeThinkLevel: mod.normalizeThinkLevel || mod.p,
83
+ normalizeThinkLevel: resolveNormalizeThinkLevel(mod),
59
84
  };
60
85
  };
61
86