@oar-sh/oar 0.9.16 → 0.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +412 -0
- package/README.md +15 -13
- package/package.json +2 -1
- package/server/claude-api-worker/claude-api-backend.mjs +345 -0
- package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
- package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
- package/server/claude-worker/claude-session-process.mjs +276 -33
- package/server/claude-worker/claude-session-worker-main.mjs +269 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +394 -13
- package/server/claude-worker/claude-usage-totals.mjs +161 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
- package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
- package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
- package/server/db-schema.mjs +96 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/grok-worker/acp-client.mjs +1 -1
- package/server/grok-worker/grok-turn-runner.mjs +4 -4
- package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
- package/server/mcp/oar-mcp-server.mjs +11 -5
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/migrations/0031-queue-provider.mjs +143 -0
- package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
- package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
- package/server/public/app/activity-replay-state.mjs +4 -3
- package/server/public/app/agent-sessions-settings-ui.js +1 -1
- package/server/public/app/agent-sessions-settings.mjs +1 -1
- package/server/public/app/api-client.js +105 -0
- package/server/public/app/bootstrap.js +243 -29
- package/server/public/app/claude-api-settings-ui.js +362 -0
- package/server/public/app/claude-billing-toggle-ui.js +224 -0
- package/server/public/app/claude-billing-toggle.mjs +112 -0
- package/server/public/app/context-tier-options.mjs +10 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +18 -0
- package/server/public/app/conversation-view.js +31 -3
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +14 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +156 -11
- package/server/public/app/model-selector-options.mjs +28 -1
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/new-conversation-model-choice.mjs +21 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +8 -2
- package/server/public/app/relay-error-ctas.mjs +58 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +22 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +28 -1
- package/server/public/app/tool-activity-emoji.mjs +4 -3
- package/server/public/app/usage-limit-ui.js +136 -5
- package/server/public/index.html +596 -35
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +14 -7
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/session-repository.mjs +8 -1
- package/server/routes/ask-user-routes.mjs +1 -1
- package/server/routes/conversation-group-routes.mjs +3 -2
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +831 -36
- package/server/routes/relay-ask-routes.mjs +3 -3
- package/server/routes/relay-event-routes.mjs +2 -2
- package/server/routes/relay-schedule-routes.mjs +1 -1
- package/server/routes/remote-relay-routes.mjs +3 -3
- package/server/routes/sessions-routes.mjs +670 -63
- package/server/server-runtime.mjs +476 -26
- package/server/services/about-service.mjs +2 -0
- package/server/services/background-work-view.mjs +2 -1
- package/server/services/claude-api-settings-service.mjs +575 -0
- package/server/services/claude-api-usage-service.mjs +361 -0
- package/server/services/claude-billing-swap-service.mjs +274 -0
- package/server/services/claude-billing-switch-service.mjs +343 -0
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-claude-api.mjs +445 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +436 -0
- package/server/services/provider-model-selection.mjs +4 -2
- package/server/services/provider-reasoning-effort.mjs +24 -2
- package/server/services/relay-ask-service.mjs +1 -1
- package/server/services/relay-event-service.mjs +3 -2
- package/server/services/relay-schedule-service.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +7 -0
- package/server/services/remote-relay-dispatcher.mjs +84 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +294 -84
- package/server/services/session-worker-registry-service.mjs +28 -1
- package/server/services/session-worker-websocket-service.mjs +17 -0
- package/server/services/usage-limit-pause-service.mjs +55 -4
- package/server/services/worker-secret-redaction.mjs +320 -0
- package/shared/anthropic-api.mjs +202 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +5 -3
- package/shared/relay-asks.mjs +11 -5
- package/shared/relay-events.mjs +2 -2
- package/shared/relay-schedules.mjs +2 -2
- package/shared/remote-relay-contract.mjs +29 -9
- package/shared/remote-relay-tool-core.mjs +23 -6
- package/shared/steer-settle-failure.mjs +2 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// Settings → Providers → Claude API: Claude chats on an API key from the
|
|
2
|
+
// Claude Console. The key (written, never read back: the tab only says that
|
|
3
|
+
// one is saved), the switch, the default model and the monthly spend cap.
|
|
4
|
+
// The relay asks Anthropic for the key's models when a key is saved, when the
|
|
5
|
+
// provider is switched on again, and on Refresh models. Commit attribution,
|
|
6
|
+
// the background command limit, the effort levels and the compaction window
|
|
7
|
+
// are the Claude tab's: a link there says so.
|
|
8
|
+
|
|
9
|
+
import { loadClaudeApiSettings, refreshModelVariantCatalog, updateClaudeApiSettings } from './api-client.js';
|
|
10
|
+
import { selectSettingsTab } from './settings-tabs.js';
|
|
11
|
+
import { showTransientRelayNotice } from './store.js';
|
|
12
|
+
|
|
13
|
+
export const CLAUDE_API_BILLING_URL = 'https://platform.claude.com/settings/billing';
|
|
14
|
+
export const CLAUDE_API_LIMITS_URL = 'https://platform.claude.com/settings/limits';
|
|
15
|
+
export const CLAUDE_API_KEYS_URL = 'https://platform.claude.com/settings/keys';
|
|
16
|
+
|
|
17
|
+
let updateInFlight = false;
|
|
18
|
+
let inputsDirty = false;
|
|
19
|
+
// The default model was picked by hand since the tab was filled. Only such a
|
|
20
|
+
// pick, from the key's own list, is saved as the default: a save that sends
|
|
21
|
+
// whatever the select shows would pin a model the key may not have (before
|
|
22
|
+
// discovery the select shows the Claude tab's list).
|
|
23
|
+
let modelChosen = false;
|
|
24
|
+
let state = {
|
|
25
|
+
configured: false,
|
|
26
|
+
enabled: false,
|
|
27
|
+
model: '',
|
|
28
|
+
availableModels: [],
|
|
29
|
+
discovered: false,
|
|
30
|
+
monthlyCapUsd: null,
|
|
31
|
+
discoveryError: null,
|
|
32
|
+
};
|
|
33
|
+
const listeners = new Set();
|
|
34
|
+
|
|
35
|
+
const CONTROL_IDS = [
|
|
36
|
+
'claude-api-key-input',
|
|
37
|
+
'claude-api-model-select',
|
|
38
|
+
'claude-api-monthly-cap-input',
|
|
39
|
+
'claude-api-enabled-toggle',
|
|
40
|
+
'claude-api-save-btn',
|
|
41
|
+
'claude-api-remove-btn',
|
|
42
|
+
'claude-api-refresh-btn',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function element(id) {
|
|
46
|
+
return document.getElementById(id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureInputTracking() {
|
|
50
|
+
for (const id of ['claude-api-key-input', 'claude-api-model-select', 'claude-api-monthly-cap-input']) {
|
|
51
|
+
const input = element(id);
|
|
52
|
+
if (!input || input.dataset.claudeApiDirtyTracking === '1') continue;
|
|
53
|
+
input.dataset.claudeApiDirtyTracking = '1';
|
|
54
|
+
input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => {
|
|
55
|
+
inputsDirty = true;
|
|
56
|
+
if (id === 'claude-api-monthly-cap-input') showCapError('');
|
|
57
|
+
if (id === 'claude-api-model-select') modelChosen = true;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function setControlsDisabled(disabled) {
|
|
63
|
+
for (const id of CONTROL_IDS) {
|
|
64
|
+
const control = element(id);
|
|
65
|
+
if (control) control.disabled = disabled;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fillModelSelect(select) {
|
|
70
|
+
const models = Array.from(new Set([state.model, ...state.availableModels].filter(Boolean)));
|
|
71
|
+
select.innerHTML = '';
|
|
72
|
+
for (const model of models) {
|
|
73
|
+
const option = document.createElement('option');
|
|
74
|
+
option.value = model;
|
|
75
|
+
option.textContent = model;
|
|
76
|
+
select.appendChild(option);
|
|
77
|
+
}
|
|
78
|
+
select.value = state.model;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The cap as the field shows it: "25", "12.5", or '' for none. */
|
|
82
|
+
export function claudeApiCapInputText(capUsd) {
|
|
83
|
+
const value = Number(capUsd);
|
|
84
|
+
return Number.isFinite(value) && value > 0 ? String(value) : '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The cap field's text as the relay takes it: `{ ok: true, capUsd }` (null:
|
|
89
|
+
* no cap), else `{ ok: false, error }`. The relay checks it again.
|
|
90
|
+
*/
|
|
91
|
+
export function parseClaudeApiCapInput(text) {
|
|
92
|
+
const value = String(text ?? '').trim();
|
|
93
|
+
if (!value) return { ok: true, capUsd: null };
|
|
94
|
+
if (!/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(value) || !(Number(value) > 0)) {
|
|
95
|
+
return { ok: false, error: 'The monthly cap must be an amount of US dollars above 0 with at most two decimals, or empty for no cap.' };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, capUsd: Number(value) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Why the cap field's text is refused, under the field; '' clears it.
|
|
101
|
+
function showCapError(text) {
|
|
102
|
+
const message = element('claude-api-monthly-cap-error');
|
|
103
|
+
const input = element('claude-api-monthly-cap-input');
|
|
104
|
+
if (message) {
|
|
105
|
+
message.textContent = text;
|
|
106
|
+
message.hidden = !text;
|
|
107
|
+
}
|
|
108
|
+
if (input) {
|
|
109
|
+
if (text) input.setAttribute('aria-invalid', 'true');
|
|
110
|
+
else input.removeAttribute('aria-invalid');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The tab's status line: `{ text, state }`. */
|
|
115
|
+
export function claudeApiSettingsStatus(settings = {}) {
|
|
116
|
+
// Anthropic refused the key when it was saved or its models refreshed: the
|
|
117
|
+
// key stays saved, and the tab says why it does not work yet.
|
|
118
|
+
if (settings.configured === true && settings.discoveryError?.error) {
|
|
119
|
+
const changed = settings.discoveryError.defaultModelChanged;
|
|
120
|
+
const note = changed?.from && changed?.to
|
|
121
|
+
? ` Until it lists this key's models, the default model is ${changed.to} (was ${changed.from}).`
|
|
122
|
+
: '';
|
|
123
|
+
return { text: `API key saved, but Anthropic refused it: ${settings.discoveryError.error}${note}`, state: 'error' };
|
|
124
|
+
}
|
|
125
|
+
if (settings.configured === true && settings.enabled === true) {
|
|
126
|
+
return {
|
|
127
|
+
text: 'API key saved. Claude API is on: pick it in New Chat, or switch a Claude chat to the API key.',
|
|
128
|
+
state: 'active',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (settings.configured === true) {
|
|
132
|
+
return { text: 'API key saved, Claude API is off. Switch it on to offer it in New Chat.', state: 'saved' };
|
|
133
|
+
}
|
|
134
|
+
return { text: 'No API key saved. Claude chats run on the relay host\'s Claude login.', state: 'unconfigured' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function claudeApiSettingsState() {
|
|
138
|
+
return state;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Called with the tab's state whenever it changes (the composer's toggle listens). */
|
|
142
|
+
export function onClaudeApiSettingsChange(listener) {
|
|
143
|
+
if (typeof listener !== 'function') return () => {};
|
|
144
|
+
listeners.add(listener);
|
|
145
|
+
return () => listeners.delete(listener);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function applyClaudeApiSettingsState(settings = {}, { resetInputs = false } = {}) {
|
|
149
|
+
if (typeof document !== 'undefined') ensureInputTracking();
|
|
150
|
+
const available = Array.isArray(settings?.availableModels)
|
|
151
|
+
? settings.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
|
|
152
|
+
: state.availableModels;
|
|
153
|
+
const cap = Number(settings?.monthlyCapUsd);
|
|
154
|
+
state = {
|
|
155
|
+
configured: settings?.configured === true,
|
|
156
|
+
enabled: settings?.configured === true && settings?.enabled === true,
|
|
157
|
+
model: String(settings?.model || state.model || '').trim(),
|
|
158
|
+
availableModels: available.length ? available : state.availableModels,
|
|
159
|
+
discovered: settings?.discovered === true,
|
|
160
|
+
monthlyCapUsd: Number.isFinite(cap) && cap > 0 ? cap : null,
|
|
161
|
+
// From a save's or a refresh's answer; a plain read keeps the last one.
|
|
162
|
+
discoveryError: settings?.configured !== true
|
|
163
|
+
? null
|
|
164
|
+
: (Object.prototype.hasOwnProperty.call(settings || {}, 'discoveryError') ? (settings.discoveryError || null) : (state.discoveryError || null)),
|
|
165
|
+
};
|
|
166
|
+
for (const listener of listeners) {
|
|
167
|
+
try { listener(state); } catch {}
|
|
168
|
+
}
|
|
169
|
+
if (typeof document === 'undefined') return state;
|
|
170
|
+
const keepInputs = inputsDirty && !resetInputs;
|
|
171
|
+
const keyInput = element('claude-api-key-input');
|
|
172
|
+
if (keyInput && !keepInputs) {
|
|
173
|
+
// Never filled from the relay: it has no key to give back.
|
|
174
|
+
keyInput.value = '';
|
|
175
|
+
keyInput.placeholder = state.configured ? 'Saved API key (enter to replace)' : 'Claude Console API key…';
|
|
176
|
+
}
|
|
177
|
+
const modelSelect = element('claude-api-model-select');
|
|
178
|
+
if (modelSelect && !keepInputs) fillModelSelect(modelSelect);
|
|
179
|
+
const capInput = element('claude-api-monthly-cap-input');
|
|
180
|
+
if (capInput && !keepInputs) {
|
|
181
|
+
capInput.value = claudeApiCapInputText(state.monthlyCapUsd);
|
|
182
|
+
showCapError('');
|
|
183
|
+
}
|
|
184
|
+
if (resetInputs) {
|
|
185
|
+
inputsDirty = false;
|
|
186
|
+
modelChosen = false;
|
|
187
|
+
}
|
|
188
|
+
const toggle = element('claude-api-enabled-toggle');
|
|
189
|
+
if (toggle) {
|
|
190
|
+
toggle.checked = state.enabled;
|
|
191
|
+
toggle.disabled = updateInFlight || !state.configured;
|
|
192
|
+
}
|
|
193
|
+
const removeButton = element('claude-api-remove-btn');
|
|
194
|
+
if (removeButton) removeButton.disabled = updateInFlight || !state.configured;
|
|
195
|
+
const refreshButton = element('claude-api-refresh-btn');
|
|
196
|
+
if (refreshButton) refreshButton.disabled = updateInFlight || !state.enabled;
|
|
197
|
+
const modelsNote = element('claude-api-models-status');
|
|
198
|
+
if (modelsNote) {
|
|
199
|
+
modelsNote.textContent = state.configured && !state.discovered
|
|
200
|
+
? 'Not listed by Anthropic yet: these are the Claude tab\'s models until the key\'s list is read.'
|
|
201
|
+
: '';
|
|
202
|
+
modelsNote.hidden = !modelsNote.textContent;
|
|
203
|
+
}
|
|
204
|
+
const status = element('claude-api-settings-status');
|
|
205
|
+
if (status) {
|
|
206
|
+
const { text, state: statusState } = claudeApiSettingsStatus(state);
|
|
207
|
+
status.textContent = text;
|
|
208
|
+
status.dataset.state = statusState;
|
|
209
|
+
}
|
|
210
|
+
window.syncAutoModelAvailability?.();
|
|
211
|
+
return state;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function refreshClaudeApiSettingsState() {
|
|
215
|
+
const settings = await loadClaudeApiSettings();
|
|
216
|
+
if (!settings) return null;
|
|
217
|
+
return applyClaudeApiSettingsState(settings);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Called when the Settings modal opens. */
|
|
221
|
+
export async function syncClaudeApiSettingsInputs() {
|
|
222
|
+
inputsDirty = false;
|
|
223
|
+
modelChosen = false;
|
|
224
|
+
ensureInputTracking();
|
|
225
|
+
const status = element('claude-api-settings-status');
|
|
226
|
+
const settings = await refreshClaudeApiSettingsState();
|
|
227
|
+
if (!settings && status) {
|
|
228
|
+
status.textContent = 'Unable to load Claude API settings.';
|
|
229
|
+
status.dataset.state = 'error';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function runUpdate(payload, { successText, failureText, resetInputs = false }) {
|
|
234
|
+
if (updateInFlight) return null;
|
|
235
|
+
updateInFlight = true;
|
|
236
|
+
setControlsDisabled(true);
|
|
237
|
+
try {
|
|
238
|
+
const result = await updateClaudeApiSettings(payload);
|
|
239
|
+
if (!result) throw new Error(failureText);
|
|
240
|
+
applyClaudeApiSettingsState(result, { resetInputs });
|
|
241
|
+
const text = typeof successText === 'function' ? successText(result) : successText;
|
|
242
|
+
showTransientRelayNotice(result.warning ? `${text} ${result.warning}` : text, result.warning ? 8000 : 4000);
|
|
243
|
+
return result;
|
|
244
|
+
} catch (error) {
|
|
245
|
+
// A refused key (a login token, an Admin key) or a refused removal (409)
|
|
246
|
+
// says why; the relay never quotes the key back.
|
|
247
|
+
alert(error?.message || failureText);
|
|
248
|
+
return null;
|
|
249
|
+
} finally {
|
|
250
|
+
updateInFlight = false;
|
|
251
|
+
setControlsDisabled(false);
|
|
252
|
+
applyClaudeApiSettingsState(state);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function saveClaudeApiSettings() {
|
|
257
|
+
const apiKey = String(element('claude-api-key-input')?.value || '').trim();
|
|
258
|
+
if (!apiKey && !state.configured) {
|
|
259
|
+
alert('Enter an API key from the Claude Console.');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const capInput = element('claude-api-monthly-cap-input');
|
|
263
|
+
// A number field reads '' for text it cannot parse: that is not "no cap".
|
|
264
|
+
const cap = capInput?.validity?.badInput
|
|
265
|
+
? parseClaudeApiCapInput('not a number')
|
|
266
|
+
: parseClaudeApiCapInput(capInput?.value);
|
|
267
|
+
showCapError(cap.ok ? '' : cap.error);
|
|
268
|
+
if (!cap.ok) {
|
|
269
|
+
capInput?.focus?.();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// A pick from the key's discovered list; otherwise the relay keeps the
|
|
273
|
+
// default it has, or derives one from the key's models.
|
|
274
|
+
const model = modelChosen && state.discovered
|
|
275
|
+
? String(element('claude-api-model-select')?.value || '').trim()
|
|
276
|
+
: '';
|
|
277
|
+
await runUpdate({
|
|
278
|
+
apiKey,
|
|
279
|
+
...(model ? { model } : {}),
|
|
280
|
+
monthlyCapUsd: cap.capUsd,
|
|
281
|
+
enabled: state.configured ? state.enabled : true,
|
|
282
|
+
}, {
|
|
283
|
+
successText: (result) => (result.model ? `Claude API settings saved for ${result.model}.` : 'Claude API settings saved.'),
|
|
284
|
+
failureText: 'Failed to save Claude API settings.',
|
|
285
|
+
resetInputs: true,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function toggleClaudeApiProvider(enabled) {
|
|
290
|
+
if (enabled && !state.configured) {
|
|
291
|
+
applyClaudeApiSettingsState(state);
|
|
292
|
+
alert('Save an API key from the Claude Console before switching Claude API on.');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
await runUpdate({ enabled: enabled === true }, {
|
|
296
|
+
successText: (result) => (result.enabled ? 'Claude API switched on.' : 'Claude API switched off.'),
|
|
297
|
+
failureText: 'Failed to update the Claude API provider.',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function removeClaudeApiSettings() {
|
|
302
|
+
if (!state.configured) return;
|
|
303
|
+
if (!confirm('Remove the saved Claude API key?')) return;
|
|
304
|
+
await runUpdate({ remove: true }, {
|
|
305
|
+
successText: 'Claude API key removed.',
|
|
306
|
+
failureText: 'Failed to remove the Claude API key.',
|
|
307
|
+
resetInputs: true,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Asks Anthropic for the key's models again (the relay's model refresh). */
|
|
312
|
+
export async function refreshClaudeApiModels() {
|
|
313
|
+
if (updateInFlight || !state.enabled) return;
|
|
314
|
+
updateInFlight = true;
|
|
315
|
+
setControlsDisabled(true);
|
|
316
|
+
try {
|
|
317
|
+
const refreshed = await refreshModelVariantCatalog();
|
|
318
|
+
const discovery = refreshed?.claudeApiModelDiscovery || null;
|
|
319
|
+
await refreshClaudeApiSettingsState();
|
|
320
|
+
if (discovery && !discovery.skipped) {
|
|
321
|
+
state = {
|
|
322
|
+
...state,
|
|
323
|
+
discoveryError: discovery.ok === false && discovery.stale !== true
|
|
324
|
+
? { error: discovery.error || 'Claude API model discovery failed', code: discovery.code || null }
|
|
325
|
+
: (discovery.ok === true ? null : state.discoveryError),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (!refreshed) {
|
|
329
|
+
showTransientRelayNotice('Could not refresh the models.', 6000);
|
|
330
|
+
} else if (discovery && discovery.ok === false && !discovery.skipped) {
|
|
331
|
+
showTransientRelayNotice(`Claude API model discovery failed. The stored models were kept: ${discovery.error || 'unknown error'}`, 8000);
|
|
332
|
+
} else {
|
|
333
|
+
showTransientRelayNotice('Claude API models refreshed.', 4000);
|
|
334
|
+
}
|
|
335
|
+
} finally {
|
|
336
|
+
updateInFlight = false;
|
|
337
|
+
setControlsDisabled(false);
|
|
338
|
+
applyClaudeApiSettingsState(state);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The cap refusal's button: the cap field, ready to raise or empty. */
|
|
343
|
+
export function focusClaudeApiCapInput() {
|
|
344
|
+
const input = element('claude-api-monthly-cap-input');
|
|
345
|
+
if (!input) return;
|
|
346
|
+
input.scrollIntoView?.({ block: 'center' });
|
|
347
|
+
input.focus?.();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The settings Claude API chats share with Claude chats live on the Claude
|
|
351
|
+
// tab: one place to change them.
|
|
352
|
+
export function openClaudeTabFromClaudeApi() {
|
|
353
|
+
selectSettingsTab('providers', 'claude');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function __resetClaudeApiSettingsUiForTests() {
|
|
357
|
+
updateInFlight = false;
|
|
358
|
+
inputsDirty = false;
|
|
359
|
+
modelChosen = false;
|
|
360
|
+
state = { configured: false, enabled: false, model: '', availableModels: [], discovered: false, monthlyCapUsd: null, discoveryError: null };
|
|
361
|
+
listeners.clear();
|
|
362
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The composer's billing toggle (claude-billing-toggle.mjs) on the page: a
|
|
2
|
+
// row above the composer, under the lock note, in Claude SDK chats. A press
|
|
3
|
+
// moves the open chat between the subscription and the saved API key through
|
|
4
|
+
// the relay's billing route; the relay's answer and its `conversation_billing`
|
|
5
|
+
// events (usage-limit-ui.js keeps them per chat) redraw it.
|
|
6
|
+
|
|
7
|
+
import { switchConversationBilling } from './api-client.js';
|
|
8
|
+
import { claudeApiSettingsState, onClaudeApiSettingsChange } from './claude-api-settings-ui.js';
|
|
9
|
+
import {
|
|
10
|
+
CLAUDE_BILLING_CACHE_WARNING,
|
|
11
|
+
claudeBillingSwitchedText,
|
|
12
|
+
claudeBillingToggleModel,
|
|
13
|
+
} from './claude-billing-toggle.mjs';
|
|
14
|
+
import { conversations, currentConvId, showTransientRelayNotice } from './store.js';
|
|
15
|
+
import { conversationBillingState, onConversationBillingChange, setConversationBilling } from './usage-limit-ui.js';
|
|
16
|
+
|
|
17
|
+
const ROW_ID = 'claude-billing-toggle';
|
|
18
|
+
|
|
19
|
+
// The relay's refusals of a switch that say the target is closed for now: the
|
|
20
|
+
// toggle closes that side with the relay's reason until the next billing event.
|
|
21
|
+
const CLOSING_REFUSALS = new Set([
|
|
22
|
+
'CLAUDE_API_CAP_REACHED',
|
|
23
|
+
'CLAUDE_API_NOT_CONFIGURED',
|
|
24
|
+
'CLAUDE_BILLING_MODEL_UNAVAILABLE',
|
|
25
|
+
'CLAUDE_BILLING_HISTORY',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
let deps = {};
|
|
29
|
+
// The chats this page has a switch on its way for; one per chat.
|
|
30
|
+
const inFlightConversationIds = new Set();
|
|
31
|
+
// Chat → what the relay said it may switch to before this page closed the
|
|
32
|
+
// side a refusal named: given back when the Claude API tab changes.
|
|
33
|
+
const switchableBeforeRefusal = new Map();
|
|
34
|
+
// The Claude API tab's state as last seen, so that only a change reopens.
|
|
35
|
+
let claudeApiSettingsKey = '';
|
|
36
|
+
|
|
37
|
+
function settingsKey(settings) {
|
|
38
|
+
return JSON.stringify([
|
|
39
|
+
settings?.configured === true,
|
|
40
|
+
settings?.enabled === true,
|
|
41
|
+
String(settings?.model || ''),
|
|
42
|
+
Array.isArray(settings?.availableModels) ? settings.availableModels : [],
|
|
43
|
+
settings?.monthlyCapUsd ?? null,
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
// The chat this page just moved to the key: the toggle says what the first
|
|
47
|
+
// turn there costs until another chat is opened.
|
|
48
|
+
let warnCacheConversationId = '';
|
|
49
|
+
let renderedConversationId = '';
|
|
50
|
+
|
|
51
|
+
function conversationProvider(conversationId) {
|
|
52
|
+
const conversation = conversations[conversationId] || null;
|
|
53
|
+
return String(conversation?.runtimeProviderType || conversation?.runtime_provider_type || '').trim().toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A switch changes the chat's provider: the composer's model list, the lock
|
|
57
|
+
// note and the list pill follow it.
|
|
58
|
+
function followBilling(conversationId, billing) {
|
|
59
|
+
const provider = String(billing?.provider || '').trim().toLowerCase();
|
|
60
|
+
if (provider !== 'claude' && provider !== 'claude-api') return;
|
|
61
|
+
const conversation = conversations[conversationId];
|
|
62
|
+
if (!conversation || conversationProvider(conversationId) === provider) return;
|
|
63
|
+
conversations[conversationId] = { ...conversation, runtimeProviderType: provider };
|
|
64
|
+
if (provider !== 'claude-api' && warnCacheConversationId === conversationId) warnCacheConversationId = '';
|
|
65
|
+
try { deps.onConversationProviderChanged?.(conversationId, provider); } catch {}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function optionButton(option, conversationId) {
|
|
69
|
+
const button = document.createElement('button');
|
|
70
|
+
button.type = 'button';
|
|
71
|
+
button.className = 'claude-billing-option';
|
|
72
|
+
button.dataset.billing = option.value;
|
|
73
|
+
button.textContent = option.label;
|
|
74
|
+
button.setAttribute('aria-pressed', option.pressed ? 'true' : 'false');
|
|
75
|
+
// Not `disabled`: a press on an unavailable side says why (a phone has no
|
|
76
|
+
// tooltip to read).
|
|
77
|
+
if (!option.pressed && !option.available) button.setAttribute('aria-disabled', 'true');
|
|
78
|
+
const title = option.reason || (!option.pressed && option.value === 'claude-api' ? CLAUDE_BILLING_CACHE_WARNING : '');
|
|
79
|
+
if (title) button.title = title;
|
|
80
|
+
button.addEventListener('click', (event) => {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
if (option.pressed) return;
|
|
83
|
+
if (!option.available) {
|
|
84
|
+
showTransientRelayNotice(option.reason, 7000);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
void switchClaudeBilling(conversationId, option.value);
|
|
88
|
+
});
|
|
89
|
+
return button;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function renderClaudeBillingToggle() {
|
|
93
|
+
if (typeof document === 'undefined') return;
|
|
94
|
+
const row = document.getElementById(ROW_ID);
|
|
95
|
+
if (!row) return;
|
|
96
|
+
const conversationId = String(currentConvId || '').trim();
|
|
97
|
+
if (conversationId !== renderedConversationId) {
|
|
98
|
+
renderedConversationId = conversationId;
|
|
99
|
+
if (warnCacheConversationId && warnCacheConversationId !== conversationId) warnCacheConversationId = '';
|
|
100
|
+
}
|
|
101
|
+
const shared = (() => {
|
|
102
|
+
try { return deps.isSharedReaderMode?.() === true; } catch { return false; }
|
|
103
|
+
})();
|
|
104
|
+
const model = conversationId && !shared
|
|
105
|
+
? claudeBillingToggleModel({
|
|
106
|
+
providerType: conversationProvider(conversationId),
|
|
107
|
+
billing: conversationBillingState(conversationId),
|
|
108
|
+
claudeApi: claudeApiSettingsState(),
|
|
109
|
+
inFlight: inFlightConversationIds.has(conversationId),
|
|
110
|
+
warnCache: warnCacheConversationId === conversationId,
|
|
111
|
+
})
|
|
112
|
+
: { visible: false };
|
|
113
|
+
if (!model.visible) {
|
|
114
|
+
row.hidden = true;
|
|
115
|
+
row.replaceChildren();
|
|
116
|
+
delete row.dataset.billing;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const label = document.createElement('span');
|
|
120
|
+
label.className = 'claude-billing-label';
|
|
121
|
+
label.id = 'claude-billing-toggle-label';
|
|
122
|
+
label.textContent = 'Billing';
|
|
123
|
+
const group = document.createElement('div');
|
|
124
|
+
group.className = 'claude-billing-options';
|
|
125
|
+
group.setAttribute('role', 'group');
|
|
126
|
+
group.setAttribute('aria-labelledby', 'claude-billing-toggle-label');
|
|
127
|
+
group.append(...model.options.map((option) => optionButton(option, conversationId)));
|
|
128
|
+
const status = document.createElement('span');
|
|
129
|
+
status.className = 'claude-billing-status';
|
|
130
|
+
status.setAttribute('role', 'status');
|
|
131
|
+
status.setAttribute('aria-live', 'polite');
|
|
132
|
+
status.textContent = model.status;
|
|
133
|
+
if (model.statusKind) status.dataset.kind = model.statusKind;
|
|
134
|
+
row.replaceChildren(label, group, status);
|
|
135
|
+
row.dataset.billing = model.current;
|
|
136
|
+
row.hidden = false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A 409 carries no billing: the side it refused is closed here with the
|
|
140
|
+
// relay's reason, so a second press does not ask again. The next
|
|
141
|
+
// conversation_billing for the chat (a switch, or the cap flipping) replaces
|
|
142
|
+
// it; a change on the Claude API tab (a key, the switch, the models, the cap)
|
|
143
|
+
// opens it again, since the relay sends no billing event for those.
|
|
144
|
+
function closeRefusedSide(conversationId, to, result) {
|
|
145
|
+
const code = String(result?.code || '').trim().toUpperCase();
|
|
146
|
+
if (!CLOSING_REFUSALS.has(code)) return;
|
|
147
|
+
const known = conversationBillingState(conversationId);
|
|
148
|
+
if (!known || String(known.switchable?.to || '') !== to) return;
|
|
149
|
+
if (known.switchable?.refused !== true) switchableBeforeRefusal.set(conversationId, known.switchable);
|
|
150
|
+
setConversationBilling(conversationId, {
|
|
151
|
+
...known,
|
|
152
|
+
switchable: {
|
|
153
|
+
to,
|
|
154
|
+
allowed: false,
|
|
155
|
+
code,
|
|
156
|
+
reason: String(result?.error || '').trim() || null,
|
|
157
|
+
refused: true,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function reopenRefusedSides() {
|
|
163
|
+
for (const [conversationId, before] of Array.from(switchableBeforeRefusal.entries())) {
|
|
164
|
+
switchableBeforeRefusal.delete(conversationId);
|
|
165
|
+
const known = conversationBillingState(conversationId);
|
|
166
|
+
if (known?.switchable?.refused !== true) continue;
|
|
167
|
+
setConversationBilling(conversationId, { ...known, switchable: before });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Moves a chat to `to` ('claude' | 'claude-api'); answers the relay's result. */
|
|
172
|
+
export async function switchClaudeBilling(conversationId, to) {
|
|
173
|
+
const id = String(conversationId || '').trim();
|
|
174
|
+
if (!id || inFlightConversationIds.has(id)) return null;
|
|
175
|
+
inFlightConversationIds.add(id);
|
|
176
|
+
renderClaudeBillingToggle();
|
|
177
|
+
let result = null;
|
|
178
|
+
try {
|
|
179
|
+
result = await switchConversationBilling(id, { to, by: 'user' });
|
|
180
|
+
} finally {
|
|
181
|
+
inFlightConversationIds.delete(id);
|
|
182
|
+
}
|
|
183
|
+
if (!result?.ok) {
|
|
184
|
+
showTransientRelayNotice(`Not switched: ${result?.error || 'the relay refused it.'}`, 7000);
|
|
185
|
+
closeRefusedSide(id, to, result);
|
|
186
|
+
renderClaudeBillingToggle();
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
if (result.changed && result.provider === 'claude-api') warnCacheConversationId = id;
|
|
190
|
+
if (result.billing) setConversationBilling(id, result.billing);
|
|
191
|
+
else followBilling(id, { provider: result.provider });
|
|
192
|
+
if (result.changed) showTransientRelayNotice(claudeBillingSwitchedText(result.provider), 6000);
|
|
193
|
+
renderClaudeBillingToggle();
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function initClaudeBillingToggle(options = {}) {
|
|
198
|
+
deps = options && typeof options === 'object' ? options : {};
|
|
199
|
+
onConversationBillingChange((conversationId, billing) => {
|
|
200
|
+
// The relay's own word replaces this page's patch.
|
|
201
|
+
if (billing?.switchable?.refused !== true) switchableBeforeRefusal.delete(conversationId);
|
|
202
|
+
if (billing) followBilling(conversationId, billing);
|
|
203
|
+
if (conversationId === String(currentConvId || '').trim()) renderClaudeBillingToggle();
|
|
204
|
+
});
|
|
205
|
+
claudeApiSettingsKey = settingsKey(claudeApiSettingsState());
|
|
206
|
+
onClaudeApiSettingsChange((settings) => {
|
|
207
|
+
const key = settingsKey(settings);
|
|
208
|
+
if (key !== claudeApiSettingsKey) {
|
|
209
|
+
claudeApiSettingsKey = key;
|
|
210
|
+
reopenRefusedSides();
|
|
211
|
+
}
|
|
212
|
+
renderClaudeBillingToggle();
|
|
213
|
+
});
|
|
214
|
+
renderClaudeBillingToggle();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function __resetClaudeBillingToggleForTests() {
|
|
218
|
+
deps = {};
|
|
219
|
+
inFlightConversationIds.clear();
|
|
220
|
+
switchableBeforeRefusal.clear();
|
|
221
|
+
claudeApiSettingsKey = '';
|
|
222
|
+
warnCacheConversationId = '';
|
|
223
|
+
renderedConversationId = '';
|
|
224
|
+
}
|