@openagents-org/agent-launcher 0.2.176 → 0.2.178

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.
@@ -120,8 +120,35 @@ function formatAttachmentsForPrompt(
120
120
  return lines.join('\n');
121
121
  }
122
122
 
123
+ /**
124
+ * Strip secrets out of anything on its way to a log line or a channel message.
125
+ *
126
+ * Adapter diagnostics quote raw CLI output, and a CLI that fails on auth tends
127
+ * to echo the credential it was handed. The shapes here are the ones that
128
+ * actually turn up in that output; the closing catch-all takes any long opaque
129
+ * token the named patterns missed.
130
+ *
131
+ * Lived as a private static on two adapters before claude needed it as well —
132
+ * a third identical copy is one copy too many for a security-relevant rule.
133
+ */
134
+ function redactSecrets(s) {
135
+ let out = String(s == null ? '' : s);
136
+ out = out
137
+ .replace(/\bsk-[A-Za-z0-9_-]{6,}/g, 'sk-[REDACTED]')
138
+ .replace(/\b(?:github_pat|gh[pousr])_[A-Za-z0-9_]{10,}/g, '[REDACTED_TOKEN]')
139
+ .replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}/g, '[REDACTED_TOKEN]')
140
+ .replace(/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED_KEY]')
141
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, '[REDACTED_JWT]')
142
+ .replace(/(authorization|api[_-]?key|x-api-key|token|bearer|secret|password|passwd)(["'\s:=]+)([^\s"',}]+)/gi,
143
+ (m, k, sep) => `${k}${sep}[REDACTED]`)
144
+ .replace(/([?&](?:api[_-]?key|key|token|access_token)=)[^&\s"']+/gi, '$1[REDACTED]')
145
+ .replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[REDACTED]');
146
+ return out;
147
+ }
148
+
123
149
  module.exports = {
124
150
  SESSION_DEFAULT_RE,
125
151
  generateSessionTitle,
126
152
  formatAttachmentsForPrompt,
153
+ redactSecrets,
127
154
  };
@@ -1021,6 +1021,38 @@ function buildPiSystemPrompt({ agentName, workspaceId, channelName, endpoint, to
1021
1021
  return identity + directive + '\n' + collab + '\n' + modePrompt + '\n' + api + '\n' + buildGuardrails();
1022
1022
  }
1023
1023
 
1024
+ /**
1025
+ * Build the workspace briefing that seeds an OpenWorker session.
1026
+ *
1027
+ * Shape mirrors buildPiSystemPrompt — identity, browser directive,
1028
+ * collaboration, mode, then the REST block — because OpenWorker reaches the
1029
+ * workspace the same way: its own shell tool plus curl, with no MCP wiring from
1030
+ * our side (registering an MCP server there writes into the user's global
1031
+ * OpenWorker config, which is not ours to edit).
1032
+ *
1033
+ * Two differences from every other builder here, both forced by OpenWorker
1034
+ * having no system-prompt hook at all:
1035
+ *
1036
+ * - `execTool` is `run_shell`, the actual name of its shell tool. Naming a
1037
+ * tool the agent does not have is how a model ends up printing curl
1038
+ * commands as text instead of running them.
1039
+ * - This is prepended to the first USER message of a session rather than
1040
+ * installed as a system prompt. That makes it part of the persisted
1041
+ * conversation, so it is sent once and still in context on every later turn
1042
+ * — which is why it must read as a briefing, not as a system directive.
1043
+ */
1044
+ function buildOpenWorkerSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false }) {
1045
+ const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills');
1046
+ const directive = buildBrowserDirective(browserEnabled);
1047
+ const collab = buildCollaborationPrompt('skills', workspaceSkillName(agentName));
1048
+ const modePrompt = buildModePrompt(mode);
1049
+ const api = buildApiSkillsPrompt({
1050
+ endpoint, workspaceId, token, agentName, channelName, disabledModules, mode,
1051
+ execTool: 'run_shell',
1052
+ });
1053
+ return identity + directive + '\n' + collab + '\n' + modePrompt + '\n' + api + '\n' + buildGuardrails();
1054
+ }
1055
+
1024
1056
  /**
1025
1057
  * Build the task FILE contents for the DeepSeek Harness adapter.
1026
1058
  *
@@ -1223,6 +1255,7 @@ module.exports = {
1223
1255
  buildOpenCodeSkillMd,
1224
1256
  buildCommandCodeSkillMd,
1225
1257
  buildPiSystemPrompt,
1258
+ buildOpenWorkerSystemPrompt,
1226
1259
  buildDeepSeekTaskFile,
1227
1260
  buildClaudeSkillMd,
1228
1261
  buildCursorSkillMd,
@@ -29,11 +29,10 @@
29
29
  'use strict';
30
30
 
31
31
  const fs = require('fs');
32
+ const os = require('os');
32
33
  const path = require('path');
33
34
  const { execFileSync } = require('child_process');
34
35
 
35
- const IS_MACOS = process.platform === 'darwin';
36
-
37
36
  /** Directories on PATH, minus any the caller wants skipped. */
38
37
  function pathDirs(exclude = []) {
39
38
  const raw = process.env.PATH || '';
@@ -154,30 +153,106 @@ function probeGit(deps = {}) {
154
153
 
155
154
  /**
156
155
  * How the user fixes each missing dependency, phrased for the install screen.
157
- * `action` is a hint the launcher UI can turn into a button; everything else
158
- * degrades to plain text.
156
+ *
157
+ * `action` is a hint the launcher UI can turn into a button. `summaryKey` and
158
+ * `alternativeKind` are the same thing for text: the launcher is translated
159
+ * and this package is not, so it looks up its own wording by those keys and
160
+ * keeps `summary` only as the fallback — which is also what the CLI and the
161
+ * install log print. `alternativeKind` in particular has to be carried rather
162
+ * than guessed: the alternative is Homebrew only on macOS, and a UI that
163
+ * labels every alternative "if you use Homebrew" is wrong on two of the three
164
+ * platforms we ship.
165
+ *
166
+ * @param {string} [platform] - seam for tests; production passes nothing.
159
167
  */
160
- function gitRemedy() {
161
- if (IS_MACOS) {
168
+ function gitRemedy(platform = process.platform) {
169
+ if (platform === 'darwin') {
162
170
  return {
163
171
  name: 'git',
164
172
  action: 'install-xcode-clt',
173
+ summaryKey: 'gitXcodeClt',
165
174
  summary: 'Git is required, and it comes with the Xcode Command Line Tools.',
166
175
  command: 'xcode-select --install',
167
176
  alternative: 'brew install git',
177
+ alternativeKind: 'homebrew',
168
178
  };
169
179
  }
170
180
  return {
171
181
  name: 'git',
172
182
  action: null,
183
+ summaryKey: 'git',
173
184
  summary: 'Git is required to download this agent.',
174
185
  command: 'sudo apt install git # or dnf / pacman, per your distro',
175
186
  alternative: null,
187
+ alternativeKind: null,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Is `uv` on PATH?
193
+ *
194
+ * Unlike git, nothing provisions uv for us: an agent whose install command IS
195
+ * `uv tool install …` and whose machine has no uv gets the shell's own
196
+ * "command not found" buried in installer output, several seconds after
197
+ * pressing Install. The probe is a plain filesystem check — running `uv
198
+ * --version` would be the same answer at the cost of a process spawn, and uv
199
+ * has no shim behaviour to catch out the way macOS's /usr/bin/git does.
200
+ *
201
+ * Also checks uv's own default install location, which its installer script
202
+ * adds to PATH via a shell profile a running GUI/daemon never re-read.
203
+ */
204
+ function probeUv(deps = {}) {
205
+ const platform = deps.platform || process.platform;
206
+ const exists = deps.exists || ((p) => fs.existsSync(p));
207
+ const home = deps.home || os.homedir();
208
+ const isWindows = platform === 'win32';
209
+ const search = deps.dirs || [
210
+ ...pathDirs(),
211
+ path.join(home, '.local', 'bin'),
212
+ path.join(home, '.cargo', 'bin'),
213
+ ...(isWindows ? [] : ['/opt/homebrew/bin', '/usr/local/bin']),
214
+ ];
215
+ const found = existsIn(search, 'uv', exists, isWindows);
216
+ return found ? { ok: true, detail: found } : { ok: false };
217
+ }
218
+
219
+ /** @param {string} [platform] - seam for tests; production passes nothing. */
220
+ function uvRemedy(platform = process.platform) {
221
+ const summary = 'uv is required to install this agent (it is a Python tool).';
222
+
223
+ if (platform === 'win32') {
224
+ return {
225
+ name: 'uv',
226
+ action: null,
227
+ summaryKey: 'uv',
228
+ summary,
229
+ // Spelled out instead of a bare `irm … | iex`, because "run this in a
230
+ // terminal" on Windows lands in cmd.exe as often as in PowerShell, and
231
+ // the bare form is a syntax error there.
232
+ command:
233
+ 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"',
234
+ // pipx is the obvious parallel to Homebrew, but a machine with no uv
235
+ // seldom has pipx either — whereas winget ships with Windows.
236
+ alternative: 'winget install --id=astral-sh.uv -e',
237
+ alternativeKind: 'winget',
238
+ };
239
+ }
240
+
241
+ const viaHomebrew = platform === 'darwin';
242
+ return {
243
+ name: 'uv',
244
+ action: null,
245
+ summaryKey: 'uv',
246
+ summary,
247
+ command: 'curl -LsSf https://astral.sh/uv/install.sh | sh',
248
+ alternative: viaHomebrew ? 'brew install uv' : 'pipx install uv',
249
+ alternativeKind: viaHomebrew ? 'homebrew' : 'pipx',
176
250
  };
177
251
  }
178
252
 
179
253
  const PROBES = {
180
254
  git: { probe: probeGit, remedy: gitRemedy },
255
+ uv: { probe: probeUv, remedy: uvRemedy },
181
256
  };
182
257
 
183
258
  /**
@@ -230,4 +305,7 @@ module.exports = {
230
305
  checkInstallPrereqs,
231
306
  missingPrereqError,
232
307
  probeGit,
308
+ probeUv,
309
+ gitRemedy,
310
+ uvRemedy,
233
311
  };
package/src/installer.js CHANGED
@@ -1805,6 +1805,22 @@ class Installer {
1805
1805
  } catch {}
1806
1806
  }
1807
1807
  }
1808
+ // Where a user-level installer puts things, on every platform. uv's own
1809
+ // `curl … | sh` (and its Windows script) install into ~/.local/bin and add
1810
+ // it to PATH through a shell profile that a GUI-launched process never
1811
+ // reads — so an install command that IS `uv tool install …` died with
1812
+ // "uv: command not found" on a machine that had uv. install-preflight's
1813
+ // probeUv already searches these two, so leaving them out here meant the
1814
+ // pre-flight passed and the spawn it was guarding then failed.
1815
+ // unshift, not push: the loop below PREPENDS each entry, so the first one
1816
+ // here ends up LAST on PATH. These are a fallback for a binary nothing else
1817
+ // provides — they must never shadow the user's own PATH or the runtime
1818
+ // directories above.
1819
+ extraDirs.unshift(
1820
+ path.join(os.homedir(), '.local', 'bin'),
1821
+ path.join(os.homedir(), '.cargo', 'bin'),
1822
+ );
1823
+
1808
1824
  // On Windows the spread above yields a "Path" key, not "PATH". Writing
1809
1825
  // `env.PATH` would create a duplicate key holding only extraDirs and drop
1810
1826
  // everything else; libuv then resolves spawned binaries against the wrong,
package/src/paths.js CHANGED
@@ -58,11 +58,13 @@ function getExtraBinDirs() {
58
58
  // CLIs whose own installer script picks a directory of its own.
59
59
  _addAgentInstallerPaths(dirs);
60
60
 
61
- // Aider (uv tool install) — its executable honors XDG_BIN_HOME /
61
+ // CLIs installed with `uv tool install` — the executable honors XDG_BIN_HOME /
62
62
  // XDG_DATA_HOME/../bin / ~/.local/bin and also always lands in the uv tools
63
63
  // venv. A GUI/daemon process won't see the installer's PATH edit, so add the
64
64
  // real install dirs explicitly (filtered to existing dirs by the caller).
65
+ // `coworker` is OpenWorker's distribution name — see uvToolBinDirs.
65
66
  for (const d of aiderBinDirs()) _push(dirs, d);
67
+ for (const d of uvToolBinDirs('coworker')) _push(dirs, d);
66
68
 
67
69
  // Also add the directory containing the current node binary
68
70
  try {
@@ -787,19 +789,23 @@ function primeBinaryLookup() {
787
789
  }
788
790
 
789
791
  /**
790
- * Directories where the Aider CLI executable can land, in the SAME priority the
791
- * official installer (aider.chat/install.{sh,ps1} → `uv tool install`) uses, so
792
- * detection matches reality on every platform:
792
+ * Directories where a `uv tool install`-ed CLI can land, in the SAME priority uv
793
+ * itself uses, so detection matches reality on every platform:
793
794
  * 1. $XDG_BIN_HOME
794
795
  * 2. $XDG_DATA_HOME/../bin
795
796
  * 3. ~/.local/bin (the default uv-tool / pipx / pip --user bin)
796
- * 4. the uv tools venv for aider-chat (Scripts on Windows, bin on Unix) — the
797
+ * 4. the uv tools venv for `pkg` (Scripts on Windows, bin on Unix) — the
797
798
  * executable always lands here on a successful `uv tool install`, even if
798
799
  * the bin-dir copy/PATH edit didn't happen.
800
+ *
801
+ * `pkg` is the DISTRIBUTION name uv keys its tool dir by, which is not always
802
+ * the command name: aider's CLI is `aider` from `aider-chat`, and OpenWorker's
803
+ * is `openworker-server` from `coworker`.
804
+ *
799
805
  * Uses os.homedir()/live env so it reflects the current process (test-friendly).
800
806
  * Returns directories only; callers join the platform binary names.
801
807
  */
802
- function aiderBinDirs() {
808
+ function uvToolBinDirs(pkg) {
803
809
  const home = os.homedir();
804
810
  const dirs = [];
805
811
  if (process.env.XDG_BIN_HOME) dirs.push(process.env.XDG_BIN_HOME);
@@ -808,17 +814,22 @@ function aiderBinDirs() {
808
814
  if (IS_WINDOWS) {
809
815
  const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
810
816
  const uvTools = process.env.UV_TOOL_DIR || path.join(appData, 'uv', 'tools');
811
- dirs.push(path.join(uvTools, 'aider-chat', 'Scripts'));
817
+ dirs.push(path.join(uvTools, pkg, 'Scripts'));
812
818
  dirs.push(path.join(home, 'bin'));
813
819
  } else {
814
820
  const uvTools = process.env.UV_TOOL_DIR
815
821
  || path.join(home, '.local', 'share', 'uv', 'tools');
816
- dirs.push(path.join(uvTools, 'aider-chat', 'bin'));
822
+ dirs.push(path.join(uvTools, pkg, 'bin'));
817
823
  dirs.push(path.join(home, 'bin'), '/usr/local/bin', '/opt/homebrew/bin');
818
824
  }
819
825
  return dirs;
820
826
  }
821
827
 
828
+ /** Aider's CLI, installed by aider.chat/install.{sh,ps1} → `uv tool install`. */
829
+ function aiderBinDirs() {
830
+ return uvToolBinDirs('aider-chat');
831
+ }
832
+
822
833
  /** Executable suffixes to try for a bare binary name, per platform. */
823
834
  const BIN_EXTS = IS_WINDOWS ? ['.cmd', '.exe', '.bat', ''] : [''];
824
835
 
@@ -889,6 +900,7 @@ module.exports = {
889
900
  getCorePrefix,
890
901
  defaultAgentWorkdir,
891
902
  aiderBinDirs,
903
+ uvToolBinDirs,
892
904
  IS_WINDOWS,
893
905
  IS_MACOS,
894
906
  SEP,
package/src/probe.js CHANGED
@@ -30,7 +30,6 @@ const { spawn } = require('child_process');
30
30
  const { getEnhancedEnv } = require('./paths');
31
31
  const { shouldUseShellForBinary } = require('./adapters/health-status');
32
32
  const { formatAuthGuidance } = require('./auth-guidance');
33
- const { testLLMConnection } = require('./utils');
34
33
 
35
34
  const PROBE_PROMPT = 'hi';
36
35
  const DEFAULT_TIMEOUT_MS = 90_000;
@@ -90,7 +89,10 @@ function authFlavor(entry, agentEnv) {
90
89
  */
91
90
  /** Classify an error by its OUTPUT TEXT alone; null when nothing matches. */
92
91
  function classifyOutputText(text) {
93
- if (/credit balance|insufficient[_ ]?(quota|credits|funds)|payment required|\b402\b|billing/.test(text)) return CODE.OUT_OF_CREDIT;
92
+ // Relays word this their own way and answer 403 rather than 402, so the
93
+ // English-and-402 pattern alone read "out of credit" as "bad credentials"
94
+ // — the one misreading that sends a user to re-authenticate a working key.
95
+ if (/credit balance|insufficient[_ ]?(quota|credits|funds)|payment required|\b402\b|billing|quota[_ ]?exceeded|exceeded your current quota|额度|余额|欠费|余额不足/.test(text)) return CODE.OUT_OF_CREDIT;
94
96
  if (/rate[- ]?limit|too many requests|\b429\b|overloaded|\b529\b/.test(text)) return CODE.RATE_LIMITED;
95
97
  if (/enotfound|econnrefused|econnreset|etimedout|eai_again|fetch failed|socket hang up|network error|self[- ]signed certificate|unable to get local issuer/.test(text)) return CODE.NETWORK;
96
98
  if (/api key.{0,24}(not set|not found|missing)|no api key|missing api key|environment variable.{0,40}(is )?(not set|required)/.test(text)) return CODE.MISSING_API_KEY;
@@ -171,6 +173,37 @@ function buildGuidance(code, entry, agentEnv, extra = {}) {
171
173
  return lines;
172
174
  }
173
175
 
176
+ /**
177
+ * Ask the model endpoint directly why an agent is not answering.
178
+ *
179
+ * Only ever used to REPLACE a verdict that explains nothing, and only when it
180
+ * can do better: a successful call means the endpoint is healthy and whatever
181
+ * ails the CLI is the CLI's own, which the caller's vaguer answer already says
182
+ * more honestly than "everything is fine" would. Likewise an unclassifiable
183
+ * error — swapping one shrug for another helps nobody.
184
+ */
185
+ async function directApiVerdict(agentEnv, entry) {
186
+ // Required here rather than at the top so a test can stand in for it, and
187
+ // so a probe that never reaches this path never loads the HTTP client.
188
+ const { testLLMConnection } = require('./utils');
189
+ let res;
190
+ try {
191
+ res = await testLLMConnection(agentEnv);
192
+ } catch (e) {
193
+ res = { success: false, error: e.message };
194
+ }
195
+ if (!res || res.success) return null;
196
+ const code = classifyFailure(res.error, {});
197
+ if (code === CODE.CLI_ERROR) return null;
198
+ return {
199
+ ok: false,
200
+ method: 'llm_api',
201
+ code,
202
+ message: tail(res.error, OUTPUT_CAP),
203
+ guidance: buildGuidance(code, entry, agentEnv),
204
+ };
205
+ }
206
+
174
207
  /** Run a CLI to completion with a hard timeout. Never rejects. */
175
208
  function runCommand(cmd, args, { env, timeoutMs, shell = false }) {
176
209
  return new Promise((resolve) => {
@@ -266,6 +299,10 @@ async function probeAgentType(connector, type, opts = {}) {
266
299
  || (entry.probe && entry.probe.timeout_s ? entry.probe.timeout_s * 1000 : DEFAULT_TIMEOUT_MS);
267
300
  const env = { ...getEnhancedEnv(), ...agentEnv };
268
301
 
302
+ // Hoisted above the CLI tier: a failing CLI probe consults it too (below).
303
+ const hasApiKey = !!(agentEnv.LLM_API_KEY || agentEnv.OPENAI_API_KEY || agentEnv.ANTHROPIC_API_KEY
304
+ || agentEnv.ANTHROPIC_AUTH_TOKEN || agentEnv.KIMI_API_KEY || agentEnv.MOONSHOT_API_KEY);
305
+
269
306
  // ── Live tier 1: the agent's own CLI, when the registry declares how ──
270
307
  const probeArgs = entry.probe && Array.isArray(entry.probe.args) ? entry.probe.args : null;
271
308
  const altCheck = entry.check_ready && entry.check_ready.alt_check;
@@ -298,6 +335,19 @@ async function probeAgentType(connector, type, opts = {}) {
298
335
  guidance: buildGuidance(code, entry, agentEnv),
299
336
  });
300
337
  }
338
+ // A CLI verdict is not always the true one. A relay answering 403 "out of
339
+ // credit" makes Claude Code retry in silence until the probe's clock runs
340
+ // out, and all that reaches here is a timeout with an unrelated warning on
341
+ // stderr — which the guidance then reads as "it may be waiting for a login
342
+ // prompt", sending the user to a terminal to fix something that is not
343
+ // broken. Where the agent carries an endpoint and a key, ask the API the
344
+ // same question directly: one round trip, and it says why.
345
+ const unexplained = code === CODE.TIMEOUT || code === CODE.CLI_ERROR || code === CODE.EMPTY_RESPONSE;
346
+ if (unexplained && hasApiKey) {
347
+ const direct = await directApiVerdict(agentEnv, entry);
348
+ if (direct) return done(direct);
349
+ }
350
+
301
351
  return done({
302
352
  ok: false, method: 'cli', code,
303
353
  message: tail(res.stderr || res.stdout, OUTPUT_CAP) || (res.timedOut ? 'Timed out' : `Exited with code ${res.code}`),
@@ -306,9 +356,8 @@ async function probeAgentType(connector, type, opts = {}) {
306
356
  }
307
357
 
308
358
  // ── Live tier 2: direct LLM API call for API-key agents (e.g. OpenClaw) ──
309
- const hasApiKey = !!(agentEnv.LLM_API_KEY || agentEnv.OPENAI_API_KEY || agentEnv.ANTHROPIC_API_KEY
310
- || agentEnv.KIMI_API_KEY || agentEnv.MOONSHOT_API_KEY);
311
359
  if (hasApiKey || (entry.install && entry.install.api_only)) {
360
+ const { testLLMConnection } = require('./utils');
312
361
  let res;
313
362
  try { res = await testLLMConnection(agentEnv); } catch (e) { res = { success: false, error: e.message }; }
314
363
  if (res && res.success) {
package/src/utils.js CHANGED
@@ -13,12 +13,20 @@ function testLLMConnection(env) {
13
13
  const http = require('http');
14
14
 
15
15
  const hasKimiConfig = !!(env.KIMI_API_KEY || env.MOONSHOT_API_KEY || env.KIMI_BASE_URL || env.KIMI_MODEL);
16
- const apiKey = env.KIMI_API_KEY || env.MOONSHOT_API_KEY || env.LLM_API_KEY || env.OPENAI_API_KEY || env.ANTHROPIC_API_KEY || '';
16
+ const apiKey = env.KIMI_API_KEY || env.MOONSHOT_API_KEY || env.LLM_API_KEY || env.OPENAI_API_KEY
17
+ || env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || '';
17
18
  if (!apiKey) return Promise.resolve({ success: false, error: 'No API key provided' });
18
19
 
19
20
  let baseUrl = (env.KIMI_BASE_URL || env.LLM_BASE_URL || env.OPENAI_BASE_URL || (hasKimiConfig ? 'https://api.moonshot.ai/v1' : 'https://api.openai.com/v1')).replace(/\/$/, '');
20
21
  const model = env.KIMI_MODEL || env.LLM_MODEL || env.OPENCLAW_MODEL || '';
21
- const isAnthropic = baseUrl.includes('anthropic');
22
+ // An agent configured the Anthropic way is Anthropic-shaped wherever it
23
+ // points. Reading only the URL missed every relay whose host says nothing
24
+ // about the protocol it speaks — the common case for Claude Code, which is
25
+ // configured entirely through ANTHROPIC_* — and sent the test to OpenAI's
26
+ // default endpoint with an Anthropic key.
27
+ const hasAnthropicConfig = !hasKimiConfig && !env.LLM_API_KEY && !env.OPENAI_API_KEY
28
+ && !!(env.ANTHROPIC_BASE_URL || env.ANTHROPIC_MODEL || env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY);
29
+ const isAnthropic = hasAnthropicConfig || baseUrl.includes('anthropic');
22
30
 
23
31
  if (!isAnthropic && !baseUrl.endsWith('/v1')) {
24
32
  baseUrl += '/v1';
@@ -28,14 +36,25 @@ function testLLMConnection(env) {
28
36
  let url, headers, body;
29
37
 
30
38
  if (isAnthropic) {
31
- url = 'https://api.anthropic.com/v1/messages';
39
+ // The configured endpoint, not the official one: a relay IS the API as
40
+ // far as this agent is concerned, and testing api.anthropic.com would
41
+ // report on a service the agent never talks to.
42
+ const base = (env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/+$/, '');
43
+ url = /\/v1$/.test(base) ? `${base}/messages` : `${base}/v1/messages`;
44
+ const token = env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || apiKey;
32
45
  headers = {
33
- 'x-api-key': apiKey,
46
+ 'x-api-key': token,
34
47
  'anthropic-version': '2023-06-01',
35
48
  'content-type': 'application/json',
36
49
  };
50
+ // Relays are split on which header they read, and Claude Code itself
51
+ // sends the bearer when pointed at one. Only off the official host: the
52
+ // real API has no use for it.
53
+ if (!/api\.anthropic\.com$/.test(new URL(url).hostname)) {
54
+ headers['authorization'] = `Bearer ${token}`;
55
+ }
37
56
  body = JSON.stringify({
38
- model: model || 'claude-sonnet-4-20250514',
57
+ model: env.ANTHROPIC_MODEL || model || 'claude-sonnet-4-20250514',
39
58
  max_tokens: 32,
40
59
  messages: [{ role: 'user', content: 'Say hi in 5 words.' }],
41
60
  });