@oar-sh/oar 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.
Files changed (101) hide show
  1. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  2. package/CHANGELOG.md +192 -0
  3. package/README.md +13 -12
  4. package/package.json +1 -1
  5. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  6. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  7. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  9. package/server/claude-worker/claude-sdk-adapter.mjs +35 -9
  10. package/server/claude-worker/claude-session-process.mjs +166 -21
  11. package/server/claude-worker/claude-session-worker-main.mjs +3 -0
  12. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  13. package/server/claude-worker/claude-turn-publisher.mjs +117 -5
  14. package/server/claude-worker/claude-usage-totals.mjs +53 -0
  15. package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
  16. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
  19. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  20. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  21. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  22. package/server/db-schema.mjs +33 -1
  23. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
  24. package/server/grok-worker/acp-client.mjs +1 -1
  25. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  26. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  27. package/server/mcp/oar-mcp-server.mjs +11 -5
  28. package/server/migrations/0031-queue-provider.mjs +143 -0
  29. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  30. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  31. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  32. package/server/public/app/activity-replay-state.mjs +4 -3
  33. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  34. package/server/public/app/agent-sessions-settings.mjs +1 -1
  35. package/server/public/app/api-client.js +52 -0
  36. package/server/public/app/bootstrap.js +153 -27
  37. package/server/public/app/claude-api-settings-ui.js +362 -0
  38. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  39. package/server/public/app/claude-billing-toggle.mjs +112 -0
  40. package/server/public/app/context-tier-options.mjs +6 -2
  41. package/server/public/app/context-usage-view.mjs +1 -1
  42. package/server/public/app/conversation-provider-indicator.mjs +11 -0
  43. package/server/public/app/conversation-view.js +29 -3
  44. package/server/public/app/inbox-view.mjs +1 -1
  45. package/server/public/app/journal-view.js +127 -10
  46. package/server/public/app/model-selector-options.mjs +17 -1
  47. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  48. package/server/public/app/provider-readiness.mjs +5 -2
  49. package/server/public/app/relay-error-ctas.mjs +50 -0
  50. package/server/public/app/relay-schedules-ui.js +1 -1
  51. package/server/public/app/settings-modal.js +11 -0
  52. package/server/public/app/settings-tabs.js +1 -1
  53. package/server/public/app/socket-handlers.js +18 -1
  54. package/server/public/app/tool-activity-emoji.mjs +4 -3
  55. package/server/public/app/usage-limit-ui.js +136 -5
  56. package/server/public/index.html +189 -3
  57. package/server/relay-tools.md +4 -4
  58. package/server/repositories/conversation-group-repository.mjs +1 -1
  59. package/server/repositories/message-repository.mjs +7 -0
  60. package/server/repositories/session-repository.mjs +8 -1
  61. package/server/routes/ask-user-routes.mjs +1 -1
  62. package/server/routes/conversation-group-routes.mjs +1 -1
  63. package/server/routes/messages-routes.mjs +645 -27
  64. package/server/routes/relay-ask-routes.mjs +3 -3
  65. package/server/routes/relay-event-routes.mjs +2 -2
  66. package/server/routes/relay-schedule-routes.mjs +1 -1
  67. package/server/routes/remote-relay-routes.mjs +3 -3
  68. package/server/routes/sessions-routes.mjs +327 -29
  69. package/server/server-runtime.mjs +363 -26
  70. package/server/services/about-service.mjs +1 -0
  71. package/server/services/background-work-view.mjs +2 -1
  72. package/server/services/claude-api-settings-service.mjs +575 -0
  73. package/server/services/claude-api-usage-service.mjs +361 -0
  74. package/server/services/claude-billing-swap-service.mjs +274 -0
  75. package/server/services/claude-billing-switch-service.mjs +343 -0
  76. package/server/services/plan-usage-claude-api.mjs +445 -0
  77. package/server/services/plan-usage-service.mjs +5 -0
  78. package/server/services/provider-model-selection.mjs +3 -2
  79. package/server/services/provider-reasoning-effort.mjs +9 -0
  80. package/server/services/relay-ask-service.mjs +1 -1
  81. package/server/services/relay-event-service.mjs +3 -2
  82. package/server/services/relay-schedule-service.mjs +1 -1
  83. package/server/services/remote-relay-caller-context.mjs +7 -0
  84. package/server/services/remote-relay-dispatcher.mjs +83 -14
  85. package/server/services/remote-relay-registry-service.mjs +1 -1
  86. package/server/services/session-worker-launch-service.mjs +61 -2
  87. package/server/services/session-worker-registry-service.mjs +28 -1
  88. package/server/services/session-worker-websocket-service.mjs +17 -0
  89. package/server/services/usage-limit-pause-service.mjs +55 -4
  90. package/server/services/worker-secret-redaction.mjs +2 -0
  91. package/shared/anthropic-api.mjs +202 -0
  92. package/shared/provider-routing.mjs +5 -3
  93. package/shared/relay-asks.mjs +11 -5
  94. package/shared/relay-events.mjs +2 -2
  95. package/shared/relay-schedules.mjs +2 -2
  96. package/shared/remote-relay-contract.mjs +29 -9
  97. package/shared/remote-relay-tool-core.mjs +23 -6
  98. package/shared/steer-settle-failure.mjs +1 -0
  99. package/shared/worker-runtime/api-client.mjs +2 -2
  100. package/shared/worker-runtime/heartbeat.mjs +5 -0
  101. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -0,0 +1,345 @@
1
+ // The Claude API backend of the Claude session runner: Claude Code on the
2
+ // Anthropic API, billed to the API key saved in Settings → Providers → Claude
3
+ // API instead of the host's Claude login.
4
+ //
5
+ // - The CLI's environment: the key as ANTHROPIC_API_KEY, the Anthropic API as
6
+ // ANTHROPIC_BASE_URL, and nothing of the host's Claude login, another
7
+ // Anthropic configuration or another provider's switch. The host's Claude
8
+ // Code state directory stays (CLAUDE_CONFIG_DIR is left as the relay set
9
+ // it): the chats are Claude chats, with the Claude provider's sessions,
10
+ // transcripts, resume and task output.
11
+ // - The flag settings layer: the user's settings and the chat folder's
12
+ // (.claude/settings.json, .claude/settings.local.json) can set `env`
13
+ // (another ANTHROPIC_BASE_URL, an ANTHROPIC_API_KEY that replaces the key,
14
+ // an ANTHROPIC_AUTH_TOKEN that beats it, a proxy, another provider, custom
15
+ // headers, a CA) and `apiKeyHelper` (a script whose output beats it). The
16
+ // flag layer outranks them all, so it pins the base URL and the key, blanks
17
+ // the bearer token and the helper, and holds every other variable that
18
+ // routes the requests or decides whom the CLI trusts at the CLI's own value
19
+ // (CLAUDE_CLI_PINNED_ENV, shared with the DeepSeek backend). The spawn's
20
+ // `--settings` argument, which other users of the host can read, carries
21
+ // the endpoint and the blanks (claudeApiFlagSettings); the key and the
22
+ // worker's own proxy and TLS values go into the same layer over the CLI's
23
+ // stdin before the first message (liveFlagSettings, claude-sdk-adapter.mjs;
24
+ // scripts/claude-api-cli-proof.mjs shows it).
25
+ // - The check: the CLI's `system/init` names where it took its API key from.
26
+ // Anything but ANTHROPIC_API_KEY stops the turn as soon as the init is read
27
+ // (checkInit). The CLI does not wait for that verdict: it sends its first
28
+ // request some 50 to 90 ms after the init (scripts/claude-api-cli-proof.mjs),
29
+ // so one request may already be out on the other credential when the stop
30
+ // lands. The check limits a wrong credential to that one request; it does
31
+ // not prevent it.
32
+ // - The model, effort, thinking and images: Claude's, exactly as the Claude
33
+ // worker has them (`claudeModels`).
34
+ // - What it reports: no Claude plan usage (there is no plan) and no Claude
35
+ // usage limit (the API answers with its own errors, which describeFailure
36
+ // reads in plain words).
37
+ //
38
+ // - Hooks: none run, neither the user's nor the chat folder's, and no
39
+ // statusLine command (CLAUDE_CLI_KEY_CHAT_SETTINGS, on the command line).
40
+ //
41
+ // The agent's shell inherits the CLI's environment, the key included; the
42
+ // relay takes the saved key out of what the worker posts and logs.
43
+
44
+ import { resolveAnthropicApiBaseUrl } from '../../shared/anthropic-api.mjs';
45
+ import { isHostClaudeSessionVariable } from '../../shared/host-claude-session-env.mjs';
46
+ import { CLAUDE_CLI_KEY_CHAT_SETTINGS, claudeCliPinnedEnv } from '../claude-worker/claude-cli-pinned-env.mjs';
47
+
48
+ export const CLAUDE_API_BACKEND_ID = 'claude-api';
49
+ export const CLAUDE_API_BACKEND_LABEL = 'Claude API';
50
+ // Where the relay's launch puts the key (session-worker-launch-service.mjs).
51
+ export const CLAUDE_API_KEY_ENV = 'OAR_CLAUDE_API_KEY';
52
+ // The stable code of a turn stopped because the CLI took another credential.
53
+ export const CLAUDE_API_KEY_SOURCE_CODE = 'claude-api-key-source';
54
+ // The only API key source the check lets through: the key in the CLI's
55
+ // environment, which is this backend's.
56
+ export const EXPECTED_API_KEY_SOURCE = 'ANTHROPIC_API_KEY';
57
+ // The sources the SDK's `system/init` can name (sdk.d.ts, ApiKeySource). A
58
+ // note names one of these, never a text of the CLI's own.
59
+ const KNOWN_API_KEY_SOURCES = Object.freeze([
60
+ 'ANTHROPIC_API_KEY',
61
+ 'apiKeyHelper',
62
+ '/login managed key',
63
+ 'none',
64
+ 'user',
65
+ 'project',
66
+ 'org',
67
+ 'temporary',
68
+ 'oauth',
69
+ ]);
70
+
71
+ const BILLING_URL = 'https://platform.claude.com/settings/billing';
72
+ const LIMITS_URL = 'https://platform.claude.com/settings/limits';
73
+ const SETTINGS_PLACE = 'Settings → Providers → Claude API';
74
+ const KEY_SOURCE_GUIDANCE = 'Remove the apiKeyHelper or other credential from the host\'s Claude Code settings '
75
+ + `(settings.json), check the key in ${SETTINGS_PLACE}, then send the message again.`;
76
+
77
+ // Variables that would hand the CLI another credential or point it at another
78
+ // endpoint or cloud (the bundled CLI, 2026-10-25). Every ANTHROPIC_* is
79
+ // dropped as well (FOREIGN_CLI_PREFIXES).
80
+ const FOREIGN_CLI_VARIABLES = Object.freeze([
81
+ // The host's Claude login, and the other ways to hand the CLI a login or
82
+ // a key.
83
+ 'CLAUDE_CODE_OAUTH_TOKEN',
84
+ 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR',
85
+ 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN',
86
+ 'CLAUDE_CODE_OAUTH_SCOPES',
87
+ 'CLAUDE_CODE_OAUTH_CLIENT_ID',
88
+ 'CLAUDE_CODE_CUSTOM_OAUTH_URL',
89
+ 'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR',
90
+ 'CLAUDE_CODE_API_KEY_HELPER_TTL_MS',
91
+ // Another provider for the CLI's requests, and their auth bypasses.
92
+ 'CLAUDE_CODE_USE_BEDROCK',
93
+ 'CLAUDE_CODE_USE_VERTEX',
94
+ 'CLAUDE_CODE_USE_FOUNDRY',
95
+ 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
96
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
97
+ 'CLAUDE_CODE_USE_GATEWAY',
98
+ 'CLAUDE_CODE_USE_MANTLE',
99
+ 'CLAUDE_CODE_USE_CCR_V2',
100
+ 'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
101
+ 'CLAUDE_CODE_SKIP_VERTEX_AUTH',
102
+ 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
103
+ 'CLAUDE_CODE_SKIP_MANTLE_AUTH',
104
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_AWS_AUTH',
105
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_GOOGLE_CLOUD_AUTH',
106
+ 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
107
+ // A host's model and effort pins: the turn's model and effort come from
108
+ // the relay, which bills them to the key.
109
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
110
+ 'CLAUDE_CODE_SUBAGENT_MODEL_FORCE',
111
+ 'CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL',
112
+ 'CLAUDE_CODE_EFFORT_LEVEL',
113
+ 'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
114
+ // Never set, and dropped when inherited: it forces the permission mode to
115
+ // default (plan mode is lost) and sandboxes Bash.
116
+ 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB',
117
+ // What the relay hands its worker (session-worker-launch-service.mjs,
118
+ // TMUX_WORKER_EXPORTED_ENV_VARS): the worker's own settings, which neither
119
+ // Claude Code nor anything the agent runs reads. A shell of the agent's
120
+ // that starts a relay of its own would take the worker's kind, folders and
121
+ // tools file for its own. The relay's Claude configuration
122
+ // (CLAUDE_CONFIG_DIR, CLAUDE_CODE_EXECUTABLE) is not on it.
123
+ 'COPILOT_ALLOW_ALL',
124
+ 'GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS',
125
+ 'COPILOT_SDK_PATH',
126
+ 'EXTENSION_PATH',
127
+ 'COPILOT_MODEL',
128
+ 'COPILOT_RELAY_MODEL',
129
+ 'CLAUDE_RELAY_MODEL',
130
+ 'CLAUDE_CLOUD_RELAY_MODEL',
131
+ 'CLAUDE_API_RELAY_MODEL',
132
+ 'CURSOR_RELAY_MODEL',
133
+ 'CURSOR_AGENT_STORE_DIR',
134
+ 'CURSOR_API_KEY',
135
+ 'GROK_RELAY_MODEL',
136
+ 'GROK_CLI_COMMAND',
137
+ 'GROK_ALWAYS_APPROVE',
138
+ 'SESSION_ID',
139
+ 'COPILOT_WORKSPACE_ROOT',
140
+ 'INIT_CWD',
141
+ ]);
142
+ // The same by prefix: every Anthropic variable (the two the CLI needs are set
143
+ // again below), the relay's folders, config, tools file, worker kind and log
144
+ // file (COPILOT_WEB_RELAY_*), the Copilot provider settings, the DeepSeek
145
+ // worker's settings, every Claude gateway setting and the relay's own OAR_*
146
+ // switches (the key's own variable among them).
147
+ const FOREIGN_CLI_PREFIXES = Object.freeze([
148
+ 'ANTHROPIC_',
149
+ 'COPILOT_WEB_RELAY_',
150
+ 'COPILOT_PROVIDER_',
151
+ 'DEEPSEEK_',
152
+ 'CLAUDE_GATEWAY_',
153
+ 'OAR_',
154
+ ]);
155
+
156
+ /**
157
+ * Whether a variable of the worker's stays out of the CLI's environment:
158
+ * another credential, endpoint or provider, the relay's settings for its
159
+ * worker, or the host Claude Code session's variables when the relay was
160
+ * started from inside one (shared/host-claude-session-env.mjs; the launcher
161
+ * drops those for every worker, the backend again, however the worker was
162
+ * started).
163
+ */
164
+ function isForeignCliVariable(key) {
165
+ const name = String(key).toUpperCase();
166
+ return FOREIGN_CLI_VARIABLES.includes(name)
167
+ || FOREIGN_CLI_PREFIXES.some((prefix) => name.startsWith(prefix))
168
+ || isHostClaudeSessionVariable(name);
169
+ }
170
+
171
+ function text(value) {
172
+ return String(value ?? '').trim();
173
+ }
174
+
175
+ /**
176
+ * The environment the CLI runs with: the worker's own without anything
177
+ * foreign, then the key and the endpoint. `OAR_ANTHROPIC_API_BASE_URL` names
178
+ * a fake API for tests; only a loopback URL is taken from it
179
+ * (resolveAnthropicApiBaseUrl), anything else is the Anthropic API.
180
+ */
181
+ export function claudeApiCliEnv(baseEnv = process.env, { apiKey = '', baseUrl = '' } = {}) {
182
+ const key = text(apiKey);
183
+ if (!key) throw new Error('claude-api-key-not-configured');
184
+ const next = {};
185
+ for (const [name, value] of Object.entries(baseEnv || {})) {
186
+ if (isForeignCliVariable(name)) continue;
187
+ next[name] = value;
188
+ }
189
+ next.ANTHROPIC_API_KEY = key;
190
+ next.ANTHROPIC_BASE_URL = text(baseUrl) || resolveAnthropicApiBaseUrl(baseEnv || {});
191
+ return next;
192
+ }
193
+
194
+ /**
195
+ * The flag settings layer every CLI of this backend is spawned with, on its
196
+ * command line: no key in it. It outranks the user's, the project's and the
197
+ * local settings, so their `env` cannot send the requests elsewhere or swap
198
+ * the key for a bearer token, and their `apiKeyHelper` cannot swap it for
199
+ * its own (nor run at the CLI's startup). No hooks and no statusLine command
200
+ * run (CLAUDE_CLI_KEY_CHAT_SETTINGS). Skills, plugins and MCP servers are
201
+ * left as the other layers have them.
202
+ */
203
+ export function claudeApiFlagSettings(baseUrl) {
204
+ return {
205
+ ...CLAUDE_CLI_KEY_CHAT_SETTINGS,
206
+ env: {
207
+ ANTHROPIC_BASE_URL: text(baseUrl),
208
+ ANTHROPIC_AUTH_TOKEN: '',
209
+ },
210
+ apiKeyHelper: '',
211
+ };
212
+ }
213
+
214
+ /**
215
+ * The same layer with the key, which the CLI takes over stdin before the
216
+ * first message (claude-sdk-adapter.mjs): a settings file's
217
+ * env.ANTHROPIC_API_KEY would otherwise replace the CLI's environment's.
218
+ * Every other variable that routes the CLI's requests, and with them the key,
219
+ * or decides whom it trusts on the way (the proxies, the other providers'
220
+ * switches and endpoints, the custom headers, the CA and TLS settings) holds
221
+ * the value of `cliEnv`, the environment the CLI was spawned with, or is
222
+ * blanked (claudeCliPinnedEnv).
223
+ */
224
+ export function claudeApiLiveFlagSettings(baseUrl, apiKey, cliEnv = {}) {
225
+ const key = text(apiKey);
226
+ if (!key) throw new Error('claude-api-key-not-configured');
227
+ const settings = claudeApiFlagSettings(baseUrl);
228
+ return { ...settings, env: { ...claudeCliPinnedEnv(cliEnv), ...settings.env, ANTHROPIC_API_KEY: key } };
229
+ }
230
+
231
+ /** The source the CLI named, as a note may quote it: a known name, or a stand-in. */
232
+ function quotedApiKeySource(value) {
233
+ const source = typeof value === 'string' ? value : '';
234
+ if (KNOWN_API_KEY_SOURCES.includes(source)) return `"${source}"`;
235
+ return source ? 'a source this relay does not know' : 'no source';
236
+ }
237
+
238
+ /**
239
+ * Null when the CLI's `system/init` says it took the key from
240
+ * ANTHROPIC_API_KEY (the backend's), else the Error that stops the turn. Its
241
+ * message names the source the CLI reported, never a key. `afterTraffic`: the
242
+ * init came while the turn was already talking to the API, so one request
243
+ * may have gone out on the other credential.
244
+ */
245
+ export function checkClaudeApiInit(sdkMessage, { afterTraffic = false } = {}) {
246
+ if (!sdkMessage || sdkMessage.type !== 'system' || sdkMessage.subtype !== 'init') return null;
247
+ const source = sdkMessage.apiKeySource;
248
+ if (source === EXPECTED_API_KEY_SOURCE) return null;
249
+ const error = new Error(
250
+ `Claude Code reported ${quotedApiKeySource(source)} as its API key source instead of the saved Claude API key, so the turn was stopped.`
251
+ + (afterTraffic ? ' At most one request may have gone out on that credential.' : ''),
252
+ );
253
+ error.code = CLAUDE_API_KEY_SOURCE_CODE;
254
+ error.apiKeySource = KNOWN_API_KEY_SOURCES.includes(source) ? source : null;
255
+ error.guidance = KEY_SOURCE_GUIDANCE;
256
+ return error;
257
+ }
258
+
259
+ /**
260
+ * `{ code, guidance }` for a failed request the Claude API has its own advice
261
+ * for, or null. Read from the error the runner stopped the turn with, the
262
+ * HTTP status the result reports, else from the status and the words the CLI
263
+ * quotes in its error text ("API Error: 401 …"). The account refusals come
264
+ * first: a spend limit can come as a 429, which is not a rate limit.
265
+ */
266
+ export function describeClaudeApiFailure(errorText, { status = null, error = null } = {}) {
267
+ if (error?.code === CLAUDE_API_KEY_SOURCE_CODE) {
268
+ return { code: CLAUDE_API_KEY_SOURCE_CODE, guidance: error.guidance || KEY_SOURCE_GUIDANCE };
269
+ }
270
+ const message = text(errorText);
271
+ const code = Number(status);
272
+ if (/credit balance is too low/i.test(message)) {
273
+ return {
274
+ code: 'claude-api-credit',
275
+ guidance: `The Claude API account's credit balance is too low. Add credits in the Claude Console (${BILLING_URL}), then send the message again.`,
276
+ };
277
+ }
278
+ if (/API usage limits|enforced_spend_limit_reached/i.test(message)) {
279
+ return {
280
+ code: 'claude-api-spend-limit',
281
+ guidance: `The Claude API account has reached its spend limit. Raise the limit in the Claude Console (${LIMITS_URL}) or wait until it resets, then send the message again.`,
282
+ };
283
+ }
284
+ if (code === 402 || /\b402\b|billing_error/i.test(message)) {
285
+ return {
286
+ code: 'claude-api-billing',
287
+ guidance: `The Claude API refused the request for a billing reason. Check the payment details in the Claude Console (${BILLING_URL}), then send the message again.`,
288
+ };
289
+ }
290
+ if (
291
+ code === 401
292
+ || code === 403
293
+ || /\b40[13]\b|authentication_error|permission_error|invalid x-api-key|invalid api key/i.test(message)
294
+ ) {
295
+ return {
296
+ code: 'claude-api-key',
297
+ guidance: `The Claude API did not accept the API key. Check it in ${SETTINGS_PLACE}, then send the message again.`,
298
+ };
299
+ }
300
+ if (code === 429 || code === 529 || /\b(?:429|529)\b|rate_limit_error|overloaded_error/i.test(message)) {
301
+ return {
302
+ code: 'claude-api-rate-limit',
303
+ guidance: `The Claude API is rate-limiting this key or is overloaded. Send the message again in a moment; the key's rate limits are in the Claude Console (${LIMITS_URL}).`,
304
+ };
305
+ }
306
+ return null;
307
+ }
308
+
309
+ export function createClaudeApiClaudeBackend({ env = process.env } = {}) {
310
+ const apiKey = text(env[CLAUDE_API_KEY_ENV]);
311
+ const baseUrl = resolveAnthropicApiBaseUrl(env);
312
+
313
+ return {
314
+ id: CLAUDE_API_BACKEND_ID,
315
+ providerType: CLAUDE_API_BACKEND_ID,
316
+ label: CLAUDE_API_BACKEND_LABEL,
317
+ // Claude's models, effort, thinking, `[1m]`, images and sub-agent models,
318
+ // exactly as the Claude worker has them: the runner and the adapter take
319
+ // none of those from this backend.
320
+ claudeModels: true,
321
+ // No plan windows and no subscription usage limit on an API key, and no
322
+ // usage route of its own.
323
+ reportsPlanUsage: false,
324
+ reportsUsageLimit: false,
325
+ usageRoute: null,
326
+ describeFailure: describeClaudeApiFailure,
327
+ checkInit: checkClaudeApiInit,
328
+ /** The settings layer pin, merged into the spawn's `settings` (no key in it). */
329
+ flagSettings: () => claudeApiFlagSettings(baseUrl),
330
+ /**
331
+ * The layer again with the key and the routing and trust pins for a CLI
332
+ * spawned with `cliEnv`, over stdin before the first message.
333
+ */
334
+ liveFlagSettings: (cliEnv = claudeApiCliEnv(env, { apiKey, baseUrl })) => claudeApiLiveFlagSettings(baseUrl, apiKey, cliEnv),
335
+
336
+ /**
337
+ * The CLI's environment, built from the worker's own (which must not
338
+ * reach the CLI as it is: it carries OAR_CLAUDE_API_KEY and possibly the
339
+ * host's login or Anthropic settings).
340
+ */
341
+ spawnEnv(baseEnv = process.env) {
342
+ return claudeApiCliEnv(baseEnv, { apiKey, baseUrl });
343
+ },
344
+ };
345
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // Claude API session worker: the Claude session worker with its CLI on the
3
+ // Anthropic API and the key saved in Settings → Providers → Claude API
4
+ // (claude-api-backend.mjs). The relay's launch puts the key in
5
+ // OAR_CLAUDE_API_KEY (through the owner-only secret file under tmux) and the
6
+ // chat's model in CLAUDE_API_RELAY_MODEL. The CLI keeps the host's Claude
7
+ // Code state directory: the chats are Claude chats, resumed and read where
8
+ // the Claude worker reads them.
9
+ import { installWorkerLogFile, installWorkerLogRedaction } from '../../shared/worker-runtime/worker-log-file.mjs';
10
+ import { runClaudeSessionWorker } from '../claude-worker/claude-session-worker-main.mjs';
11
+ import { CLAUDE_API_KEY_ENV, createClaudeApiClaudeBackend } from './claude-api-backend.mjs';
12
+
13
+ installWorkerLogFile();
14
+ // The key never shows in this worker's console or log, whatever the CLI or
15
+ // the agent prints (installed after the log file, so its copy is redacted too).
16
+ installWorkerLogRedaction({ secrets: [process.env[CLAUDE_API_KEY_ENV]] });
17
+
18
+ const backend = createClaudeApiClaudeBackend({ env: process.env });
19
+
20
+ runClaudeSessionWorker({
21
+ logTag: 'claude-api-worker',
22
+ workerName: 'claude-api-session-worker',
23
+ // None set: the CLI's own default, as for a Claude chat without a model.
24
+ defaultModel: String(process.env.CLAUDE_API_RELAY_MODEL || '').trim(),
25
+ backend,
26
+ }).catch((error) => {
27
+ console.error('claude-api-session-worker fatal:', error?.stack || error?.message || error);
28
+ process.exit(1);
29
+ });
@@ -0,0 +1,150 @@
1
+ // The CLI's routing and trust variables a chat folder's settings must not
2
+ // set, shared by the backends that run Claude Code on a key of the relay's
3
+ // (DeepSeek, Claude API): each pins them in its live flag settings. And the
4
+ // settings both spawn the CLI with: no hooks (CLAUDE_CLI_KEY_CHAT_SETTINGS).
5
+ //
6
+ // The list comes from the bundled CLI and only from it. On every SDK or CLI
7
+ // bump, re-run both proofs (scripts/deepseek-cli-proof.mjs,
8
+ // scripts/claude-api-cli-proof.mjs) and the env-name grep over that binary
9
+ // (fixed names, grep -a, under timeout and nice; never a regex over the
10
+ // whole binary), and update the list and its source line below.
11
+
12
+ /**
13
+ * Every variable through which the env of the chat folder's settings
14
+ * (.claude/settings.json, settings.local.json) could send the CLI's requests,
15
+ * and with them the key, somewhere else, or change whom the CLI trusts on the
16
+ * way: the endpoints, the provider switches, the credentials, the proxies and
17
+ * the TLS trust. The DeepSeek and the Claude API backends pin each one in
18
+ * their live flag settings (liveFlagSettings), which the CLI applies over the
19
+ * folder's, to the value the CLI was spawned with: the worker's own, else the
20
+ * other case's for a proxy variable, else ''.
21
+ * '' is unset to the CLI for each of these (truthy or trimmed reads), except
22
+ * SSL_CERT_FILE and SSL_CERT_DIR: '' empties OpenSSL's and Node's system
23
+ * store, so those two are pinned only when the worker sets them.
24
+ *
25
+ * Source: the names Claude Code 2.1.304 (the bundled CLI) reads, by a grep
26
+ * of the binary; the CLI's own lists of endpoints (with their selection
27
+ * switches and companions) and credentials are in it as they are there. A
28
+ * deny-list, because a settings layer can only pin names known in advance.
29
+ * Left out, each for its reason: the model variables (same endpoint, same
30
+ * key), what the CLI already drops from a folder's settings or from all
31
+ * settings (CLAUDE_CONFIG_DIR, CLAUDE_CODE_CUSTOM_OAUTH_URL, the OTEL
32
+ * exporter endpoints, ANTHROPIC_UNIX_SOCKET, the host-managed provider
33
+ * switches, the file-descriptor credentials), a provider's own SDK settings
34
+ * (AWS_*, GOOGLE_*, AZURE_*, read only for the provider the switches here
35
+ * turn off), NODE_OPTIONS (at runtime the CLI reads only its system-CA
36
+ * flags, which add no CA), and what routes only other programs' traffic
37
+ * (NPM_CONFIG_*, GIT_*, the resolver's variables).
38
+ */
39
+ export const CLAUDE_CLI_PINNED_ENV = Object.freeze([
40
+ // Endpoints: the CLI's list, its fallback and the headers sent with them.
41
+ 'ANTHROPIC_BASE_URL',
42
+ '_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL',
43
+ 'ANTHROPIC_CUSTOM_HEADERS',
44
+ 'ANTHROPIC_BEDROCK_BASE_URL',
45
+ 'ANTHROPIC_VERTEX_BASE_URL',
46
+ 'ANTHROPIC_FOUNDRY_BASE_URL',
47
+ 'ANTHROPIC_AWS_BASE_URL',
48
+ 'ANTHROPIC_GOOGLE_CLOUD_BASE_URL',
49
+ 'ANTHROPIC_BEDROCK_MANTLE_BASE_URL',
50
+ 'CLAUDE_CODE_API_BASE_URL',
51
+ 'CLAUDE_CODE_ARTIFACTS_API_BASE_URL',
52
+ 'CLAUDE_CODE_ARTIFACTS_API_TOKEN',
53
+ 'CLAUDE_CODE_ARTIFACT_ASSET_BASE_URL',
54
+ 'CLAUDE_CODE_ARTIFACT_LIVE_BASE_URL',
55
+ 'CLAUDE_CODE_ARTIFACT_SYNC_BASE_URL',
56
+ 'CLAUDE_CODE_ARTIFACT_VIEWER_BASE_URL',
57
+ 'CLAUDE_CODE_MEMORY_API_BASE_URL',
58
+ 'CLAUDE_CODE_MEMORY_API_TOKEN',
59
+ // Provider switches.
60
+ 'CLAUDE_CODE_USE_BEDROCK',
61
+ 'CLAUDE_CODE_USE_VERTEX',
62
+ 'CLAUDE_CODE_USE_FOUNDRY',
63
+ 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
64
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
65
+ 'CLAUDE_CODE_USE_MANTLE',
66
+ 'CLAUDE_CODE_USE_GATEWAY',
67
+ 'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
68
+ 'CLAUDE_CODE_SKIP_VERTEX_AUTH',
69
+ 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
70
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_AWS_AUTH',
71
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_GOOGLE_CLOUD_AUTH',
72
+ 'CLAUDE_CODE_SKIP_MANTLE_AUTH',
73
+ // Credentials: the CLI's list, the OAuth login, the federation profile and
74
+ // the proxy credential helper.
75
+ 'ANTHROPIC_API_KEY',
76
+ 'ANTHROPIC_AUTH_TOKEN',
77
+ 'CLAUDE_CODE_OAUTH_TOKEN',
78
+ 'AWS_BEARER_TOKEN_BEDROCK',
79
+ 'ANTHROPIC_FOUNDRY_API_KEY',
80
+ 'ANTHROPIC_FOUNDRY_AUTH_TOKEN',
81
+ 'ANTHROPIC_AWS_API_KEY',
82
+ 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN',
83
+ 'CLAUDE_CODE_OAUTH_SCOPES',
84
+ 'CLAUDE_CODE_OAUTH_CLIENT_ID',
85
+ 'ANTHROPIC_PROFILE',
86
+ 'ANTHROPIC_IDENTITY_TOKEN',
87
+ 'ANTHROPIC_IDENTITY_TOKEN_FILE',
88
+ 'ANTHROPIC_FEDERATION_RULE_ID',
89
+ 'ANTHROPIC_ORGANIZATION_ID',
90
+ 'ANTHROPIC_SERVICE_ACCOUNT_ID',
91
+ 'ANTHROPIC_SCOPE',
92
+ 'ANTHROPIC_WORKSPACE_ID',
93
+ 'CLAUDE_CODE_ENABLE_PROXY_AUTH_HELPER',
94
+ // Proxies: the CLI reads both cases, the lower one first.
95
+ 'HTTPS_PROXY',
96
+ 'https_proxy',
97
+ 'HTTP_PROXY',
98
+ 'http_proxy',
99
+ 'ALL_PROXY',
100
+ 'all_proxy',
101
+ 'NO_PROXY',
102
+ 'no_proxy',
103
+ 'CLAUDE_CODE_HTTPS_PROXY',
104
+ 'CLAUDE_CODE_HTTP_PROXY',
105
+ // TLS trust and the client certificate.
106
+ 'NODE_TLS_REJECT_UNAUTHORIZED',
107
+ 'NODE_EXTRA_CA_CERTS',
108
+ 'CLAUDE_CODE_CERT_STORE',
109
+ 'CLAUDE_CODE_CLIENT_CERT',
110
+ 'CLAUDE_CODE_CLIENT_KEY',
111
+ 'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE',
112
+ 'SSL_CERT_FILE',
113
+ 'SSL_CERT_DIR',
114
+ ]);
115
+ // The two of CLAUDE_CLI_PINNED_ENV for which '' is not unset (see there).
116
+ const PINNED_ONLY_WHEN_SET = new Set(['SSL_CERT_FILE', 'SSL_CERT_DIR']);
117
+ // The proxy variables the CLI and the tools it runs read in either case.
118
+ const OTHER_CASE = new Map(
119
+ ['HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY', 'NO_PROXY'].flatMap((name) => [
120
+ [name, name.toLowerCase()],
121
+ [name.toLowerCase(), name],
122
+ ]),
123
+ );
124
+
125
+ /**
126
+ * The settings both backends spawn every CLI with, on its command line (their
127
+ * flagSettings): no hooks and no statusLine command, neither the user's nor
128
+ * the chat folder's nor a plugin's. Those run as the relay's user with the
129
+ * CLI's environment, the key in it, and a folder's SessionStart hook would run
130
+ * before the live pins land. The SDK has the spawn settings in force before
131
+ * any hook (sdk.d.ts, Options.settings; scripts/claude-cli-hooks-proof.mjs
132
+ * shows it). Hooks in managed settings still run: those are the host admin's.
133
+ * OAR's Claude worker registers no SDK hooks of its own.
134
+ */
135
+ export const CLAUDE_CLI_KEY_CHAT_SETTINGS = Object.freeze({ disableAllHooks: true });
136
+
137
+ /**
138
+ * The pins of CLAUDE_CLI_PINNED_ENV for a CLI spawned with `cliEnv`: the
139
+ * value it was spawned with, else the other case's (a proxy the worker sets
140
+ * as https_proxy holds for HTTPS_PROXY as well), else ''.
141
+ */
142
+ export function claudeCliPinnedEnv(cliEnv = {}) {
143
+ const pins = {};
144
+ for (const name of CLAUDE_CLI_PINNED_ENV) {
145
+ const own = cliEnv[name] ?? cliEnv[OTHER_CASE.get(name)];
146
+ if (own !== undefined) pins[name] = String(own);
147
+ else if (!PINNED_ONLY_WHEN_SET.has(name)) pins[name] = '';
148
+ }
149
+ return pins;
150
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The relay's `remote_relay` tool for the Claude worker, shaped as an SDK MCP
2
+ * The relay's `relay_tool` tool for the Claude worker, shaped as an SDK MCP
3
3
  * tool definition (`{ name, description, inputSchema, handler }`) so the
4
4
  * adapter can hand it to `createSdkMcpServer` next to the preview tool. On the
5
- * wire it is `mcp__relay__remote_relay`; the module never imports the SDK.
5
+ * wire it is `mcp__relay__relay_tool`; the module never imports the SDK.
6
6
  */
7
7
 
8
8
  import * as z from 'zod';
@@ -140,7 +140,7 @@ export function createRemoteRelayToolDefinition({ api, getConversationId = () =>
140
140
  // A tool exception would surface as a failed MCP call and can abort
141
141
  // the turn; a refusal the model can read cannot.
142
142
  const message = error?.message || String(error);
143
- dbg('remote_relay tool failed', message);
143
+ dbg('relay_tool failed', message);
144
144
  result = { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message };
145
145
  }
146
146
  return { content: [{ type: 'text', text: formatRemoteRelayToolResult(result) }] };
@@ -11,7 +11,7 @@ import { CLAUDE_LIVE_FLAG_SETTINGS_CODE, CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE } fr
11
11
  export { CLAUDE_LIVE_FLAG_SETTINGS_CODE };
12
12
 
13
13
  // The name the in-process MCP server is registered under; it prefixes every
14
- // tool it carries on the wire (`mcp__relay__preview`, `mcp__relay__remote_relay`).
14
+ // tool it carries on the wire (`mcp__relay__preview`, `mcp__relay__relay_tool`).
15
15
  export const RELAY_MCP_SERVER_NAME = 'relay';
16
16
 
17
17
  // The per-server tool-call timeout the CLI applies to the relay's tools.
@@ -19,7 +19,7 @@ export const RELAY_MCP_SERVER_NAME = 'relay';
19
19
  // value, else by the MCP_TOOL_TIMEOUT environment variable, else by 1e8 ms —
20
20
  // and SDK servers are exempt from the idle timeout. Pinning it here keeps a
21
21
  // host-wide MCP_TOOL_TIMEOUT (set for some other server) from cutting off a
22
- // remote_relay wait: those run up to wait_seconds (≤ 600 s) plus however long
22
+ // relay_tool wait: those run up to wait_seconds (≤ 600 s) plus however long
23
23
  // an approval card sits unanswered.
24
24
  export const RELAY_MCP_TOOL_TIMEOUT_MS = REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS;
25
25
 
@@ -122,6 +122,23 @@ export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = nul
122
122
  return Object.keys(settings).length ? settings : null;
123
123
  }
124
124
 
125
+ /**
126
+ * The spawn's `settings` with a backend's flag settings on top: null when
127
+ * neither has anything, the relay's object untouched when the backend has
128
+ * nothing to add. `env` is merged by name, so neither side drops the other's
129
+ * variables.
130
+ */
131
+ export function mergeSpawnSettings(spawnSettings, backendSettings) {
132
+ const own = spawnSettings && typeof spawnSettings === 'object' ? spawnSettings : null;
133
+ const extra = backendSettings && typeof backendSettings === 'object' && Object.keys(backendSettings).length
134
+ ? backendSettings
135
+ : null;
136
+ if (!extra) return own;
137
+ const merged = { ...(own || {}), ...extra };
138
+ if (own?.env || extra.env) merged.env = { ...(own?.env || {}), ...(extra.env || {}) };
139
+ return merged;
140
+ }
141
+
125
142
  /**
126
143
  * Live twin of the attribution spawn setting, for a value (OAR or off). A
127
144
  * running CLI takes a new text through the flag layer (it arrives as a fresh
@@ -209,7 +226,7 @@ export function systemPromptForRelayMode(relayMode, { backgroundCommandLimitMs =
209
226
  * payload. `getConversationId` is called per tool call because a process
210
227
  * outlives the turn it was spawned for.
211
228
  *
212
- * `remote_relay` is the exception: it rides along only when the worker found
229
+ * `relay_tool` is the exception: it rides along only when the worker found
213
230
  * at least one paired remote (`remoteRelayTool`), because a tool that can do
214
231
  * nothing on a relay without remotes would only cost context on every turn.
215
232
  */
@@ -375,7 +392,7 @@ export function startClaudeSession({
375
392
  pathToClaudeCodeExecutable = '',
376
393
  api = null,
377
394
  getConversationId = () => '',
378
- // Whether this process carries `remote_relay` (the worker's registration
395
+ // Whether this process carries `relay_tool` (the worker's registration
379
396
  // decision). Fixed for the process: the tool list is part of the spawn.
380
397
  remoteRelayTool = false,
381
398
  // Another endpoint for the CLI (null = Anthropic through the host's Claude
@@ -387,15 +404,24 @@ export function startClaudeSession({
387
404
  dbg = () => {},
388
405
  } = {}) {
389
406
  const normalizedModel = String(model || '').trim().toLowerCase();
390
- const effort = backend ? '' : normalizeClaudeEffort(reasoningEffort);
407
+ // A backend on Claude's own models (the Claude API backend) takes the
408
+ // model and the effort exactly as Claude does; only another provider's
409
+ // backend maps them into its terms.
410
+ const modelBackend = backend && backend.claudeModels !== true ? backend : null;
411
+ const effort = modelBackend ? '' : normalizeClaudeEffort(reasoningEffort);
391
412
  const ultracode = effort === CLAUDE_ULTRACODE_EFFORT;
392
- const effortOption = backend ? backend.effortOption(reasoningEffort) : (ultracode ? 'xhigh' : effort);
393
- const cliModel = backend ? backend.cliModel(model) : String(model || '').trim();
413
+ const effortOption = modelBackend ? modelBackend.effortOption(reasoningEffort) : (ultracode ? 'xhigh' : effort);
414
+ const cliModel = modelBackend ? modelBackend.cliModel(model) : String(model || '').trim();
394
415
  // Spawn-time twin of the flag-settings helpers: the settings layer is the
395
416
  // only way to hand a fresh CLI the session-scoped ultracode flag, the
396
417
  // auto-compact window and the thinking pin, and all three share one
397
- // `settings` object.
398
- const spawnSettings = claudeSpawnSettings({ ultracode, autoCompactWindow, thinkingEnabled, attribution });
418
+ // `settings` object. A backend's own pins (no hooks for both, the Claude
419
+ // API backend's endpoint and blanked credentials) join them; theirs win a
420
+ // clash.
421
+ const spawnSettings = mergeSpawnSettings(
422
+ claudeSpawnSettings({ ultracode, autoCompactWindow, thinkingEnabled, attribution }),
423
+ typeof backend?.flagSettings === 'function' ? backend.flagSettings() : null,
424
+ );
399
425
  // Relay tools need the worker's authenticated API helper; a caller that
400
426
  // passes none (tests, probes) gets a session without them.
401
427
  const relayMcpServer = typeof api === 'function'