@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.
- package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +192 -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 +3 -3
- 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-tools.md +4 -4
- 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 +645 -27
- 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 +327 -29
- package/server/server-runtime.mjs +363 -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 +83 -14
- 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 +29 -9
- 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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The composer's billing toggle for a Claude chat: Subscription | API key.
|
|
2
|
+
// Pure and DOM-free: what the toggle shows, from the chat's provider, what
|
|
3
|
+
// the relay said of its billing (`billing`, GET /api/conversation and the
|
|
4
|
+
// `conversation_billing` socket event) and the Claude API tab's state.
|
|
5
|
+
//
|
|
6
|
+
// A switch applies from the chat's next turn; the worker is swapped between
|
|
7
|
+
// turns, and while it waits the relay's `billing.pending` says for what.
|
|
8
|
+
|
|
9
|
+
export const CLAUDE_BILLING_TOGGLE_OPTIONS = Object.freeze([
|
|
10
|
+
Object.freeze({ value: 'claude', label: 'Subscription' }),
|
|
11
|
+
Object.freeze({ value: 'claude-api', label: 'API key' }),
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export const CLAUDE_BILLING_CACHE_WARNING = 'The first turn on the API key has no prompt cache and costs more.';
|
|
15
|
+
// A subscription chat while Claude API's monthly cap is reached: said on the
|
|
16
|
+
// toggle itself, not only when the unavailable side is pressed.
|
|
17
|
+
export const CLAUDE_BILLING_CAP_TEXT = "This month's Claude API cap is reached: the chat stays on the subscription.";
|
|
18
|
+
|
|
19
|
+
function normalizeBilling(value) {
|
|
20
|
+
const billing = String(value || '').trim().toLowerCase();
|
|
21
|
+
return billing === 'claude' || billing === 'claude-api' ? billing : '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** "the API key" / "the subscription", for running text. */
|
|
25
|
+
export function claudeBillingPlace(billing) {
|
|
26
|
+
return normalizeBilling(billing) === 'claude-api' ? 'the API key' : 'the subscription';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the toggle says while the worker swap waits (`billing.pending`), or ''. */
|
|
30
|
+
export function claudeBillingPendingText(pending) {
|
|
31
|
+
if (!pending || typeof pending !== 'object') return '';
|
|
32
|
+
const place = claudeBillingPlace(pending.to);
|
|
33
|
+
if (pending.swapping === true) return `Restarting on ${place}…`;
|
|
34
|
+
const waitingFor = String(pending.waitingFor || '').trim();
|
|
35
|
+
if (waitingFor === 'turn') return `Moves to ${place} after the running turn.`;
|
|
36
|
+
if (waitingFor === 'question') return `Moves to ${place} once the open question is answered.`;
|
|
37
|
+
if (waitingFor === 'compaction') return `Moves to ${place} after the compaction.`;
|
|
38
|
+
if (waitingFor === 'background') {
|
|
39
|
+
const count = Number(pending.backgroundTaskCount) || 0;
|
|
40
|
+
return count > 0
|
|
41
|
+
? `Moves to ${place} once ${count} background task${count === 1 ? '' : 's'} finish${count === 1 ? 'es' : ''}.`
|
|
42
|
+
: `Moves to ${place} once the background tasks finish.`;
|
|
43
|
+
}
|
|
44
|
+
if (waitingFor === 'stop') return `Moving to ${place}: the old Claude process has not stopped yet, retrying.`;
|
|
45
|
+
return `Moving to ${place}…`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The toggle for one chat:
|
|
50
|
+
* `{ visible, current, options: [{ value, label, pressed, available, reason }],
|
|
51
|
+
* status, statusKind }`.
|
|
52
|
+
*
|
|
53
|
+
* Shown in a Claude SDK chat (either billing) whose relay said its billing,
|
|
54
|
+
* when Claude API is on with a saved key; a chat on the key keeps it after
|
|
55
|
+
* Claude API is switched off, so it can move back. `inFlight` is a switch
|
|
56
|
+
* this page sent and has no answer for yet; `warnCache` the chat this page
|
|
57
|
+
* just moved to the key.
|
|
58
|
+
*/
|
|
59
|
+
export function claudeBillingToggleModel({
|
|
60
|
+
providerType = '',
|
|
61
|
+
billing = null,
|
|
62
|
+
claudeApi = null,
|
|
63
|
+
inFlight = false,
|
|
64
|
+
warnCache = false,
|
|
65
|
+
} = {}) {
|
|
66
|
+
const hidden = { visible: false, current: '', options: [], status: '', statusKind: '' };
|
|
67
|
+
const chatProvider = normalizeBilling(providerType);
|
|
68
|
+
if (!chatProvider || !billing || typeof billing !== 'object') return hidden;
|
|
69
|
+
const current = normalizeBilling(billing.provider) || chatProvider;
|
|
70
|
+
const claudeApiReady = claudeApi?.configured === true && claudeApi?.enabled === true;
|
|
71
|
+
if (current === 'claude' && !claudeApiReady) return hidden;
|
|
72
|
+
const switchable = billing.switchable && typeof billing.switchable === 'object' ? billing.switchable : {};
|
|
73
|
+
const target = normalizeBilling(switchable.to);
|
|
74
|
+
// The chat's billing was read while Claude API was off; the tab has since
|
|
75
|
+
// switched it on. The relay checks the switch again when it is pressed;
|
|
76
|
+
// a refusal it gave this page since (`refused`) stands.
|
|
77
|
+
const allowed = switchable.allowed === true
|
|
78
|
+
|| (target === 'claude-api' && claudeApiReady && switchable.code === 'CLAUDE_API_NOT_CONFIGURED'
|
|
79
|
+
&& switchable.refused !== true);
|
|
80
|
+
const options = CLAUDE_BILLING_TOGGLE_OPTIONS.map((option) => {
|
|
81
|
+
const pressed = option.value === current;
|
|
82
|
+
const available = pressed || (option.value === target && allowed && !inFlight);
|
|
83
|
+
const reason = pressed || available
|
|
84
|
+
? ''
|
|
85
|
+
: (inFlight ? 'A switch is on its way.' : (String(switchable.reason || '').trim() || 'The relay does not allow this switch now.'));
|
|
86
|
+
return { value: option.value, label: option.label, pressed, available, reason };
|
|
87
|
+
});
|
|
88
|
+
let status = '';
|
|
89
|
+
let statusKind = '';
|
|
90
|
+
const pendingText = claudeBillingPendingText(billing.pending);
|
|
91
|
+
if (inFlight) {
|
|
92
|
+
status = 'Switching…';
|
|
93
|
+
statusKind = 'pending';
|
|
94
|
+
} else if (pendingText) {
|
|
95
|
+
status = pendingText;
|
|
96
|
+
statusKind = 'pending';
|
|
97
|
+
} else if (warnCache && current === 'claude-api') {
|
|
98
|
+
status = CLAUDE_BILLING_CACHE_WARNING;
|
|
99
|
+
statusKind = 'warning';
|
|
100
|
+
} else if (current === 'claude' && target === 'claude-api' && !allowed && switchable.code === 'CLAUDE_API_CAP_REACHED') {
|
|
101
|
+
status = CLAUDE_BILLING_CAP_TEXT;
|
|
102
|
+
statusKind = 'warning';
|
|
103
|
+
}
|
|
104
|
+
return { visible: true, current, options, status, statusKind };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The notice after a switch the relay accepted. */
|
|
108
|
+
export function claudeBillingSwitchedText(to) {
|
|
109
|
+
return normalizeBilling(to) === 'claude-api'
|
|
110
|
+
? `This chat runs on the API key from the next turn. ${CLAUDE_BILLING_CACHE_WARNING}`
|
|
111
|
+
: 'This chat runs on the subscription from the next turn.';
|
|
112
|
+
}
|
|
@@ -46,7 +46,9 @@ function claudeTiersForModel(modelId, claudeTiers) {
|
|
|
46
46
|
* windows on a Claude conversation. For the Claude provider the offered tiers
|
|
47
47
|
* are exactly the enabled catalog ids the server folded into
|
|
48
48
|
* claudeContextTiersByModel: base id → default, "[1m]" id → long_context, and a
|
|
49
|
-
* model may legitimately have only one of the two.
|
|
49
|
+
* model may legitimately have only one of the two. Claude API (`claude-api`)
|
|
50
|
+
* serves the same ids on an API key, with its own folded list
|
|
51
|
+
* (claudeApiContextTiersByModel), which the caller passes as `claudeTiers`.
|
|
50
52
|
*/
|
|
51
53
|
export function buildContextTierOptions({
|
|
52
54
|
modelId = '',
|
|
@@ -63,7 +65,9 @@ export function buildContextTierOptions({
|
|
|
63
65
|
if (provider === 'claude-cloud') return [{ value: 'default', label: UNKNOWN_WINDOW_LABEL }];
|
|
64
66
|
// DeepSeek's models have one window, 1M: no tier to choose.
|
|
65
67
|
if (provider === 'deepseek') return [{ value: 'default', label: tokenLabel(DEEPSEEK_CONTEXT_WINDOW_TOKENS) }];
|
|
66
|
-
const claudeTierList = provider === 'claude'
|
|
68
|
+
const claudeTierList = provider === 'claude' || provider === 'claude-api'
|
|
69
|
+
? claudeTiersForModel(modelId, claudeTiers)
|
|
70
|
+
: null;
|
|
67
71
|
if (claudeTierList) {
|
|
68
72
|
return claudeTierList
|
|
69
73
|
.map((tier) => String(tier?.value || '').trim().toLowerCase())
|
|
@@ -352,7 +352,7 @@ ${renderThinkingButtons('display', THINKING_DISPLAY_CHOICES, displayKey)}
|
|
|
352
352
|
|
|
353
353
|
/**
|
|
354
354
|
* The owner's switch for a session an agent started: may its agents start
|
|
355
|
-
* sessions themselves (the
|
|
355
|
+
* sessions themselves (the relay_tool's one-level rule lifted for it)?
|
|
356
356
|
* Only the owner sets it; no agent can.
|
|
357
357
|
*/
|
|
358
358
|
export function renderAgentSessionsControlHtml({ conversationId = '', mayCreateSessions = false } = {}) {
|
|
@@ -27,6 +27,12 @@ export function isConversationUsingDeepSeekProvider(conversation = null) {
|
|
|
27
27
|
return resolveConversationProviderType(conversation) === 'deepseek';
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// A Claude chat on the saved Claude Console key: the same Claude worker as
|
|
31
|
+
// `claude`, on another bill. A chat can switch between the two.
|
|
32
|
+
export function isConversationUsingClaudeApiProvider(conversation = null) {
|
|
33
|
+
return resolveConversationProviderType(conversation) === 'claude-api';
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
// Its own provider, never a flavour of `claude`: the session runs in a sandbox
|
|
31
37
|
// at Anthropic, so nothing that is true of a local Claude worker (modes,
|
|
32
38
|
// effort, steering, a working directory) may be assumed for it.
|
|
@@ -40,6 +46,7 @@ const CLAUDE_CLOUD_MARKER = '☁';
|
|
|
40
46
|
const PROVIDER_NAMES = Object.freeze({
|
|
41
47
|
openai: 'OpenAI',
|
|
42
48
|
claude: 'Claude',
|
|
49
|
+
'claude-api': 'Claude API',
|
|
43
50
|
'claude-cloud': 'Claude Cloud',
|
|
44
51
|
cursor: 'Cursor',
|
|
45
52
|
grok: 'Grok',
|
|
@@ -79,6 +86,9 @@ const SESSION_LOCK_LABELS = {
|
|
|
79
86
|
openai: 'OpenAI',
|
|
80
87
|
'openai-image': 'OpenAI Image',
|
|
81
88
|
claude: 'Claude SDK',
|
|
89
|
+
// The same Claude SDK models as `claude`, named for the bill, as the pill
|
|
90
|
+
// and New Chat name it.
|
|
91
|
+
'claude-api': 'Claude API',
|
|
82
92
|
cursor: 'Cursor SDK',
|
|
83
93
|
grok: 'Grok',
|
|
84
94
|
deepseek: 'DeepSeek',
|
|
@@ -99,6 +109,7 @@ export function sessionLockProviderKey({ providerType = '', model = '' } = {}) {
|
|
|
99
109
|
}
|
|
100
110
|
if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
|
|
101
111
|
if (normalized === 'claude' || normalized === 'claude-agent-sdk' || normalized === 'anthropic') return 'claude';
|
|
112
|
+
if (normalized === 'claude-api') return 'claude-api';
|
|
102
113
|
if (normalized === 'claude-cloud') return 'claude-cloud';
|
|
103
114
|
if (normalized === 'cursor') return 'cursor';
|
|
104
115
|
if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
|
|
@@ -81,6 +81,9 @@ import { stripRemoteRelayDisplayArtifacts } from './relay-prompt-sanitizer.mjs';
|
|
|
81
81
|
import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
|
|
82
82
|
import { attachCodeCopyButtons } from './code-copy.mjs';
|
|
83
83
|
import { relayErrorCtaActions } from './relay-error-ctas.mjs';
|
|
84
|
+
import { switchClaudeBilling } from './claude-billing-toggle-ui.js';
|
|
85
|
+
import { CLAUDE_API_BILLING_URL, CLAUDE_API_LIMITS_URL, focusClaudeApiCapInput } from './claude-api-settings-ui.js';
|
|
86
|
+
import { knownConversationBilling, noteConversationBilling } from './usage-limit-ui.js';
|
|
84
87
|
import { confirmCliInstall } from './cli-install-ui.js';
|
|
85
88
|
import { startGrokSignIn } from './grok-auth-ui.js';
|
|
86
89
|
import { openSettingsModal } from './settings-modal.js';
|
|
@@ -3508,8 +3511,9 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
|
|
|
3508
3511
|
|
|
3509
3512
|
// Each CTA lands the user where the state actually lives: the provider panel
|
|
3510
3513
|
// shows the install log / the device code, so the settings modal is opened
|
|
3511
|
-
// first and the action then runs on top of it.
|
|
3512
|
-
|
|
3514
|
+
// first and the action then runs on top of it. New Chat runs them too, for a
|
|
3515
|
+
// refusal of its own (journal-view.js).
|
|
3516
|
+
export function runRelayErrorCta(cta) {
|
|
3513
3517
|
if (cta === 'install-grok-cli') {
|
|
3514
3518
|
openSettingsModal('providers', 'grok');
|
|
3515
3519
|
// The confirm sheet — never a bare curl | bash from a chat bubble.
|
|
@@ -3525,6 +3529,15 @@ function runRelayErrorCta(cta) {
|
|
|
3525
3529
|
if (cta === 'open-claude-settings') openSettingsModal('providers', 'claude');
|
|
3526
3530
|
if (cta === 'open-deepseek-settings') openSettingsModal('providers', 'deepseek');
|
|
3527
3531
|
if (cta === 'open-deepseek-top-up') window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener');
|
|
3532
|
+
if (cta === 'open-claude-api-settings') openSettingsModal('providers', 'claude-api');
|
|
3533
|
+
if (cta === 'open-claude-api-cap') {
|
|
3534
|
+
openSettingsModal('providers', 'claude-api');
|
|
3535
|
+
focusClaudeApiCapInput();
|
|
3536
|
+
}
|
|
3537
|
+
if (cta === 'open-claude-api-billing') window.open(CLAUDE_API_BILLING_URL, '_blank', 'noopener');
|
|
3538
|
+
if (cta === 'open-claude-api-limits') window.open(CLAUDE_API_LIMITS_URL, '_blank', 'noopener');
|
|
3539
|
+
// The chat this note is in, not a chat opened since: the bubble is on screen.
|
|
3540
|
+
if (cta === 'switch-claude-billing-to-subscription' && currentConvId) void switchClaudeBilling(currentConvId, 'claude');
|
|
3528
3541
|
}
|
|
3529
3542
|
|
|
3530
3543
|
function handleBubbleActionClick(event) {
|
|
@@ -4615,6 +4628,10 @@ export async function sendMessage() {
|
|
|
4615
4628
|
relayMode: selectedMode,
|
|
4616
4629
|
conversationId: targetConversationId || undefined,
|
|
4617
4630
|
newConversation: isNew || undefined,
|
|
4631
|
+
// The billing this page shows for a Claude chat: the relay refuses the
|
|
4632
|
+
// send when the chat has moved to the other one since, so nobody's
|
|
4633
|
+
// turn runs on a bill they did not see.
|
|
4634
|
+
billing: (!isNew && targetConversationId && knownConversationBilling(targetConversationId)) || undefined,
|
|
4618
4635
|
attachments,
|
|
4619
4636
|
imageTarget: draftImageEditTarget
|
|
4620
4637
|
? {
|
|
@@ -4628,7 +4645,16 @@ export async function sendMessage() {
|
|
|
4628
4645
|
// A refusal the user can act on (the conversation's model left the
|
|
4629
4646
|
// catalog) is shown as the relay words it.
|
|
4630
4647
|
let refusal = '';
|
|
4631
|
-
const r = await sendMessageApi(body, {
|
|
4648
|
+
const r = await sendMessageApi(body, {
|
|
4649
|
+
onRefused: (reason, payload) => {
|
|
4650
|
+
refusal = reason;
|
|
4651
|
+
// The chat's billing changed under this page: it learns the new one,
|
|
4652
|
+
// so sending again goes through.
|
|
4653
|
+
if (payload?.code === 'CLAUDE_BILLING_CHANGED' && targetConversationId) {
|
|
4654
|
+
noteConversationBilling(targetConversationId, payload.billing);
|
|
4655
|
+
}
|
|
4656
|
+
},
|
|
4657
|
+
});
|
|
4632
4658
|
if (!r) {
|
|
4633
4659
|
// Offline: park the send in the durable outbox instead of bouncing it
|
|
4634
4660
|
// back into the composer. The client-generated messageId makes a replay
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The Inbox, as DOM: every pending question across this relay and its paired
|
|
2
2
|
// relays (GET /api/inbox), grouped by relay, newest first; what was answered
|
|
3
3
|
// or dismissed in the last day under a divider. And the card a question that
|
|
4
|
-
// does not block (
|
|
4
|
+
// does not block (relay_tool `ask`) shows in its own session.
|
|
5
5
|
//
|
|
6
6
|
// Every text here is written by an agent, some on other relays: it goes into
|
|
7
7
|
// the page through textContent only, never as HTML or a link. Buttons carry
|
|
@@ -73,13 +73,15 @@ import {
|
|
|
73
73
|
loadClaudeCloudRepos,
|
|
74
74
|
loadGrokSettings,
|
|
75
75
|
loadDeepSeekSettings,
|
|
76
|
+
loadClaudeApiSettings,
|
|
76
77
|
loadOpenAISettings,
|
|
77
78
|
} from './api-client.js';
|
|
78
79
|
import { isSetupRequired, updateProviderReadinessInputs } from './provider-readiness.mjs';
|
|
79
80
|
import { SETUP_TEXT } from './no-conversation-state.mjs';
|
|
80
81
|
import { openSettingsModal } from './settings-modal.js';
|
|
82
|
+
import { START_ON_SUBSCRIPTION_ACTION, newChatClaudeApiRefusalActions } from './relay-error-ctas.mjs';
|
|
81
83
|
import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
|
|
82
|
-
import { renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, getTranscriptJumpEpoch, getTranscriptLiveRowEpoch, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn, captureConversationReadingAnchor, restoreConversationReadingAnchor, detachConversationWindowFromTail, mergeMessagesIntoTranscript, isConversationWindowAtTail } from './conversation-view.js';
|
|
84
|
+
import { runRelayErrorCta, renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, getTranscriptJumpEpoch, getTranscriptLiveRowEpoch, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn, captureConversationReadingAnchor, restoreConversationReadingAnchor, detachConversationWindowFromTail, mergeMessagesIntoTranscript, isConversationWindowAtTail } from './conversation-view.js';
|
|
83
85
|
import { mergeTailPage } from './transcript-reading-anchor.mjs';
|
|
84
86
|
import { readWindowRetention } from './conversation-window-retention.mjs';
|
|
85
87
|
import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
|
|
@@ -105,6 +107,7 @@ import { createInfiniteLoader } from './infinite-loader.js';
|
|
|
105
107
|
import {
|
|
106
108
|
buildNewConversationModelChoices,
|
|
107
109
|
newConversationContextTierState,
|
|
110
|
+
preselectNewConversationModel,
|
|
108
111
|
reasoningChoicesForProviderModel,
|
|
109
112
|
resolvePreferredReasoningEffort,
|
|
110
113
|
} from './new-conversation-model-choice.mjs';
|
|
@@ -191,6 +194,7 @@ let newConversationClaudeSettingsCache = null;
|
|
|
191
194
|
let newConversationCursorSettingsCache = null;
|
|
192
195
|
let newConversationGrokSettingsCache = null;
|
|
193
196
|
let newConversationDeepSeekSettingsCache = null;
|
|
197
|
+
let newConversationClaudeApiSettingsCache = null;
|
|
194
198
|
let newConversationClaudeCloudSettingsCache = null;
|
|
195
199
|
// Claude Cloud fields of the New Chat modal: what the selected folder's git
|
|
196
200
|
// remote said, and what was written into Repository/Branch from it (so a later
|
|
@@ -700,7 +704,7 @@ function paintConvList() {
|
|
|
700
704
|
: renderConversationOriginMarkerHtml(c, { conversations });
|
|
701
705
|
// "watched": a session another conversation watches by id (relay events).
|
|
702
706
|
const watchedMarkerHtml = relayEventWatchedMarkerHtml(c.id);
|
|
703
|
-
// "scheduled": a schedule fires into this session (
|
|
707
|
+
// "scheduled": a schedule fires into this session (relay_tool schedule).
|
|
704
708
|
const scheduledMarkerHtml = relayScheduleMarkerHtml(c.id);
|
|
705
709
|
const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
|
|
706
710
|
const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
|
|
@@ -926,6 +930,8 @@ export function applyLoadedConversationState(id, response, {
|
|
|
926
930
|
// Absent from an older relay's payload: keep what the socket said.
|
|
927
931
|
pause: 'usageLimitPause' in response ? response.usageLimitPause : undefined,
|
|
928
932
|
providerType: response.runtimeSession?.providerType,
|
|
933
|
+
// A Claude chat's billing and whether it may switch (an older relay: absent).
|
|
934
|
+
billing: 'billing' in response ? response.billing : undefined,
|
|
929
935
|
});
|
|
930
936
|
// Merge, not replace: this payload only carries one conversation's previews.
|
|
931
937
|
mergeConversationPreviews(id, response.previews || []);
|
|
@@ -1236,6 +1242,7 @@ function normalizeNewConversationProviderType(value = '') {
|
|
|
1236
1242
|
if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
|
|
1237
1243
|
if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
|
|
1238
1244
|
if (normalized === 'claude') return 'claude';
|
|
1245
|
+
if (normalized === 'claude-api') return 'claude-api';
|
|
1239
1246
|
if (normalized === CLAUDE_CLOUD_PROVIDER) return CLAUDE_CLOUD_PROVIDER;
|
|
1240
1247
|
if (normalized === 'cursor') return 'cursor';
|
|
1241
1248
|
if (normalized === 'grok') return 'grok';
|
|
@@ -1278,6 +1285,7 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
|
|
|
1278
1285
|
const hasCursor = providers.includes('cursor');
|
|
1279
1286
|
const hasGrok = providers.includes('grok');
|
|
1280
1287
|
const hasDeepSeek = providers.includes('deepseek');
|
|
1288
|
+
const hasClaudeApi = providers.includes('claude-api');
|
|
1281
1289
|
if (wantsOpenAI) {
|
|
1282
1290
|
if (hasOpenAIByok) return true;
|
|
1283
1291
|
const settingsModel = String(newConversationOpenAISettingsCache?.model || '').trim();
|
|
@@ -1321,7 +1329,16 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
|
|
|
1321
1329
|
: [];
|
|
1322
1330
|
return normalizedModelId === deepseekModel || deepseekModels.includes(normalizedModelId);
|
|
1323
1331
|
}
|
|
1324
|
-
|
|
1332
|
+
if (normalizedProvider === 'claude-api') {
|
|
1333
|
+
if (hasClaudeApi) return true;
|
|
1334
|
+
const claudeApiModel = String(newConversationClaudeApiSettingsCache?.model || '').trim();
|
|
1335
|
+
const claudeApiModels = Array.isArray(newConversationClaudeApiSettingsCache?.models)
|
|
1336
|
+
? newConversationClaudeApiSettingsCache.models.map((entry) => String(entry || '').trim()).filter(Boolean)
|
|
1337
|
+
: [];
|
|
1338
|
+
return normalizedModelId === claudeApiModel || claudeApiModels.includes(normalizedModelId);
|
|
1339
|
+
}
|
|
1340
|
+
// Claude API serves the Claude provider's ids: still none of Copilot's.
|
|
1341
|
+
const claudeOnly = hasClaude && providers.every((provider) => provider === 'claude' || provider === 'claude-api');
|
|
1325
1342
|
if (claudeOnly) return false;
|
|
1326
1343
|
const cursorOnly = hasCursor && providers.every((provider) => provider === 'cursor');
|
|
1327
1344
|
if (cursorOnly) return false;
|
|
@@ -1332,7 +1349,8 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
|
|
|
1332
1349
|
// Should a relay ever tag catalog rows for Claude Cloud, they are no more
|
|
1333
1350
|
// Copilot's than the other runtimes' rows are.
|
|
1334
1351
|
const exclusiveOnly = providers.length > 0 && providers.every((provider) => (
|
|
1335
|
-
provider === 'claude' || provider === 'cursor' || provider === 'grok' || provider === 'deepseek'
|
|
1352
|
+
provider === 'claude' || provider === 'cursor' || provider === 'grok' || provider === 'deepseek'
|
|
1353
|
+
|| provider === 'claude-api' || provider === CLAUDE_CLOUD_PROVIDER
|
|
1336
1354
|
));
|
|
1337
1355
|
if (exclusiveOnly) return false;
|
|
1338
1356
|
return providers.some((provider) => (
|
|
@@ -1341,6 +1359,7 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
|
|
|
1341
1359
|
&& provider !== 'cursor'
|
|
1342
1360
|
&& provider !== 'grok'
|
|
1343
1361
|
&& provider !== 'deepseek'
|
|
1362
|
+
&& provider !== 'claude-api'
|
|
1344
1363
|
&& provider !== CLAUDE_CLOUD_PROVIDER
|
|
1345
1364
|
)) || !hasOpenAIByok;
|
|
1346
1365
|
}
|
|
@@ -1492,6 +1511,10 @@ function updateNewConversationProviderHelp(provider = 'github') {
|
|
|
1492
1511
|
help.textContent = "Claude chats run through the Claude Agent SDK with the relay host's Claude login.";
|
|
1493
1512
|
return;
|
|
1494
1513
|
}
|
|
1514
|
+
if (normalizedProvider === 'claude-api') {
|
|
1515
|
+
help.textContent = 'Claude API chats run through the Claude Agent SDK on the API key saved in Settings, billed to its Claude Console organisation. The chat can switch to the subscription later.';
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1495
1518
|
if (normalizedProvider === CLAUDE_CLOUD_PROVIDER) {
|
|
1496
1519
|
help.textContent = "Claude Cloud chats run in a sandbox at Anthropic on a clone of a GitHub repository, billed to the relay host's Claude account.";
|
|
1497
1520
|
return;
|
|
@@ -1537,7 +1560,7 @@ async function populateNewConversationModelSelect(providerType = 'github') {
|
|
|
1537
1560
|
buildCatalogModelOptions(eligibleModels, {
|
|
1538
1561
|
metadataByModel: catalog.modelMetadataByModel || {},
|
|
1539
1562
|
// The merged metadata knows Copilot's windows, not Claude's or the cloud's.
|
|
1540
|
-
annotateContextWindow: normalizedProvider !== 'claude' && !isCloud,
|
|
1563
|
+
annotateContextWindow: normalizedProvider !== 'claude' && normalizedProvider !== 'claude-api' && !isCloud,
|
|
1541
1564
|
}).filter((option) => hasAuto || option.value.toLowerCase() !== 'auto'),
|
|
1542
1565
|
);
|
|
1543
1566
|
for (const choice of choices) {
|
|
@@ -1555,10 +1578,13 @@ async function populateNewConversationModelSelect(providerType = 'github') {
|
|
|
1555
1578
|
// The tab's default, not the last model another provider's chat used.
|
|
1556
1579
|
const cloudDefault = claudeCloudDefaultModel(newConversationClaudeCloudSettingsCache, eligibleModels);
|
|
1557
1580
|
if (cloudDefault) target.value = cloudDefault;
|
|
1558
|
-
} else
|
|
1559
|
-
target.value
|
|
1560
|
-
|
|
1561
|
-
|
|
1581
|
+
} else {
|
|
1582
|
+
const preselected = preselectNewConversationModel(Array.from(target.options, (option) => option.value), {
|
|
1583
|
+
storedModel,
|
|
1584
|
+
providerDefault: normalizedProvider === 'claude-api' ? newConversationClaudeApiSettingsCache?.model : '',
|
|
1585
|
+
allowAuto: normalizedProvider !== 'openai-image',
|
|
1586
|
+
});
|
|
1587
|
+
if (preselected) target.value = preselected;
|
|
1562
1588
|
}
|
|
1563
1589
|
updateNewConversationProviderHelp(normalizedProvider);
|
|
1564
1590
|
syncNewConversationReasoningLabel(normalizedProvider);
|
|
@@ -2115,7 +2141,7 @@ function applyNewConversationSetupState(setupRequired) {
|
|
|
2115
2141
|
}
|
|
2116
2142
|
|
|
2117
2143
|
async function openNewConversationModelModal() {
|
|
2118
|
-
const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings, deepseekSettings] = await Promise.all([
|
|
2144
|
+
const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings, deepseekSettings, claudeApiSettings] = await Promise.all([
|
|
2119
2145
|
loadModelCatalog(),
|
|
2120
2146
|
loadOpenAISettings(),
|
|
2121
2147
|
loadClaudeSettings(),
|
|
@@ -2125,6 +2151,8 @@ async function openNewConversationModelModal() {
|
|
|
2125
2151
|
loadClaudeCloudSettings(),
|
|
2126
2152
|
// The same for a relay without DeepSeek.
|
|
2127
2153
|
loadDeepSeekSettings(),
|
|
2154
|
+
// And without Claude API.
|
|
2155
|
+
loadClaudeApiSettings(),
|
|
2128
2156
|
]);
|
|
2129
2157
|
newConversationCatalogCache = catalog || null;
|
|
2130
2158
|
newConversationOpenAISettingsCache = settings || null;
|
|
@@ -2133,6 +2161,7 @@ async function openNewConversationModelModal() {
|
|
|
2133
2161
|
newConversationGrokSettingsCache = grokSettings || null;
|
|
2134
2162
|
newConversationClaudeCloudSettingsCache = claudeCloudSettings || null;
|
|
2135
2163
|
newConversationDeepSeekSettingsCache = deepseekSettings || null;
|
|
2164
|
+
newConversationClaudeApiSettingsCache = claudeApiSettings || null;
|
|
2136
2165
|
// Fresh answers for the readiness question, shared with the empty screen
|
|
2137
2166
|
// and the composer. With no provider known to be ready the modal opens in
|
|
2138
2167
|
// its setup state: Settings is the main button, Start stays as "anyway".
|
|
@@ -2143,11 +2172,13 @@ async function openNewConversationModelModal() {
|
|
|
2143
2172
|
cursor: cursorSettings || null,
|
|
2144
2173
|
grok: grokSettings || null,
|
|
2145
2174
|
deepseek: deepseekSettings || null,
|
|
2175
|
+
claudeApi: claudeApiSettings || null,
|
|
2146
2176
|
catalog: catalog || null,
|
|
2147
2177
|
});
|
|
2148
2178
|
applyNewConversationSetupState(isSetupRequired(readiness));
|
|
2149
2179
|
// Every open starts from empty cloud fields; the folder lookup refills them.
|
|
2150
2180
|
resetNewConversationCloudState();
|
|
2181
|
+
clearNewConversationClaudeApiRefusal();
|
|
2151
2182
|
bindNewConversationCloudInputs();
|
|
2152
2183
|
const providerSelect = document.getElementById('new-conversation-provider-select');
|
|
2153
2184
|
if (providerSelect) {
|
|
@@ -2159,6 +2190,9 @@ async function openNewConversationModelModal() {
|
|
|
2159
2190
|
if (claudeSettings?.enabled === true) {
|
|
2160
2191
|
options.push({ value: 'claude', label: 'Claude SDK' });
|
|
2161
2192
|
}
|
|
2193
|
+
if (claudeApiSettings?.enabled === true) {
|
|
2194
|
+
options.push({ value: 'claude-api', label: 'Claude API' });
|
|
2195
|
+
}
|
|
2162
2196
|
if (claudeCloudSettings?.enabled === true) {
|
|
2163
2197
|
options.push({ value: CLAUDE_CLOUD_PROVIDER, label: 'Claude Cloud' });
|
|
2164
2198
|
}
|
|
@@ -2188,6 +2222,7 @@ async function openNewConversationModelModal() {
|
|
|
2188
2222
|
if (providerSelect.dataset.modelsBound !== '1') {
|
|
2189
2223
|
providerSelect.dataset.modelsBound = '1';
|
|
2190
2224
|
providerSelect.addEventListener('change', () => {
|
|
2225
|
+
clearNewConversationClaudeApiRefusal();
|
|
2191
2226
|
void populateNewConversationModelSelect(providerSelect.value);
|
|
2192
2227
|
});
|
|
2193
2228
|
}
|
|
@@ -2376,6 +2411,18 @@ async function createNewConversation(selectedModel, selectedReasoningEffort = ''
|
|
|
2376
2411
|
showNewConversationCloudError(cloudError.text, cloudError.field ? [cloudError.field] : []);
|
|
2377
2412
|
return;
|
|
2378
2413
|
}
|
|
2414
|
+
// Claude API refused to start (this month's cap, no key, switched off):
|
|
2415
|
+
// nothing was bound. The relay's words stay in the modal, with the way
|
|
2416
|
+
// to fix it and the same chat on the subscription.
|
|
2417
|
+
const claudeApiActions = !recovered && selectedProvider === 'claude-api'
|
|
2418
|
+
? newChatClaudeApiRefusalActions(error?.payload?.code, {
|
|
2419
|
+
subscriptionOffered: newConversationProviderOffered('claude'),
|
|
2420
|
+
})
|
|
2421
|
+
: [];
|
|
2422
|
+
if (claudeApiActions.length) {
|
|
2423
|
+
showNewConversationClaudeApiRefusal(error?.message || 'Claude API could not start this chat.', claudeApiActions, selectedModel);
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2379
2426
|
showTransientRelayNotice(recovered
|
|
2380
2427
|
// The chat is usable, but its worker did not start, so say what broke.
|
|
2381
2428
|
? `The chat was created, but its session could not start: ${error?.message || 'unknown error'}.`
|
|
@@ -2387,8 +2434,78 @@ async function createNewConversation(selectedModel, selectedReasoningEffort = ''
|
|
|
2387
2434
|
}
|
|
2388
2435
|
}
|
|
2389
2436
|
|
|
2437
|
+
function newConversationProviderOffered(provider) {
|
|
2438
|
+
return Array.from(document.getElementById('new-conversation-provider-select')?.options || [])
|
|
2439
|
+
.some((option) => option.value === provider);
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function clearNewConversationClaudeApiRefusal() {
|
|
2443
|
+
const note = document.getElementById('new-conversation-refusal');
|
|
2444
|
+
if (!note) return;
|
|
2445
|
+
note.replaceChildren();
|
|
2446
|
+
note.hidden = true;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// The same chat, started on the Claude provider: its model when the Claude
|
|
2450
|
+
// list has it, the choices the modal holds otherwise.
|
|
2451
|
+
async function startNewConversationOnSubscription(model) {
|
|
2452
|
+
const providerSelect = document.getElementById('new-conversation-provider-select');
|
|
2453
|
+
if (!providerSelect || newConversationInFlight) return;
|
|
2454
|
+
clearNewConversationClaudeApiRefusal();
|
|
2455
|
+
// The effort chosen for the refused chat: rebuilding the lists for Claude
|
|
2456
|
+
// would fall back to the stored one.
|
|
2457
|
+
const effort = String(document.getElementById('new-conversation-reasoning-select')?.value || '').trim().toLowerCase();
|
|
2458
|
+
providerSelect.value = 'claude';
|
|
2459
|
+
await populateNewConversationModelSelect('claude');
|
|
2460
|
+
const modelSelect = document.getElementById('new-conversation-model-select');
|
|
2461
|
+
if (modelSelect && Array.from(modelSelect.options).some((option) => option.value === model)) {
|
|
2462
|
+
modelSelect.value = model;
|
|
2463
|
+
await populateNewConversationReasoningSelect(model);
|
|
2464
|
+
}
|
|
2465
|
+
const reasoningSelect = document.getElementById('new-conversation-reasoning-select');
|
|
2466
|
+
if (effort && reasoningSelect && Array.from(reasoningSelect.options).some((option) => option.value === effort)) {
|
|
2467
|
+
reasoningSelect.value = effort;
|
|
2468
|
+
}
|
|
2469
|
+
await confirmNewConversationModel();
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
function showNewConversationClaudeApiRefusal(message, actions, model) {
|
|
2473
|
+
const note = document.getElementById('new-conversation-refusal');
|
|
2474
|
+
if (!note) {
|
|
2475
|
+
showTransientRelayNotice(message, 12000);
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
const text = document.createElement('div');
|
|
2479
|
+
text.className = 'new-conversation-refusal-text';
|
|
2480
|
+
text.textContent = message;
|
|
2481
|
+
const buttons = document.createElement('div');
|
|
2482
|
+
buttons.className = 'new-conversation-refusal-actions';
|
|
2483
|
+
for (const item of actions) {
|
|
2484
|
+
const button = document.createElement('button');
|
|
2485
|
+
button.type = 'button';
|
|
2486
|
+
button.className = 'summary-btn';
|
|
2487
|
+
button.dataset.action = item.action;
|
|
2488
|
+
button.textContent = item.label;
|
|
2489
|
+
button.addEventListener('click', (event) => {
|
|
2490
|
+
event.preventDefault();
|
|
2491
|
+
if (item.action === START_ON_SUBSCRIPTION_ACTION) {
|
|
2492
|
+
void startNewConversationOnSubscription(model);
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
closeNewConversationModelModal();
|
|
2496
|
+
runRelayErrorCta(item.action);
|
|
2497
|
+
});
|
|
2498
|
+
buttons.appendChild(button);
|
|
2499
|
+
}
|
|
2500
|
+
note.replaceChildren(text, buttons);
|
|
2501
|
+
note.hidden = false;
|
|
2502
|
+
// The modal's body scrolls: on a phone the note lands below the fold.
|
|
2503
|
+
note.scrollIntoView?.({ block: 'nearest' });
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2390
2506
|
export async function confirmNewConversationModel() {
|
|
2391
2507
|
if (newConversationInFlight) return;
|
|
2508
|
+
clearNewConversationClaudeApiRefusal();
|
|
2392
2509
|
const selectedModel = String(document.getElementById('new-conversation-model-select')?.value || '').trim();
|
|
2393
2510
|
const selectedReasoningEffort = String(document.getElementById('new-conversation-reasoning-select')?.value || '').trim().toLowerCase();
|
|
2394
2511
|
if (!selectedModel) return;
|
|
@@ -12,7 +12,7 @@ export function composerPlaceholderFor({ modelId = '', providerType = '' } = {})
|
|
|
12
12
|
const provider = String(providerType || '').trim().toLowerCase();
|
|
13
13
|
const id = String(modelId || '').trim().toLowerCase().replace(/\[[^\]]*\]$/, '');
|
|
14
14
|
const providerFallback = () => {
|
|
15
|
-
if (provider === 'claude' || provider === 'claude-cloud') return 'Message Claude…';
|
|
15
|
+
if (provider === 'claude' || provider === 'claude-api' || provider === 'claude-cloud') return 'Message Claude…';
|
|
16
16
|
if (provider === 'grok') return 'Message Grok…';
|
|
17
17
|
if (provider === 'openai' || provider === 'openai-byok') return 'Message OpenAI…';
|
|
18
18
|
if (provider === 'cursor') return 'Message Cursor…';
|
|
@@ -195,6 +195,22 @@ export function normalizeModelSelectorOptions(models = [], {
|
|
|
195
195
|
});
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* `humanizeModelLabel` for a list of ids, where two ids that would read the
|
|
200
|
+
* same (two dated snapshots of one model) keep their ids instead: a Map of
|
|
201
|
+
* id → label.
|
|
202
|
+
*/
|
|
203
|
+
export function distinctModelLabels(modelIds = []) {
|
|
204
|
+
const ids = (Array.isArray(modelIds) ? modelIds : []).map((id) => String(id || '').trim()).filter(Boolean);
|
|
205
|
+
const labels = new Map(ids.map((id) => [id, humanizeModelLabel(id) || id]));
|
|
206
|
+
const counts = new Map();
|
|
207
|
+
for (const label of labels.values()) counts.set(label, (counts.get(label) || 0) + 1);
|
|
208
|
+
for (const [id, label] of labels) {
|
|
209
|
+
if (counts.get(label) > 1) labels.set(id, id);
|
|
210
|
+
}
|
|
211
|
+
return labels;
|
|
212
|
+
}
|
|
213
|
+
|
|
198
214
|
/**
|
|
199
215
|
* The one option list both pickers render (composer #model-select and the
|
|
200
216
|
* New Chat modal), so they cannot disagree on order or wording. Callers filter
|
|
@@ -25,6 +25,27 @@ export function buildNewConversationModelChoices(options = []) {
|
|
|
25
25
|
return choices;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The model New Chat starts on, from the values its list offers: the
|
|
30
|
+
* composer's last model when the list has it, else the provider's own
|
|
31
|
+
* default (Claude API's settings model; the list is sorted by name, so its
|
|
32
|
+
* first entry is no default), else Auto when allowed, else ''. '' leaves the
|
|
33
|
+
* list's first entry.
|
|
34
|
+
*/
|
|
35
|
+
export function preselectNewConversationModel(values = [], {
|
|
36
|
+
storedModel = '',
|
|
37
|
+
providerDefault = '',
|
|
38
|
+
allowAuto = true,
|
|
39
|
+
} = {}) {
|
|
40
|
+
const offered = new Set((Array.isArray(values) ? values : []).map((value) => String(value || '').trim()).filter(Boolean));
|
|
41
|
+
const stored = String(storedModel || '').trim();
|
|
42
|
+
if (stored && offered.has(stored)) return stored;
|
|
43
|
+
const fallback = String(providerDefault || '').trim();
|
|
44
|
+
if (fallback && offered.has(fallback)) return fallback;
|
|
45
|
+
if (allowAuto && offered.has('auto')) return 'auto';
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
function normalizeProviderKey(provider = '') {
|
|
29
50
|
const key = String(provider || '').trim().toLowerCase();
|
|
30
51
|
if (key === 'openai-byok') return 'openai';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Which providers are known to be ready to run a chat, from what the client
|
|
2
2
|
// already loads: the provider settings payloads and the model catalog. No DOM.
|
|
3
3
|
//
|
|
4
|
-
// OpenAI, Claude, Claude Cloud, Cursor, Grok and DeepSeek say so
|
|
5
|
-
// settings carry `enabled`. Copilot has no such flag and the relay never asks
|
|
4
|
+
// OpenAI, Claude, Claude API, Claude Cloud, Cursor, Grok and DeepSeek say so
|
|
5
|
+
// themselves: their settings carry `enabled`. Copilot has no such flag and the relay never asks
|
|
6
6
|
// whether the Copilot CLI is signed in, so the best signal is indirect: has a
|
|
7
7
|
// real Copilot model list ever reached the catalog? A fresh database is seeded
|
|
8
8
|
// with a curated list under the source `bootstrap-seed`; a server discovery,
|
|
@@ -20,6 +20,7 @@ export const SEED_CATALOG_SOURCE = 'bootstrap-seed';
|
|
|
20
20
|
const SETTINGS_PROVIDERS = Object.freeze([
|
|
21
21
|
['openai', 'openai'],
|
|
22
22
|
['claude', 'claude'],
|
|
23
|
+
['claudeApi', 'claude-api'],
|
|
23
24
|
['claudeCloud', 'claude-cloud'],
|
|
24
25
|
['cursor', 'cursor'],
|
|
25
26
|
['grok', 'grok'],
|
|
@@ -33,6 +34,7 @@ const REQUIRED_INPUTS = Object.freeze(['openai', 'claude', 'cursor', 'grok', 'ca
|
|
|
33
34
|
* @param {object} input the settings payloads New Chat loads, plus GET /api/models
|
|
34
35
|
* @param {object|null} [input.openai]
|
|
35
36
|
* @param {object|null} [input.claude]
|
|
37
|
+
* @param {object|null} [input.claudeApi] null on a relay without Claude API: not offered, not unknown
|
|
36
38
|
* @param {object|null} [input.claudeCloud] null on a relay without Claude Cloud: not offered, not unknown
|
|
37
39
|
* @param {object|null} [input.cursor]
|
|
38
40
|
* @param {object|null} [input.grok]
|
|
@@ -68,6 +70,7 @@ export function isSetupRequired(readiness) {
|
|
|
68
70
|
const inputs = {
|
|
69
71
|
openai: undefined,
|
|
70
72
|
claude: undefined,
|
|
73
|
+
claudeApi: undefined,
|
|
71
74
|
claudeCloud: undefined,
|
|
72
75
|
cursor: undefined,
|
|
73
76
|
grok: undefined,
|