@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,575 @@
1
+ 'use strict';
2
+
3
+ // Settings of the Claude API provider (Settings → Providers → Claude API):
4
+ // Claude chats that run on an API key from the Claude Console, billed to the
5
+ // Console organisation, beside the Claude provider that runs on the host's
6
+ // subscription login.
7
+ //
8
+ // One key per relay, stored like the DeepSeek and Cursor keys in the settings
9
+ // table and never sent to a browser. Next to it: the switch, the default
10
+ // model, the discovered models and the ones enabled for the composer, and an
11
+ // optional monthly spend cap. Commit attribution, the background command
12
+ // limit, the effort levels and the compaction window are the Claude tab's, so
13
+ // they are not stored here.
14
+ //
15
+ // Discovery asks Anthropic's `GET /v1/models` with the key
16
+ // (shared/anthropic-api.mjs). Until it has run, the Claude provider's model
17
+ // list stands in; a failed run keeps what was stored.
18
+
19
+ import { createAnthropicApiClient } from '../../shared/anthropic-api.mjs';
20
+ import { LONG_CONTEXT_TOKENS, resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
21
+ import {
22
+ CLAUDE_DEFAULT_CONTEXT_LIMIT_TOKENS,
23
+ claudeBaseModelId,
24
+ claudeLongContextModelId,
25
+ isClaudeLongContextModelId,
26
+ isSafeClaudeModelId,
27
+ } from '../../shared/model-id.mjs';
28
+ import { DEFAULT_CLAUDE_REASONING_EFFORTS, withClaudeUltracodeTier } from './provider-reasoning-effort.mjs';
29
+
30
+ export const CLAUDE_API_PROVIDER_TYPE = 'claude-api';
31
+ export const CLAUDE_API_PROVIDER_LABEL = 'Claude API';
32
+
33
+ export const CLAUDE_API_KEY_SETTING_KEY = 'claude_api_key';
34
+ export const CLAUDE_API_ENABLED_SETTING_KEY = 'claude_api_enabled';
35
+ export const CLAUDE_API_MODEL_SETTING_KEY = 'claude_api_model';
36
+ export const CLAUDE_API_MODELS_SETTING_KEY = 'claude_api_models';
37
+ export const CLAUDE_API_ENABLED_MODELS_SETTING_KEY = 'claude_api_enabled_models';
38
+ export const CLAUDE_API_MONTHLY_CAP_SETTING_KEY = 'claude_api_monthly_cap_usd';
39
+ // The derived default a new key replaced, kept until that key's models are
40
+ // listed: a refused key must not move the default to another model.
41
+ export const CLAUDE_API_CARRIED_MODEL_SETTING_KEY = 'claude_api_carried_model';
42
+
43
+ // The highest monthly cap the field takes: far above any sane spend, low
44
+ // enough that a typo of many digits is refused rather than stored.
45
+ export const CLAUDE_API_MONTHLY_CAP_MAX_USD = 1_000_000;
46
+
47
+ // Anthropic's credentials all start with `sk-ant-`. The field takes any
48
+ // `sk-ant-` value except the kinds below. It does not require `sk-ant-api`:
49
+ // keys from platform.claude.com can start otherwise (personal keys start with
50
+ // `sk-ant-usr-`). Whether a key works is for Anthropic to say, at the model
51
+ // discovery that runs right after the save.
52
+ const ANTHROPIC_KEY_PREFIX = 'sk-ant-';
53
+ const CONSOLE_KEY_HINT = 'Claude API needs an API key from the Claude Console (platform.claude.com → API keys); Claude login tokens and Admin keys are refused.';
54
+
55
+ // Credentials that look like keys but are not API keys. Storing claude.ai
56
+ // login or session tokens is not allowed (Anthropic's Agent SDK terms), and an
57
+ // Admin key manages an organisation instead of calling models. The prefixes
58
+ // are the ones the Claude Code 2.1.304 binary itself tells apart: its secret
59
+ // scanner (`sk-ant-(?:oat|ort)\d{2}-`, `sk-ant-admin01-`), its OAuth check
60
+ // (`startsWith("sk-ant-oat")`), its claude.ai session handling (`sk-ant-sid`,
61
+ // `sk-ant-si-`), its session-ingress tokens (`claude self-hosted-runner
62
+ // decode-token` strips "any sk-ant-cc- / sk-ant-si- prefix" from
63
+ // CLAUDE_CODE_SESSION_ACCESS_TOKEN) and its remote sessions' git credential
64
+ // (`sk-ant-ccsr-`, sent as the password of an https remote).
65
+ const REFUSED_KEY_KINDS = Object.freeze([
66
+ Object.freeze({ prefix: 'sk-ant-oat', kind: 'oauth-access-token', what: 'a Claude OAuth access token (it signs in a Claude subscription)' }),
67
+ Object.freeze({ prefix: 'sk-ant-ort', kind: 'oauth-refresh-token', what: 'a Claude OAuth refresh token (it signs in a Claude subscription)' }),
68
+ Object.freeze({ prefix: 'sk-ant-admin', kind: 'admin-key', what: 'an Anthropic Admin API key (it manages an organisation and cannot run models)' }),
69
+ Object.freeze({ prefix: 'sk-ant-sid', kind: 'session-token', what: 'a claude.ai session key' }),
70
+ Object.freeze({ prefix: 'sk-ant-si-', kind: 'session-token', what: 'a Claude session token' }),
71
+ Object.freeze({ prefix: 'sk-ant-cc-', kind: 'session-access-token', what: 'a Claude Code session access token' }),
72
+ Object.freeze({ prefix: 'sk-ant-ccsr-', kind: 'remote-session-credential', what: 'a Claude Code remote session credential' }),
73
+ ]);
74
+
75
+ function hasOwn(object, key) {
76
+ return Object.prototype.hasOwnProperty.call(object, key);
77
+ }
78
+
79
+ function toText(value) {
80
+ return String(value ?? '').trim();
81
+ }
82
+
83
+ function lower(value) {
84
+ return toText(value).toLowerCase();
85
+ }
86
+
87
+ /**
88
+ * Which kind of credential a pasted value is, by its prefix alone:
89
+ * `{ ok: true }` for a key the field takes, else `{ ok: false, kind, error }`.
90
+ * The error names the kind and never quotes the value.
91
+ */
92
+ export function classifyClaudeApiKey(value) {
93
+ const key = lower(value);
94
+ const refused = REFUSED_KEY_KINDS.find((entry) => key.startsWith(entry.prefix));
95
+ if (refused) {
96
+ return { ok: false, kind: refused.kind, error: `This is ${refused.what}, not an API key. ${CONSOLE_KEY_HINT}` };
97
+ }
98
+ // Another vendor's key (OpenAI's `sk-proj-`, DeepSeek's `sk-`) or a typo:
99
+ // refused here rather than failing later at discovery or the first turn.
100
+ if (!key.startsWith(ANTHROPIC_KEY_PREFIX)) {
101
+ return { ok: false, kind: 'not-console-key', error: `This does not look like a Claude API key. ${CONSOLE_KEY_HINT}` };
102
+ }
103
+ return { ok: true, kind: 'api-key' };
104
+ }
105
+
106
+ /**
107
+ * The monthly cap from a request: null (or '') means no cap, else a positive
108
+ * number of US dollars with at most two decimals, up to
109
+ * CLAUDE_API_MONTHLY_CAP_MAX_USD. A number or its text both work.
110
+ */
111
+ export function parseClaudeApiMonthlyCap(value) {
112
+ if (value === null || value === undefined) return { ok: true, capUsd: null };
113
+ if (typeof value !== 'number' && typeof value !== 'string') {
114
+ return { ok: false, error: 'monthlyCapUsd must be a number of US dollars, or empty for no cap' };
115
+ }
116
+ const text = toText(value);
117
+ if (!text) return { ok: true, capUsd: null };
118
+ if (!/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(text)) {
119
+ return { ok: false, error: 'monthlyCapUsd must be a positive amount of US dollars with at most two decimals' };
120
+ }
121
+ const capUsd = Number(text);
122
+ if (!Number.isFinite(capUsd) || capUsd <= 0) {
123
+ return { ok: false, error: 'monthlyCapUsd must be more than 0, or empty for no cap' };
124
+ }
125
+ if (capUsd > CLAUDE_API_MONTHLY_CAP_MAX_USD) {
126
+ return { ok: false, error: `monthlyCapUsd must be at most ${CLAUDE_API_MONTHLY_CAP_MAX_USD} US dollars` };
127
+ }
128
+ return { ok: true, capUsd };
129
+ }
130
+
131
+ /** A Claude model id as the Claude provider takes it: safe, `claude-*`, `[1m]` allowed. */
132
+ function isUsableModelId(value) {
133
+ const id = toText(value);
134
+ return !!id && isSafeClaudeModelId(id) && /^claude-/i.test(id);
135
+ }
136
+
137
+ function uniqueModels(values) {
138
+ const out = [];
139
+ const seen = new Set();
140
+ for (const value of values) {
141
+ const model = toText(value);
142
+ const key = model.toLowerCase();
143
+ if (!model || seen.has(key) || !isUsableModelId(model)) continue;
144
+ seen.add(key);
145
+ out.push(model);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ function positiveIntegerOrNull(value) {
151
+ const number = Number(value);
152
+ return Number.isFinite(number) && number > 0 ? Math.trunc(number) : null;
153
+ }
154
+
155
+ function booleanOrNull(value) {
156
+ return typeof value === 'boolean' ? value : null;
157
+ }
158
+
159
+ /**
160
+ * A discovered model as stored (anthropic-api.mjs mapAnthropicModel), or a
161
+ * bare id from an older list; null for anything unusable. `[1m]` ids are
162
+ * never stored: they are derived on reading.
163
+ */
164
+ function normalizeStoredModel(entry) {
165
+ const raw = typeof entry === 'string' ? { id: entry } : (entry && typeof entry === 'object' ? entry : null);
166
+ const id = toText(raw?.id);
167
+ if (!isUsableModelId(id) || isClaudeLongContextModelId(id)) return null;
168
+ return {
169
+ id,
170
+ displayName: toText(raw.displayName) || id,
171
+ contextWindow: positiveIntegerOrNull(raw.contextWindow),
172
+ maxOutput: positiveIntegerOrNull(raw.maxOutput),
173
+ imageInput: booleanOrNull(raw.imageInput),
174
+ effort: booleanOrNull(raw.effort),
175
+ effortLevels: (Array.isArray(raw.effortLevels) ? raw.effortLevels : [])
176
+ .map(lower)
177
+ .filter((level) => ['low', 'medium', 'high', 'xhigh', 'max'].includes(level)),
178
+ thinking: booleanOrNull(raw.thinking),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Reads `{ apiKey?, enabled?, model?, enabledModels?, monthlyCapUsd?,
184
+ * remove? }` from a request body. The key is never echoed in an error, and a
185
+ * Claude login token or an Admin key is refused by its prefix.
186
+ */
187
+ export function parseClaudeApiSettingsUpdateRequest(body = {}) {
188
+ const payload = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
189
+ if (payload.remove === true) return { ok: true, remove: true, enabled: false };
190
+ const update = { remove: false };
191
+ if (hasOwn(payload, 'apiKey')) {
192
+ if (typeof payload.apiKey !== 'string') return { ok: false, error: 'apiKey must be a string' };
193
+ const apiKey = payload.apiKey.trim();
194
+ if (apiKey && !/^[\x21-\x7e]{8,512}$/.test(apiKey)) return { ok: false, error: 'The Claude API key has an invalid format' };
195
+ if (apiKey) {
196
+ const kind = classifyClaudeApiKey(apiKey);
197
+ if (!kind.ok) return { ok: false, code: `claude-api-key-${kind.kind}`, error: kind.error };
198
+ update.apiKey = apiKey;
199
+ }
200
+ }
201
+ if (hasOwn(payload, 'enabled')) {
202
+ if (typeof payload.enabled !== 'boolean') return { ok: false, error: 'enabled must be true or false' };
203
+ update.enabled = payload.enabled;
204
+ }
205
+ if (hasOwn(payload, 'model')) {
206
+ // Empty: back to the derived default (the Claude tab's, else a Sonnet).
207
+ const model = payload.model === null ? '' : toText(payload.model);
208
+ if (model && !isUsableModelId(model)) return { ok: false, error: 'Invalid Claude API model ID' };
209
+ update.model = model;
210
+ }
211
+ if (hasOwn(payload, 'enabledModels')) {
212
+ if (!Array.isArray(payload.enabledModels)) return { ok: false, error: 'enabledModels must be a list' };
213
+ update.enabledModels = uniqueModels(payload.enabledModels);
214
+ }
215
+ if (hasOwn(payload, 'monthlyCapUsd')) {
216
+ const cap = parseClaudeApiMonthlyCap(payload.monthlyCapUsd);
217
+ if (!cap.ok) return { ok: false, error: cap.error };
218
+ update.monthlyCapUsd = cap.capUsd;
219
+ }
220
+ if (Object.keys(update).length === 1) return { ok: false, error: 'No Claude API settings update provided' };
221
+ if (update.apiKey && !hasOwn(update, 'enabled')) update.enabled = true;
222
+ return { ok: true, ...update };
223
+ }
224
+
225
+ export function createClaudeApiSettingsService({
226
+ readSetting = () => '',
227
+ writeSetting = null,
228
+ deleteSetting = null,
229
+ // Runtime sessions with their message and queue counts
230
+ // (stmts.listRuntimeSessionProviderCandidates); guards the key removal.
231
+ listProviderCandidates = () => [],
232
+ // Live workers that run on the key (the worker registry's kind): a chat
233
+ // switched back to the subscription may still have one finishing a turn.
234
+ countLiveKeyWorkers = () => 0,
235
+ // The Claude provider's settings (server-runtime getClaudeProviderSettings):
236
+ // its model list stands in before the first discovery, its default model is
237
+ // preferred, and its effort levels are this provider's.
238
+ getClaudeProviderSettings = () => null,
239
+ fetchImpl = globalThis.fetch,
240
+ // Where OAR_ANTHROPIC_API_BASE_URL is read (a loopback fake API in tests).
241
+ env = process.env,
242
+ now = () => new Date(),
243
+ } = {}) {
244
+ function readClaudeSettings() {
245
+ try {
246
+ const settings = getClaudeProviderSettings();
247
+ return settings && typeof settings === 'object' ? settings : {};
248
+ } catch {
249
+ return {};
250
+ }
251
+ }
252
+
253
+ function readJsonList(settingKey) {
254
+ try {
255
+ const parsed = JSON.parse(readSetting(settingKey) || '[]');
256
+ return Array.isArray(parsed) ? parsed : [];
257
+ } catch {
258
+ return [];
259
+ }
260
+ }
261
+
262
+ function readDiscovered() {
263
+ const out = [];
264
+ const seen = new Set();
265
+ for (const entry of readJsonList(CLAUDE_API_MODELS_SETTING_KEY)) {
266
+ const model = normalizeStoredModel(entry);
267
+ if (!model || seen.has(model.id.toLowerCase())) continue;
268
+ seen.add(model.id.toLowerCase());
269
+ out.push(model);
270
+ }
271
+ return out;
272
+ }
273
+
274
+ /**
275
+ * The ids on offer: the discovered models, each followed by its `[1m]`
276
+ * variant when it has a 1M window or the Claude tab lists that variant;
277
+ * before any discovery, the Claude tab's list as it is.
278
+ */
279
+ function offeredModels(discovered, claudeSettings) {
280
+ const claudeList = uniqueModels(Array.isArray(claudeSettings?.availableModels) ? claudeSettings.availableModels : []);
281
+ if (!discovered.length) return claudeList;
282
+ const claudeKeys = new Set(claudeList.map((id) => id.toLowerCase()));
283
+ const ids = [];
284
+ for (const model of discovered) {
285
+ ids.push(model.id);
286
+ const longId = claudeLongContextModelId(model.id);
287
+ if ((model.contextWindow || 0) >= LONG_CONTEXT_TOKENS || claudeKeys.has(longId.toLowerCase())) ids.push(longId);
288
+ }
289
+ return uniqueModels(ids);
290
+ }
291
+
292
+ /**
293
+ * The default carried over a key change when it is still offered, else the
294
+ * Claude tab's default model when it is offered, else the first Sonnet,
295
+ * else the first.
296
+ */
297
+ function defaultModelFor(offered, claudeSettings, carried = '') {
298
+ const fromCarried = carried ? offered.find((id) => id.toLowerCase() === lower(carried)) : '';
299
+ if (fromCarried) return fromCarried;
300
+ const claudeDefault = lower(claudeSettings?.model);
301
+ const fromClaude = claudeDefault ? offered.find((id) => id.toLowerCase() === claudeDefault) : '';
302
+ if (fromClaude) return fromClaude;
303
+ const bases = offered.filter((id) => !isClaudeLongContextModelId(id));
304
+ return bases.find((id) => /sonnet/i.test(id)) || bases[0] || offered[0] || toText(claudeSettings?.model);
305
+ }
306
+
307
+ function readMonthlyCap() {
308
+ const parsed = parseClaudeApiMonthlyCap(readSetting(CLAUDE_API_MONTHLY_CAP_SETTING_KEY));
309
+ return parsed.ok ? parsed.capUsd : null;
310
+ }
311
+
312
+ /** Everything stored, the key included: for the relay, never the browser. */
313
+ function getSettings() {
314
+ const apiKey = toText(readSetting(CLAUDE_API_KEY_SETTING_KEY));
315
+ const enabledSetting = toText(readSetting(CLAUDE_API_ENABLED_SETTING_KEY));
316
+ const claudeSettings = readClaudeSettings();
317
+ const discovered = readDiscovered();
318
+ const offered = offeredModels(discovered, claudeSettings);
319
+ const stored = toText(readSetting(CLAUDE_API_MODEL_SETTING_KEY));
320
+ const carried = toText(readSetting(CLAUDE_API_CARRIED_MODEL_SETTING_KEY));
321
+ const model = stored && isUsableModelId(stored) ? stored : defaultModelFor(offered, claudeSettings, carried);
322
+ const availableModels = uniqueModels([model, ...offered]);
323
+ const availableKeys = new Set(availableModels.map((id) => id.toLowerCase()));
324
+ const storedEnabled = uniqueModels(readJsonList(CLAUDE_API_ENABLED_MODELS_SETTING_KEY))
325
+ .filter((entry) => availableKeys.has(entry.toLowerCase()));
326
+ // The default model always stays enabled; with no choice stored, all are.
327
+ const models = storedEnabled.length ? uniqueModels([model, ...storedEnabled]) : availableModels;
328
+
329
+ const detailsByBase = new Map(discovered.map((entry) => [entry.id.toLowerCase(), entry]));
330
+ const claudeEfforts = claudeSettings?.effortsByModel && typeof claudeSettings.effortsByModel === 'object'
331
+ ? claudeSettings.effortsByModel
332
+ : {};
333
+ const effortsByModel = {};
334
+ const contextWindowsByModel = {};
335
+ for (const id of availableModels) {
336
+ const key = id.toLowerCase();
337
+ const baseKey = claudeBaseModelId(id).toLowerCase();
338
+ const detail = detailsByBase.get(baseKey) || null;
339
+ // Anthropic's answer first (a model that takes no effort has only the
340
+ // SDK default), then the Claude tab's levels for the same model, then
341
+ // the full Claude ladder, as the Claude provider offers it.
342
+ if (detail?.effort === false) {
343
+ effortsByModel[key] = ['none'];
344
+ } else if (detail?.effortLevels?.length) {
345
+ effortsByModel[key] = withClaudeUltracodeTier(['none', ...detail.effortLevels]);
346
+ } else {
347
+ const fromClaude = claudeEfforts[key] || claudeEfforts[baseKey];
348
+ effortsByModel[key] = Array.isArray(fromClaude) && fromClaude.length
349
+ ? withClaudeUltracodeTier(fromClaude)
350
+ : [...DEFAULT_CLAUDE_REASONING_EFFORTS];
351
+ }
352
+ // A `[1m]` id runs with the 1M window; its base runs with Claude Code's
353
+ // default window, as on the Claude tab.
354
+ if (isClaudeLongContextModelId(id)) {
355
+ contextWindowsByModel[key] = LONG_CONTEXT_TOKENS;
356
+ } else if (detail?.contextWindow) {
357
+ contextWindowsByModel[key] = availableKeys.has(claudeLongContextModelId(id).toLowerCase())
358
+ ? Math.min(detail.contextWindow, CLAUDE_DEFAULT_CONTEXT_LIMIT_TOKENS)
359
+ : detail.contextWindow;
360
+ } else {
361
+ contextWindowsByModel[key] = resolveFallbackContextLimitTokens(id);
362
+ }
363
+ }
364
+ const modelDetails = {};
365
+ for (const entry of discovered) {
366
+ modelDetails[entry.id.toLowerCase()] = {
367
+ id: entry.id,
368
+ displayName: entry.displayName,
369
+ contextWindow: entry.contextWindow,
370
+ maxOutput: entry.maxOutput,
371
+ imageInput: entry.imageInput,
372
+ effort: entry.effort,
373
+ thinking: entry.thinking,
374
+ };
375
+ }
376
+ return {
377
+ configured: !!apiKey,
378
+ enabled: !!apiKey && (enabledSetting === '' || enabledSetting === 'true'),
379
+ apiKey,
380
+ model,
381
+ models,
382
+ availableModels,
383
+ enabledModels: models,
384
+ discovered: discovered.length > 0,
385
+ effortsByModel,
386
+ contextWindowsByModel,
387
+ modelDetails,
388
+ monthlyCapUsd: readMonthlyCap(),
389
+ providerType: CLAUDE_API_PROVIDER_TYPE,
390
+ label: CLAUDE_API_PROVIDER_LABEL,
391
+ };
392
+ }
393
+
394
+ /** What the settings tab and the socket get: the key only as "configured". */
395
+ function describe() {
396
+ const { apiKey: _apiKey, ...rest } = getSettings();
397
+ return rest;
398
+ }
399
+
400
+ /** The context window of a model this provider offers, or null. */
401
+ function contextWindowFor(model) {
402
+ const key = lower(model);
403
+ if (!key) return null;
404
+ const known = getSettings().contextWindowsByModel[key];
405
+ if (known) return known;
406
+ return isClaudeLongContextModelId(key) ? LONG_CONTEXT_TOKENS : resolveFallbackContextLimitTokens(key);
407
+ }
408
+
409
+ function countConversations() {
410
+ const rows = (() => {
411
+ try {
412
+ const listed = listProviderCandidates();
413
+ return Array.isArray(listed) ? listed : [];
414
+ } catch {
415
+ return [];
416
+ }
417
+ })();
418
+ // A chat on the key, and a chat whose unfinished rows still run on it (a
419
+ // switch back to the subscription while one ran).
420
+ const bound = rows.filter((row) => lower(row?.provider_type) === CLAUDE_API_PROVIDER_TYPE);
421
+ const stamped = rows.filter((row) => lower(row?.provider_type) !== CLAUDE_API_PROVIDER_TYPE
422
+ && Number(row?.claude_api_queue_count || 0) > 0);
423
+ const started = bound.filter((row) => Number(row?.message_count || 0) > 0).length;
424
+ const queued = bound.filter((row) => Number(row?.active_queue_count || 0) > 0).length + stamped.length;
425
+ let liveWorkers = 0;
426
+ try { liveWorkers = Math.max(0, Number(countLiveKeyWorkers()) || 0); } catch { liveWorkers = 0; }
427
+ const active = bound.filter((row) => Number(row?.message_count || 0) > 0 || Number(row?.active_queue_count || 0) > 0).length
428
+ + stamped.length;
429
+ return { active, started, queued, liveWorkers };
430
+ }
431
+
432
+ /**
433
+ * Applies a parsed update. Returns `{ ok, keyChanged, discover, disabled,
434
+ * ... }`: `keyChanged` tells the caller to stop the live workers (they hold
435
+ * the old key), `discover` that model discovery should run, `disabled`
436
+ * that the provider went off (unstarted chats move back to the default).
437
+ */
438
+ function update(parsed = {}) {
439
+ if (typeof writeSetting !== 'function' || typeof deleteSetting !== 'function') {
440
+ return { ok: false, error: 'Claude API settings are unavailable' };
441
+ }
442
+ const previous = getSettings();
443
+ const stamp = now().toISOString();
444
+ if (parsed.remove === true) {
445
+ const counts = countConversations();
446
+ if (counts.active === 0 && counts.liveWorkers > 0) {
447
+ return {
448
+ ok: false,
449
+ code: 'claude-api-key-removal-blocked',
450
+ error: `Cannot remove the Claude API key while ${counts.liveWorkers} worker(s) still run on it. Try again once their turns have finished.`,
451
+ activeConversationCount: 0,
452
+ startedConversationCount: 0,
453
+ activeQueueConversationCount: 0,
454
+ };
455
+ }
456
+ if (counts.active > 0) {
457
+ return {
458
+ ok: false,
459
+ code: 'claude-api-key-removal-blocked',
460
+ error: `Cannot remove the Claude API key while ${counts.active} Claude API conversation(s) still exist. Switch Claude API off for new conversations instead.`,
461
+ activeConversationCount: counts.active,
462
+ startedConversationCount: counts.started,
463
+ activeQueueConversationCount: counts.queued,
464
+ };
465
+ }
466
+ deleteSetting(CLAUDE_API_KEY_SETTING_KEY);
467
+ deleteSetting(CLAUDE_API_MODELS_SETTING_KEY);
468
+ deleteSetting(CLAUDE_API_ENABLED_MODELS_SETTING_KEY);
469
+ deleteSetting(CLAUDE_API_CARRIED_MODEL_SETTING_KEY);
470
+ writeSetting(CLAUDE_API_ENABLED_SETTING_KEY, 'false', stamp);
471
+ return {
472
+ ok: true,
473
+ removed: true,
474
+ keyChanged: previous.configured,
475
+ discover: false,
476
+ disabled: previous.enabled,
477
+ settings: describe(),
478
+ };
479
+ }
480
+ const apiKey = toText(parsed.apiKey);
481
+ const keyChanged = !!apiKey && apiKey !== previous.apiKey;
482
+ const hasKey = !!(apiKey || previous.apiKey);
483
+ const nextEnabled = typeof parsed.enabled === 'boolean' ? parsed.enabled : previous.enabled;
484
+ if (nextEnabled && !hasKey) return { ok: false, error: 'Claude API key is not configured' };
485
+ if (keyChanged) {
486
+ writeSetting(CLAUDE_API_KEY_SETTING_KEY, apiKey, stamp);
487
+ // Another key may belong to another organisation with other models.
488
+ deleteSetting(CLAUDE_API_MODELS_SETTING_KEY);
489
+ // A default nobody picked was derived from the old key's list; until
490
+ // the new key's list is read, the fallback list keeps it if it can.
491
+ const stored = toText(readSetting(CLAUDE_API_MODEL_SETTING_KEY));
492
+ if (previous.configured && !stored && !parsed.model && previous.model) {
493
+ writeSetting(CLAUDE_API_CARRIED_MODEL_SETTING_KEY, previous.model, stamp);
494
+ }
495
+ }
496
+ if (hasOwn(parsed, 'enabled') || keyChanged) writeSetting(CLAUDE_API_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', stamp);
497
+ if (hasOwn(parsed, 'model')) {
498
+ if (parsed.model) writeSetting(CLAUDE_API_MODEL_SETTING_KEY, parsed.model, stamp);
499
+ else deleteSetting(CLAUDE_API_MODEL_SETTING_KEY);
500
+ }
501
+ if (hasOwn(parsed, 'enabledModels')) {
502
+ if (parsed.enabledModels.length) {
503
+ writeSetting(CLAUDE_API_ENABLED_MODELS_SETTING_KEY, JSON.stringify(parsed.enabledModels), stamp);
504
+ } else {
505
+ deleteSetting(CLAUDE_API_ENABLED_MODELS_SETTING_KEY);
506
+ }
507
+ }
508
+ if (hasOwn(parsed, 'monthlyCapUsd')) {
509
+ if (parsed.monthlyCapUsd === null) deleteSetting(CLAUDE_API_MONTHLY_CAP_SETTING_KEY);
510
+ else writeSetting(CLAUDE_API_MONTHLY_CAP_SETTING_KEY, String(parsed.monthlyCapUsd), stamp);
511
+ }
512
+ const current = getSettings();
513
+ const discover = current.enabled && (keyChanged || !previous.enabled || !current.discovered);
514
+ return {
515
+ ok: true,
516
+ removed: false,
517
+ previousModel: previous.model,
518
+ keyChanged: keyChanged && previous.configured,
519
+ discover,
520
+ disabled: previous.enabled && !current.enabled,
521
+ settings: describe(),
522
+ };
523
+ }
524
+
525
+ /**
526
+ * Asks Anthropic which models the key may use and stores the Claude ones
527
+ * with what the answer says about them. A failure keeps what was stored, so
528
+ * the pickers never empty out. An answer for a key that was changed or
529
+ * removed while the request ran is dropped: that key's models are not the
530
+ * new key's.
531
+ */
532
+ async function refreshModels() {
533
+ const settings = getSettings();
534
+ if (!settings.configured) return { ok: false, models: settings.availableModels, error: 'Claude API key is not configured' };
535
+ const redact = (text) => String(text ?? '').split(settings.apiKey).join('[redacted]');
536
+ try {
537
+ const client = createAnthropicApiClient({ apiKey: settings.apiKey, env, fetchImpl });
538
+ const listed = await client.listModels();
539
+ const current = getSettings();
540
+ if (current.apiKey !== settings.apiKey) {
541
+ return {
542
+ ok: false,
543
+ stale: true,
544
+ models: current.availableModels,
545
+ error: 'The Claude API key changed while its models were being listed; that list was dropped',
546
+ code: 'claude-api-key-changed',
547
+ };
548
+ }
549
+ const models = [];
550
+ const seen = new Set();
551
+ for (const entry of Array.isArray(listed) ? listed : []) {
552
+ const model = normalizeStoredModel(entry);
553
+ if (!model || seen.has(model.id.toLowerCase())) continue;
554
+ seen.add(model.id.toLowerCase());
555
+ models.push(model);
556
+ }
557
+ if (!models.length) {
558
+ return { ok: false, models: settings.availableModels, error: 'Anthropic listed no Claude models for this key' };
559
+ }
560
+ writeSetting?.(CLAUDE_API_MODELS_SETTING_KEY, JSON.stringify(models), now().toISOString());
561
+ // The key's own list now decides the default.
562
+ deleteSetting?.(CLAUDE_API_CARRIED_MODEL_SETTING_KEY);
563
+ return { ok: true, models: getSettings().availableModels, error: null };
564
+ } catch (error) {
565
+ return {
566
+ ok: false,
567
+ models: settings.availableModels,
568
+ error: redact(error?.message || 'Claude API model discovery failed'),
569
+ code: error?.code || null,
570
+ };
571
+ }
572
+ }
573
+
574
+ return { getSettings, describe, update, refreshModels, contextWindowFor };
575
+ }