@oar-sh/oar 0.9.17 → 0.9.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +234 -0
- package/README.md +13 -12
- package/package.json +1 -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-cli-pinned-env.mjs +150 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +5 -5
- package/server/claude-worker/claude-sdk-adapter.mjs +35 -9
- package/server/claude-worker/claude-session-process.mjs +166 -21
- package/server/claude-worker/claude-session-worker-main.mjs +3 -0
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-publisher.mjs +117 -5
- package/server/claude-worker/claude-usage-totals.mjs +53 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
- 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 +33 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
- 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/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 +52 -0
- package/server/public/app/bootstrap.js +153 -27
- 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 +6 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +11 -0
- package/server/public/app/conversation-view.js +29 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +127 -10
- package/server/public/app/model-selector-options.mjs +17 -1
- package/server/public/app/new-conversation-model-choice.mjs +21 -0
- package/server/public/app/provider-readiness.mjs +5 -2
- package/server/public/app/relay-error-ctas.mjs +50 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -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 +189 -3
- package/server/relay-heap-guard.mjs +338 -0
- package/server/relay-self-restart.mjs +33 -7
- package/server/relay-tools.md +6 -6
- package/server/repositories/conversation-group-repository.mjs +1 -1
- 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 +1 -1
- package/server/routes/messages-routes.mjs +678 -31
- 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 +365 -53
- package/server/server-runtime.mjs +373 -26
- package/server/services/about-service.mjs +1 -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/plan-usage-claude-api.mjs +445 -0
- package/server/services/plan-usage-service.mjs +5 -0
- package/server/services/provider-model-selection.mjs +3 -2
- package/server/services/provider-reasoning-effort.mjs +9 -0
- 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 +111 -17
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +61 -2
- 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 +2 -0
- package/shared/anthropic-api.mjs +202 -0
- 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 +54 -12
- package/shared/remote-relay-tool-core.mjs +23 -6
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
const CLAUDE_SETTINGS_CTA = Object.freeze([
|
|
17
17
|
Object.freeze({ action: 'open-claude-settings', label: 'Claude settings' }),
|
|
18
18
|
]);
|
|
19
|
+
const CLAUDE_API_SETTINGS_CTA = Object.freeze([
|
|
20
|
+
Object.freeze({ action: 'open-claude-api-settings', label: 'Claude API settings' }),
|
|
21
|
+
]);
|
|
22
|
+
const SWITCH_TO_SUBSCRIPTION_CTA = Object.freeze({ action: 'switch-claude-billing-to-subscription', label: 'Switch to subscription' });
|
|
23
|
+
const CLAUDE_API_UNAVAILABLE_CTA = Object.freeze([
|
|
24
|
+
Object.freeze({ action: 'open-claude-api-settings', label: 'Claude API settings' }),
|
|
25
|
+
SWITCH_TO_SUBSCRIPTION_CTA,
|
|
26
|
+
]);
|
|
19
27
|
|
|
20
28
|
const RELAY_ERROR_CTAS = Object.freeze({
|
|
21
29
|
'grok-cli-missing': Object.freeze([
|
|
@@ -44,6 +52,31 @@ const RELAY_ERROR_CTAS = Object.freeze({
|
|
|
44
52
|
Object.freeze({ action: 'open-deepseek-top-up', label: 'Top up DeepSeek' }),
|
|
45
53
|
Object.freeze({ action: 'open-deepseek-settings', label: 'DeepSeek settings' }),
|
|
46
54
|
]),
|
|
55
|
+
// A Claude API row failed closed at the claim (server-runtime.mjs,
|
|
56
|
+
// claudeApiClaimCheck): no key or the provider switched off, or this
|
|
57
|
+
// month's cap reached. The way out is the tab (at its cap field for the
|
|
58
|
+
// cap), or the subscription, which the cap never blocks.
|
|
59
|
+
'claude-api-not-configured': CLAUDE_API_UNAVAILABLE_CTA,
|
|
60
|
+
'claude-api-cap-reached': Object.freeze([
|
|
61
|
+
Object.freeze({ action: 'open-claude-api-cap', label: 'Claude API settings' }),
|
|
62
|
+
SWITCH_TO_SUBSCRIPTION_CTA,
|
|
63
|
+
]),
|
|
64
|
+
// A turn the Claude API refused (claude-api-backend.mjs, describeClaudeApiFailure).
|
|
65
|
+
'claude-api-key': CLAUDE_API_SETTINGS_CTA,
|
|
66
|
+
'claude-api-key-source': CLAUDE_API_SETTINGS_CTA,
|
|
67
|
+
'claude-api-credit': Object.freeze([
|
|
68
|
+
Object.freeze({ action: 'open-claude-api-billing', label: 'Add funds' }),
|
|
69
|
+
Object.freeze({ action: 'open-claude-api-settings', label: 'Claude API settings' }),
|
|
70
|
+
]),
|
|
71
|
+
'claude-api-billing': Object.freeze([
|
|
72
|
+
Object.freeze({ action: 'open-claude-api-billing', label: 'Console billing' }),
|
|
73
|
+
]),
|
|
74
|
+
'claude-api-spend-limit': Object.freeze([
|
|
75
|
+
Object.freeze({ action: 'open-claude-api-limits', label: 'Console limits' }),
|
|
76
|
+
]),
|
|
77
|
+
'claude-api-rate-limit': Object.freeze([
|
|
78
|
+
Object.freeze({ action: 'open-claude-api-limits', label: 'Console limits' }),
|
|
79
|
+
]),
|
|
47
80
|
});
|
|
48
81
|
|
|
49
82
|
const STABLE_CODE_PATTERN = /error code:\s*relay\.([a-z0-9-]+)/i;
|
|
@@ -81,3 +114,20 @@ export function relayErrorCtaActionsForCode(code) {
|
|
|
81
114
|
const normalized = String(code || '').trim().toLowerCase().replace(/^relay\./, '');
|
|
82
115
|
return RELAY_ERROR_CTAS[normalized] || [];
|
|
83
116
|
}
|
|
117
|
+
|
|
118
|
+
export const START_ON_SUBSCRIPTION_ACTION = 'start-new-chat-on-subscription';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The buttons of New Chat when the relay refused to start a Claude API chat
|
|
122
|
+
* (its 409 code, CLAUDE_API_CAP_REACHED or CLAUDE_API_NOT_CONFIGURED; nothing
|
|
123
|
+
* was bound): the transcript's buttons for that code, where "Switch to
|
|
124
|
+
* subscription" becomes "Start on subscription" (the same new chat on the
|
|
125
|
+
* Claude provider), offered only when New Chat offers the Claude provider.
|
|
126
|
+
*/
|
|
127
|
+
export function newChatClaudeApiRefusalActions(code, { subscriptionOffered = false } = {}) {
|
|
128
|
+
const normalized = String(code || '').trim().toLowerCase().replace(/_/g, '-');
|
|
129
|
+
return relayErrorCtaActionsForCode(normalized).flatMap((item) => {
|
|
130
|
+
if (item.action !== 'switch-claude-billing-to-subscription') return [item];
|
|
131
|
+
return subscriptionOffered ? [{ action: START_ON_SUBSCRIPTION_ACTION, label: 'Start on subscription' }] : [];
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -164,7 +164,7 @@ export function syncRelayScheduleHeader() {
|
|
|
164
164
|
}
|
|
165
165
|
const next = nextScheduledAt(mine);
|
|
166
166
|
const nextText = next ? new Date(next).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
|
167
|
-
line.innerHTML = `<button type="button" class="relay-watch-chip relay-schedule-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-schedule-panel" title="Prompts the relay queues at set times (agents set them with
|
|
167
|
+
line.innerHTML = `<button type="button" class="relay-watch-chip relay-schedule-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-schedule-panel" title="Prompts the relay queues at set times (agents set them with relay_tool schedule)">⏰ ${escHtml(chipText)}${nextText ? ` <span class="relay-watch-hint">next ${escHtml(nextText)}</span>` : ''}</button>`;
|
|
168
168
|
line.hidden = false;
|
|
169
169
|
panel.innerHTML = panelOpen ? renderSchedulePanelHtml(schedules, id, { conversations, escHtml }) : '';
|
|
170
170
|
panel.hidden = !panelOpen;
|
|
@@ -56,6 +56,7 @@ import { selectSettingsTab } from './settings-tabs.js';
|
|
|
56
56
|
import { refreshClaudeAuthSection } from './claude-auth-ui.js';
|
|
57
57
|
import { refreshGrokAuthSection } from './grok-auth-ui.js';
|
|
58
58
|
import { syncDeepSeekSettingsInputs } from './deepseek-settings-ui.js';
|
|
59
|
+
import { syncClaudeApiSettingsInputs } from './claude-api-settings-ui.js';
|
|
59
60
|
|
|
60
61
|
export {
|
|
61
62
|
applyDeepSeekSettingsState,
|
|
@@ -65,6 +66,15 @@ export {
|
|
|
65
66
|
saveDeepSeekSettings,
|
|
66
67
|
toggleDeepSeekProvider,
|
|
67
68
|
} from './deepseek-settings-ui.js';
|
|
69
|
+
export {
|
|
70
|
+
applyClaudeApiSettingsState,
|
|
71
|
+
openClaudeTabFromClaudeApi,
|
|
72
|
+
refreshClaudeApiModels,
|
|
73
|
+
refreshClaudeApiSettingsState,
|
|
74
|
+
removeClaudeApiSettings,
|
|
75
|
+
saveClaudeApiSettings,
|
|
76
|
+
toggleClaudeApiProvider,
|
|
77
|
+
} from './claude-api-settings-ui.js';
|
|
68
78
|
import { openClaudeCloudSettingsSection } from './claude-cloud-settings-ui.js';
|
|
69
79
|
import { refreshCliInstallSections } from './cli-install-ui.js';
|
|
70
80
|
import { refreshUpdateSection } from './update-ui.js';
|
|
@@ -1960,6 +1970,7 @@ export function openSettingsModal(tab, providerTab) {
|
|
|
1960
1970
|
ensureCursorSettingsInputTracking();
|
|
1961
1971
|
void syncCursorSettingsInputs();
|
|
1962
1972
|
void syncDeepSeekSettingsInputs();
|
|
1973
|
+
void syncClaudeApiSettingsInputs();
|
|
1963
1974
|
void refreshCursorAllowanceState();
|
|
1964
1975
|
void refreshCursorDashboardTokenState();
|
|
1965
1976
|
void refreshGrokAllowanceState();
|
|
@@ -4,7 +4,7 @@ const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
|
|
|
4
4
|
const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'shares', 'notifications', 'features'];
|
|
5
5
|
// Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
|
|
6
6
|
// is also the sub-tab a first-time visitor lands on.
|
|
7
|
-
const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-cloud', 'grok', 'cursor', 'deepseek'];
|
|
7
|
+
const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-api', 'claude-cloud', 'grok', 'cursor', 'deepseek'];
|
|
8
8
|
|
|
9
9
|
let settingsTabsBound = false;
|
|
10
10
|
|
|
@@ -66,7 +66,7 @@ import { updateProviderReadinessInputs } from './provider-readiness.mjs';
|
|
|
66
66
|
import { applyCliInstallState } from './cli-install-ui.js';
|
|
67
67
|
import { renderUpdateSection } from './update-ui.js';
|
|
68
68
|
import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
|
|
69
|
-
import { applyClaudeUsageLimit, applyUsageLimitPause } from './usage-limit-ui.js';
|
|
69
|
+
import { applyClaudeUsageLimit, applyConversationBilling, applyUsageLimitPause } from './usage-limit-ui.js';
|
|
70
70
|
import {
|
|
71
71
|
showThinking,
|
|
72
72
|
removeThinking,
|
|
@@ -154,6 +154,7 @@ let deps = null;
|
|
|
154
154
|
* @property {(payload?: object) => void} applyClaudeSettingsState
|
|
155
155
|
* @property {(payload?: object) => void} applyGrokSettingsState
|
|
156
156
|
* @property {(payload?: object) => void} applyDeepSeekSettingsState
|
|
157
|
+
* @property {(payload?: object) => void} applyClaudeApiSettingsState
|
|
157
158
|
* @property {(payload?: object) => void} applyCursorSettingsState
|
|
158
159
|
*/
|
|
159
160
|
|
|
@@ -466,6 +467,11 @@ export async function connectSocket(overrideDeps) {
|
|
|
466
467
|
});
|
|
467
468
|
// The Claude usage limit: a conversation's paused turn, and the account's
|
|
468
469
|
// latest report (also sent on connect).
|
|
470
|
+
// A Claude chat moved between the subscription and the API key, or what it
|
|
471
|
+
// may switch to changed (a swap waiting, the key removed).
|
|
472
|
+
socket.on('conversation_billing', (payload) => {
|
|
473
|
+
applyConversationBilling(payload || null);
|
|
474
|
+
});
|
|
469
475
|
socket.on('usage_limit_pause', (payload) => {
|
|
470
476
|
applyUsageLimitPause(payload || null);
|
|
471
477
|
});
|
|
@@ -507,6 +513,17 @@ export async function connectSocket(overrideDeps) {
|
|
|
507
513
|
.catch(() => {});
|
|
508
514
|
}
|
|
509
515
|
});
|
|
516
|
+
// The Claude API tab saved (a key, the switch, the cap): the tab, New
|
|
517
|
+
// Chat's readiness and the composer's billing toggle follow.
|
|
518
|
+
socket.on('claude_api_settings_updated', (payload) => {
|
|
519
|
+
const state = deps?.applyClaudeApiSettingsState?.(payload || {});
|
|
520
|
+
updateProviderReadinessInputs({ claudeApi: state || payload || null });
|
|
521
|
+
if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
|
|
522
|
+
void Promise.resolve()
|
|
523
|
+
.then(() => deps?.refreshCurrentView?.())
|
|
524
|
+
.catch(() => {});
|
|
525
|
+
}
|
|
526
|
+
});
|
|
510
527
|
socket.on('cursor_settings_updated', (payload) => {
|
|
511
528
|
const state = deps?.applyCursorSettingsState?.(payload || {});
|
|
512
529
|
updateProviderReadinessInputs({ cursor: state || payload || null });
|
|
@@ -31,10 +31,11 @@ export function prefixToolActivityEmoji(text) {
|
|
|
31
31
|
if (/^Tool \((create_file|write)\)/i.test(value)) return `🆕 ${value}`;
|
|
32
32
|
if (/^Tool \((bash|shell|terminal)\)/i.test(value)) return `🔧 ${value}`;
|
|
33
33
|
if (/^Tool \((sql|sqlite)\)/i.test(value)) return `🗄️ ${value}`;
|
|
34
|
-
// Work on another OAR relay. Claude's raw name is
|
|
35
|
-
// the Copilot CLI names MCP tools <server>-<tool> (oar-
|
|
34
|
+
// Work on another OAR relay. Claude's raw name is mcp__relay__relay_tool and
|
|
35
|
+
// the Copilot CLI names MCP tools <server>-<tool> (oar-relay_tool); the
|
|
36
36
|
// activity normalisers strip both, but a line that kept one matches too.
|
|
37
|
-
|
|
37
|
+
// Lines written up to 0.9.17 carry the old name, remote_relay.
|
|
38
|
+
if (/^Tool \((?:mcp__[a-z0-9_-]+__|[a-z0-9_]+-)?(?:relay_tool|remote_relay)\)/i.test(value)) return `🛰️ ${value}`;
|
|
38
39
|
if (/^Tool \(/i.test(value)) return `🛠️ ${value}`;
|
|
39
40
|
return `ℹ️ ${value}`;
|
|
40
41
|
}
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
// banner above the composer. Before the limit it warns ("usage at 96 %"); at
|
|
3
3
|
// the limit it says when the window resets; and when a turn of this
|
|
4
4
|
// conversation was paused there, it says when the turn carries on, with
|
|
5
|
-
// Resume now and Cancel
|
|
5
|
+
// Resume now and Cancel, and Switch this chat to API key when the chat may
|
|
6
|
+
// move to the Claude API key (its `billing` says so).
|
|
6
7
|
//
|
|
7
8
|
// State arrives over the socket (`usage_limit_pause` per conversation,
|
|
8
9
|
// `claude_usage_limit` for the account), with the conversation payload, and
|
|
9
10
|
// with GET /api/status polls, so a client that missed an event catches up.
|
|
10
11
|
|
|
11
12
|
import { showTransientRelayNotice } from './store.js';
|
|
12
|
-
import { cancelUsageLimitPause, resumeUsageLimitPause } from './api-client.js';
|
|
13
|
+
import { cancelUsageLimitPause, resumeUsageLimitPause, switchConversationBilling } from './api-client.js';
|
|
13
14
|
|
|
14
15
|
const BANNER_ID = 'usage-limit-banner';
|
|
15
16
|
const DISMISSED_KEY = 'oar.usageLimitWarningDismissed';
|
|
@@ -18,11 +19,25 @@ const WARNING_FROM = 0.9;
|
|
|
18
19
|
|
|
19
20
|
let account = null;
|
|
20
21
|
const pauses = new Map();
|
|
22
|
+
// Each conversation's billing as the relay last said it (payload `billing`,
|
|
23
|
+
// socket `conversation_billing`): the subscription or the API key, and
|
|
24
|
+
// whether it may switch now.
|
|
25
|
+
const billings = new Map();
|
|
26
|
+
// Told of every change to a chat's billing (the composer's billing toggle).
|
|
27
|
+
const billingListeners = new Set();
|
|
21
28
|
let currentConversationId = '';
|
|
22
29
|
let currentProviderType = '';
|
|
23
30
|
let actionInFlight = false;
|
|
24
31
|
let wakeTimer = null;
|
|
25
32
|
|
|
33
|
+
function notifyBilling(conversationId) {
|
|
34
|
+
const id = String(conversationId || '').trim();
|
|
35
|
+
if (!id) return;
|
|
36
|
+
for (const listener of billingListeners) {
|
|
37
|
+
try { listener(id, billings.get(id) || null); } catch {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
function isFuture(iso, nowMs) {
|
|
27
42
|
const at = Date.parse(String(iso || ''));
|
|
28
43
|
return Number.isFinite(at) && at > nowMs;
|
|
@@ -73,6 +88,33 @@ export function usageLimitAccountText(state, nowMs = Date.now()) {
|
|
|
73
88
|
return `Claude usage at ${Math.round(utilization * 100)} % of the ${label} — resets ${resets}`;
|
|
74
89
|
}
|
|
75
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Whether the usage-limit note offers to move the chat to the API key: a
|
|
93
|
+
* subscription chat (never a cloud one) whose relay says the switch is
|
|
94
|
+
* allowed now (a key saved, Claude API on, the cap not reached, the model on
|
|
95
|
+
* the key's list).
|
|
96
|
+
*/
|
|
97
|
+
export function usageLimitSwitchOffered(conversationId, providerType = currentProviderType) {
|
|
98
|
+
if (String(providerType || '').trim().toLowerCase() !== 'claude') return false;
|
|
99
|
+
const billing = billings.get(String(conversationId || '')) || null;
|
|
100
|
+
return billing?.provider === 'claude'
|
|
101
|
+
&& billing?.switchable?.to === 'claude-api'
|
|
102
|
+
&& billing.switchable.allowed === true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Why the usage-limit note does not offer the API key, when the reason is
|
|
107
|
+
* worth a line: this month's Claude API cap. '' otherwise (Claude API off or
|
|
108
|
+
* without a key is no news at the limit).
|
|
109
|
+
*/
|
|
110
|
+
export function usageLimitSwitchBlockedText(conversationId, providerType = currentProviderType) {
|
|
111
|
+
if (String(providerType || '').trim().toLowerCase() !== 'claude') return '';
|
|
112
|
+
const billing = billings.get(String(conversationId || '')) || null;
|
|
113
|
+
if (billing?.provider !== 'claude' || billing?.switchable?.to !== 'claude-api') return '';
|
|
114
|
+
if (billing.switchable.allowed === true || billing.switchable.code !== 'CLAUDE_API_CAP_REACHED') return '';
|
|
115
|
+
return "No switch to the API key: this month's Claude API cap is reached.";
|
|
116
|
+
}
|
|
117
|
+
|
|
76
118
|
function dismissedKey(state) {
|
|
77
119
|
return state ? `${state.rateLimitType || ''}|${state.resetsAt || ''}|${state.status || ''}` : '';
|
|
78
120
|
}
|
|
@@ -101,6 +143,21 @@ async function runPauseAction(action) {
|
|
|
101
143
|
actionInFlight = true;
|
|
102
144
|
renderUsageLimitBanner();
|
|
103
145
|
try {
|
|
146
|
+
if (action === 'switch') {
|
|
147
|
+
const switched = await switchConversationBilling(conversationId, { to: 'claude-api', by: 'usage-limit' });
|
|
148
|
+
if (!switched?.ok) {
|
|
149
|
+
// Refused (the cap, the model, the key): the pause stays, and says why.
|
|
150
|
+
showTransientRelayNotice(`Not switched: ${switched?.error || 'the relay refused it.'}`, 7000);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
pauses.delete(conversationId);
|
|
154
|
+
if (switched.billing) {
|
|
155
|
+
billings.set(conversationId, switched.billing);
|
|
156
|
+
notifyBilling(conversationId);
|
|
157
|
+
}
|
|
158
|
+
showTransientRelayNotice('This chat now runs on the Claude API key; the paused turn carries on there.', 5000);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
104
161
|
const result = action === 'resume'
|
|
105
162
|
? await resumeUsageLimitPause(conversationId)
|
|
106
163
|
: await cancelUsageLimitPause(conversationId);
|
|
@@ -149,7 +206,19 @@ export function renderUsageLimitBanner(nowMs = Date.now()) {
|
|
|
149
206
|
const text = document.createElement('span');
|
|
150
207
|
text.className = 'usage-limit-text';
|
|
151
208
|
text.textContent = usageLimitPauseText(pause, nowMs);
|
|
152
|
-
children.push(text, button('Resume now', 'usage-limit-resume', () => runPauseAction('resume'))
|
|
209
|
+
children.push(text, button('Resume now', 'usage-limit-resume', () => runPauseAction('resume')));
|
|
210
|
+
if (usageLimitSwitchOffered(currentConversationId)) {
|
|
211
|
+
children.push(button('Switch this chat to API key', 'usage-limit-switch', () => runPauseAction('switch')));
|
|
212
|
+
} else {
|
|
213
|
+
const blocked = usageLimitSwitchBlockedText(currentConversationId);
|
|
214
|
+
if (blocked) {
|
|
215
|
+
const note = document.createElement('span');
|
|
216
|
+
note.className = 'usage-limit-text usage-limit-switch-blocked';
|
|
217
|
+
note.textContent = blocked;
|
|
218
|
+
children.push(note);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
children.push(button('Cancel', 'usage-limit-cancel', () => runPauseAction('cancel')));
|
|
153
222
|
banner.dataset.state = 'paused';
|
|
154
223
|
} else {
|
|
155
224
|
const line = currentConversationId && currentProviderType === 'claude' && !isDismissed(account)
|
|
@@ -198,15 +267,75 @@ export function applyUsageLimitFromStatus(status) {
|
|
|
198
267
|
renderUsageLimitBanner();
|
|
199
268
|
}
|
|
200
269
|
|
|
201
|
-
/** The conversation on screen, with what its payload said about a pause. */
|
|
202
|
-
export function setUsageLimitConversation(conversationId, { pause = undefined, providerType = '' } = {}) {
|
|
270
|
+
/** The conversation on screen, with what its payload said about a pause and its billing. */
|
|
271
|
+
export function setUsageLimitConversation(conversationId, { pause = undefined, providerType = '', billing = undefined } = {}) {
|
|
203
272
|
currentConversationId = String(conversationId || '').trim();
|
|
204
273
|
currentProviderType = String(providerType || '').trim().toLowerCase();
|
|
205
274
|
if (currentConversationId && pause !== undefined) {
|
|
206
275
|
if (pause && typeof pause === 'object') pauses.set(currentConversationId, pause);
|
|
207
276
|
else pauses.delete(currentConversationId);
|
|
208
277
|
}
|
|
278
|
+
if (currentConversationId && billing !== undefined) {
|
|
279
|
+
if (billing && typeof billing === 'object') billings.set(currentConversationId, billing);
|
|
280
|
+
else billings.delete(currentConversationId);
|
|
281
|
+
}
|
|
209
282
|
renderUsageLimitBanner();
|
|
283
|
+
notifyBilling(currentConversationId);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** `conversation_billing`: a chat's billing changed, or what it may switch to. */
|
|
287
|
+
export function applyConversationBilling(payload) {
|
|
288
|
+
const conversationId = String(payload?.conversationId || '').trim();
|
|
289
|
+
if (!conversationId) return;
|
|
290
|
+
if (payload.billing && typeof payload.billing === 'object') {
|
|
291
|
+
billings.set(conversationId, payload.billing);
|
|
292
|
+
if (conversationId === currentConversationId && payload.billing.provider) currentProviderType = payload.billing.provider;
|
|
293
|
+
} else {
|
|
294
|
+
billings.delete(conversationId);
|
|
295
|
+
}
|
|
296
|
+
renderUsageLimitBanner();
|
|
297
|
+
notifyBilling(conversationId);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A switch answered with the chat's billing (the composer's toggle). */
|
|
301
|
+
export function setConversationBilling(conversationId, billing) {
|
|
302
|
+
const id = String(conversationId || '').trim();
|
|
303
|
+
if (!id || !billing || typeof billing !== 'object') return;
|
|
304
|
+
billings.set(id, billing);
|
|
305
|
+
if (id === currentConversationId && billing.provider) currentProviderType = billing.provider;
|
|
306
|
+
renderUsageLimitBanner();
|
|
307
|
+
notifyBilling(id);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** What the relay last said of a chat's billing, or null. */
|
|
311
|
+
export function conversationBillingState(conversationId) {
|
|
312
|
+
return billings.get(String(conversationId || '').trim()) || null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** `listener(conversationId, billing)` on every change; returns the unsubscribe. */
|
|
316
|
+
export function onConversationBillingChange(listener) {
|
|
317
|
+
if (typeof listener !== 'function') return () => {};
|
|
318
|
+
billingListeners.add(listener);
|
|
319
|
+
return () => billingListeners.delete(listener);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A send was refused because the chat's billing is now `provider`: remember it. */
|
|
323
|
+
export function noteConversationBilling(conversationId, provider) {
|
|
324
|
+
const id = String(conversationId || '').trim();
|
|
325
|
+
const billing = String(provider || '').trim().toLowerCase();
|
|
326
|
+
if (!id || (billing !== 'claude' && billing !== 'claude-api')) return;
|
|
327
|
+
const known = billings.get(id) || {};
|
|
328
|
+
// What it may switch to is the relay's to say again; until then, nothing.
|
|
329
|
+
billings.set(id, { ...known, provider: billing, switchable: { to: billing === 'claude' ? 'claude-api' : 'claude', allowed: false, code: null, reason: null } });
|
|
330
|
+
if (id === currentConversationId) currentProviderType = billing;
|
|
331
|
+
renderUsageLimitBanner();
|
|
332
|
+
notifyBilling(id);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The billing the page last heard for a chat: 'claude', 'claude-api', or ''. */
|
|
336
|
+
export function knownConversationBilling(conversationId) {
|
|
337
|
+
const billing = billings.get(String(conversationId || '').trim()) || null;
|
|
338
|
+
return billing?.provider === 'claude' || billing?.provider === 'claude-api' ? billing.provider : '';
|
|
210
339
|
}
|
|
211
340
|
|
|
212
341
|
export function initUsageLimitUi() {
|
|
@@ -216,6 +345,8 @@ export function initUsageLimitUi() {
|
|
|
216
345
|
export function __resetUsageLimitUiForTests() {
|
|
217
346
|
account = null;
|
|
218
347
|
pauses.clear();
|
|
348
|
+
billings.clear();
|
|
349
|
+
billingListeners.clear();
|
|
219
350
|
currentConversationId = '';
|
|
220
351
|
currentProviderType = '';
|
|
221
352
|
actionInFlight = false;
|