@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
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
REMOTE_RELAY_LOCAL_ALIAS,
|
|
15
15
|
REMOTE_RELAY_LOCAL_ID,
|
|
16
16
|
REMOTE_RELAY_PROVIDERS,
|
|
17
|
+
REMOTE_RELAY_SEND_PROVIDERS,
|
|
17
18
|
REMOTE_RELAY_SESSION_SETTINGS,
|
|
18
19
|
formatSessionGroupList,
|
|
19
20
|
isRemoteRelayApprovalQuestion,
|
|
@@ -45,7 +46,7 @@ import {
|
|
|
45
46
|
import { RELAY_ASK_KIND, relayQuestionKind } from '../../shared/relay-asks.mjs';
|
|
46
47
|
import { RELAY_ASK_REMOTE_PATHS, isMissingRelayAskRoute, relayWithoutAsksText } from './relay-ask-link-service.mjs';
|
|
47
48
|
|
|
48
|
-
// The one place a `
|
|
49
|
+
// The one place a `relay_tool` tool call is decided and carried out (plan
|
|
49
50
|
// §5.6). Every provider adapter ends up at POST /api/remote-relays/tool, whose
|
|
50
51
|
// handler calls dispatch(). In order:
|
|
51
52
|
//
|
|
@@ -114,7 +115,7 @@ const TITLE_MAX = 80;
|
|
|
114
115
|
|
|
115
116
|
// The provider settings routes a relay serves; github (Copilot) has none with
|
|
116
117
|
// models — its catalogue is /api/models plus /api/status defaultModel.
|
|
117
|
-
const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai', 'claude-cloud', 'deepseek']);
|
|
118
|
+
const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai', 'claude-cloud', 'deepseek', 'claude-api']);
|
|
118
119
|
// A Claude Cloud session clones a GitHub repository instead of opening a
|
|
119
120
|
// folder: create_session takes `repo` and `branch` for it.
|
|
120
121
|
const CLOUD_PROVIDER = REMOTE_RELAY_CLOUD_PROVIDER;
|
|
@@ -141,6 +142,7 @@ const CATALOG_PROVIDER_LABELS = Object.freeze({
|
|
|
141
142
|
grok: 'grok',
|
|
142
143
|
'claude-cloud': 'claude-cloud',
|
|
143
144
|
deepseek: 'deepseek',
|
|
145
|
+
'claude-api': 'claude-api',
|
|
144
146
|
});
|
|
145
147
|
// Worker statuses that mean "working on something right now" (scope active).
|
|
146
148
|
const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
|
|
@@ -1926,7 +1928,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1926
1928
|
* When the replay fails too, nobody knows; the error carries the session
|
|
1927
1929
|
* and message id so the agent checks with wait instead of sending again.
|
|
1928
1930
|
*/
|
|
1929
|
-
async function postPrompt(ctx, { session, text, origin, model, mode, effort }) {
|
|
1931
|
+
async function postPrompt(ctx, { session, text, origin, model, mode, effort, billing = '' }) {
|
|
1930
1932
|
const messageId = String(randomUUID());
|
|
1931
1933
|
const body = {
|
|
1932
1934
|
conversationId: session,
|
|
@@ -1937,13 +1939,18 @@ export function createRemoteRelayDispatcher({
|
|
|
1937
1939
|
...(mode ? { relayMode: mode } : {}),
|
|
1938
1940
|
...(effort ? { reasoningEffort: effort } : {}),
|
|
1939
1941
|
};
|
|
1942
|
+
// A Claude session moved to another billing with this message: one call,
|
|
1943
|
+
// which switches and enqueues together on the target, or does neither.
|
|
1944
|
+
const post = () => (billing
|
|
1945
|
+
? postBillingSwitch(ctx, session, { to: billing, by: 'agent', message: body })
|
|
1946
|
+
: call(ctx, 'POST', '/api/message', { body }));
|
|
1940
1947
|
let sent;
|
|
1941
1948
|
try {
|
|
1942
|
-
sent = await
|
|
1949
|
+
sent = await post();
|
|
1943
1950
|
} catch (error) {
|
|
1944
1951
|
if (!isTransientRemoteError(error)) throw error;
|
|
1945
1952
|
try {
|
|
1946
|
-
sent = await
|
|
1953
|
+
sent = await post();
|
|
1947
1954
|
} catch (retryError) {
|
|
1948
1955
|
if (isOwnDuplicateMessage(retryError, messageId)) return { duplicate: false, messageId };
|
|
1949
1956
|
if (!isTransientRemoteError(retryError)) throw retryError;
|
|
@@ -1969,6 +1976,36 @@ export function createRemoteRelayDispatcher({
|
|
|
1969
1976
|
};
|
|
1970
1977
|
}
|
|
1971
1978
|
|
|
1979
|
+
/**
|
|
1980
|
+
* POST /api/conversation/:id/billing with a message. A relay older than the
|
|
1981
|
+
* switch answers 404 without a code: nothing was switched or sent. Its
|
|
1982
|
+
* refusals (the billing not set up there, the model, the cap, the
|
|
1983
|
+
* history) keep the remote's words.
|
|
1984
|
+
*/
|
|
1985
|
+
async function postBillingSwitch(ctx, session, body) {
|
|
1986
|
+
try {
|
|
1987
|
+
return await call(ctx, 'POST', sessionPath(session, '/billing'), { body });
|
|
1988
|
+
} catch (error) {
|
|
1989
|
+
if (isTransientRemoteError(error)) throw error;
|
|
1990
|
+
const remoteCode = toText(error?.remoteBody?.code);
|
|
1991
|
+
const label = body.to === 'claude-api' ? 'the API key' : 'the subscription';
|
|
1992
|
+
if (Number(error?.status) === 404 && !remoteCode) {
|
|
1993
|
+
throw new DispatchFailure(409, REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
1994
|
+
`Relay "${ctx.relay.name}" cannot switch a session's billing (it runs an OAR from before the switch); nothing was sent. Send without provider to keep the session's billing.`,
|
|
1995
|
+
{ session });
|
|
1996
|
+
}
|
|
1997
|
+
const remoteText = toText(error?.remoteBody?.error) || toText(error?.message);
|
|
1998
|
+
const code = remoteCode === 'CLAUDE_BILLING_MODEL_UNAVAILABLE'
|
|
1999
|
+
? REMOTE_RELAY_ERROR_CODES.unknownModel
|
|
2000
|
+
: (['CLAUDE_API_NOT_CONFIGURED', 'CLAUDE_NOT_CONFIGURED', 'CLAUDE_API_CAP_REACHED'].includes(remoteCode)
|
|
2001
|
+
? REMOTE_RELAY_ERROR_CODES.providerUnavailable
|
|
2002
|
+
: (Number(error?.status) === 404 ? REMOTE_RELAY_ERROR_CODES.notFound : REMOTE_RELAY_ERROR_CODES.invalidInput));
|
|
2003
|
+
throw new DispatchFailure(Number(error?.status) || 409, code,
|
|
2004
|
+
`Relay "${ctx.relay.name}" did not switch session ${session} to ${label}: ${remoteText || 'refused'}. Nothing was sent.`,
|
|
2005
|
+
{ session, ...(remoteCode ? { remoteCode } : {}) });
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1972
2009
|
/**
|
|
1973
2010
|
* What the remote made of the effort: `reported` is the one it bound (the
|
|
1974
2011
|
* bootstrap's preferredReasoningEffort, the message's
|
|
@@ -2090,10 +2127,19 @@ export function createRemoteRelayDispatcher({
|
|
|
2090
2127
|
}
|
|
2091
2128
|
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
2092
2129
|
const provider = toText(runtime.providerType).toLowerCase() || 'github';
|
|
2130
|
+
// `provider` on a send is a Claude session's billing: the subscription
|
|
2131
|
+
// or the API key. The other one switches the session with this message.
|
|
2132
|
+
const billing = toText(args.provider).toLowerCase();
|
|
2133
|
+
if (billing && !REMOTE_RELAY_SEND_PROVIDERS.includes(provider)) {
|
|
2134
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
|
|
2135
|
+
`Session ${session} on relay "${ctx.relay.name}" runs on ${provider}: provider on send switches a Claude session's billing only. Nothing was sent.`,
|
|
2136
|
+
{ session, provider });
|
|
2137
|
+
}
|
|
2138
|
+
const switchTo = billing && billing !== provider ? billing : '';
|
|
2093
2139
|
// Checked before any approval card (runRelayAction) when the session
|
|
2094
|
-
// could be read there; here otherwise.
|
|
2140
|
+
// could be read there; here otherwise. Against the billing it moves to.
|
|
2095
2141
|
const settled = args.model
|
|
2096
|
-
? (ctx.settledModel || await settleSendModel(ctx, conversation, args.model))
|
|
2142
|
+
? (ctx.settledModel || await settleSendModel(ctx, conversation, args.model, { provider: billing }))
|
|
2097
2143
|
: null;
|
|
2098
2144
|
const requestedModel = settled?.model || '';
|
|
2099
2145
|
const modelNote = settled?.note || null;
|
|
@@ -2110,16 +2156,25 @@ export function createRemoteRelayDispatcher({
|
|
|
2110
2156
|
const requested = normalizeEffort(args.effort) || normalizeEffort(settled?.effort);
|
|
2111
2157
|
const own = requested ? '' : normalizeEffort(conversation?.preferredReasoningEffort);
|
|
2112
2158
|
const origin = await buildOrigin(ctx);
|
|
2113
|
-
const { sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model, mode }, {
|
|
2159
|
+
const { sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model, mode, billing: switchTo }, {
|
|
2114
2160
|
effort: requested || own,
|
|
2115
2161
|
source: requested ? EFFORT_SOURCES.requested : (own ? EFFORT_SOURCES.remote : ''),
|
|
2116
2162
|
note: null,
|
|
2117
2163
|
});
|
|
2164
|
+
const billingNote = switchTo
|
|
2165
|
+
? `Session ${session} now runs on ${switchTo === 'claude-api' ? 'the API key' : 'the subscription'}, from this message on (a running turn finishes on the old billing).`
|
|
2166
|
+
: '';
|
|
2118
2167
|
return deliverAndWait('send', ctx, {
|
|
2119
2168
|
session,
|
|
2120
2169
|
sent,
|
|
2121
2170
|
waitSeconds: args.wait_seconds,
|
|
2122
|
-
extra: {
|
|
2171
|
+
extra: {
|
|
2172
|
+
...(modelNote ? { modelNote } : {}),
|
|
2173
|
+
...(billing && REMOTE_RELAY_SEND_PROVIDERS.includes(provider) ? { provider: switchTo || provider } : {}),
|
|
2174
|
+
...(switchTo ? { billingSwitched: true } : {}),
|
|
2175
|
+
...effortResult(choice),
|
|
2176
|
+
...(billingNote ? { note: [effortResult(choice).note, billingNote].filter(Boolean).join(' ') } : {}),
|
|
2177
|
+
},
|
|
2123
2178
|
});
|
|
2124
2179
|
}
|
|
2125
2180
|
|
|
@@ -2178,9 +2233,11 @@ export function createRemoteRelayDispatcher({
|
|
|
2178
2233
|
}
|
|
2179
2234
|
|
|
2180
2235
|
/** settleRequestedModel for a send to `conversation` (its detail payload); an unread catalogue leaves it unchecked. */
|
|
2181
|
-
async function settleSendModel(ctx, conversation, requested) {
|
|
2236
|
+
async function settleSendModel(ctx, conversation, requested, { provider: billing = '' } = {}) {
|
|
2182
2237
|
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
2183
|
-
const
|
|
2238
|
+
const own = toText(runtime.providerType).toLowerCase() || 'github';
|
|
2239
|
+
// A Claude session moving to its other billing takes that billing's list.
|
|
2240
|
+
const provider = billing && REMOTE_RELAY_SEND_PROVIDERS.includes(own) ? billing : own;
|
|
2184
2241
|
let catalog = null;
|
|
2185
2242
|
try {
|
|
2186
2243
|
catalog = await loadCatalog(ctx);
|
|
@@ -2329,10 +2386,29 @@ export function createRemoteRelayDispatcher({
|
|
|
2329
2386
|
// From here the new session's queued turn counts toward the cap itself.
|
|
2330
2387
|
ctx.onPromptQueued?.();
|
|
2331
2388
|
} catch (error) {
|
|
2332
|
-
if (!choice.effort || !isEffortRejection(error)) throw error;
|
|
2333
2389
|
// The session exists by now: say so, or the agent starts a second one.
|
|
2390
|
+
const remoteCode = toText(error?.remoteBody?.code);
|
|
2391
|
+
if (!choice.effort || !isEffortRejection(error)) {
|
|
2392
|
+
// The remote's own 4xx is passed on, anything else is a 502, as
|
|
2393
|
+
// mapRemoteError answers them.
|
|
2394
|
+
const code = toText(error?.code);
|
|
2395
|
+
const remoteStatus = Number(error?.status) || 0;
|
|
2396
|
+
const status = error instanceof DispatchFailure
|
|
2397
|
+
? error.status
|
|
2398
|
+
: (code.startsWith(REMOTE_RELAY_ERROR_CODES.httpPrefix) && remoteStatus >= 400 && remoteStatus < 500 ? remoteStatus : 502);
|
|
2399
|
+
const supportedModels = Array.isArray(error?.remoteBody?.supportedModels) ? error.remoteBody.supportedModels.slice(0, 50) : null;
|
|
2400
|
+
throw new DispatchFailure(status, code || `${REMOTE_RELAY_ERROR_CODES.httpPrefix}${status}`,
|
|
2401
|
+
`${toText(error?.message).replace(/[.\s]+$/, '') || `Relay "${ctx.relay.name}" refused the first prompt`}. The session ${session} was created, but the prompt was not queued.`,
|
|
2402
|
+
{
|
|
2403
|
+
...(isPlainObject(error?.extra) ? error.extra : {}),
|
|
2404
|
+
session,
|
|
2405
|
+
...(remoteCode ? { remoteCode } : {}),
|
|
2406
|
+
...(supportedModels ? { supportedModels } : {}),
|
|
2407
|
+
openUrl: remoteConversationUrl(ctx.relay.url, session) || null,
|
|
2408
|
+
note: 'Use send with this session (update_session to change its model); do not create another session.',
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2334
2411
|
const supported = supportedEffortsOf(error);
|
|
2335
|
-
const remoteCode = toText(error.remoteBody?.code);
|
|
2336
2412
|
throw new DispatchFailure(400, toText(error.code) || `${REMOTE_RELAY_ERROR_CODES.httpPrefix}400`,
|
|
2337
2413
|
`${toText(error.message) || `Relay "${ctx.relay.name}" refused effort "${choice.effort}"`}. The session was created, but the prompt was not queued.`,
|
|
2338
2414
|
{
|
|
@@ -3711,7 +3787,7 @@ export function createRemoteRelayDispatcher({
|
|
|
3711
3787
|
}
|
|
3712
3788
|
} else {
|
|
3713
3789
|
const conversation = await call(early, 'GET', sessionPath(args.session), { query: { limit: 1 } });
|
|
3714
|
-
settledModel = await settleSendModel(early, conversation, args.model);
|
|
3790
|
+
settledModel = await settleSendModel(early, conversation, args.model, { provider: args.provider });
|
|
3715
3791
|
}
|
|
3716
3792
|
} catch (error) {
|
|
3717
3793
|
if (error instanceof DispatchFailure) {
|
|
@@ -3722,6 +3798,18 @@ export function createRemoteRelayDispatcher({
|
|
|
3722
3798
|
settledModel = null;
|
|
3723
3799
|
}
|
|
3724
3800
|
}
|
|
3801
|
+
// The card names a billing switch only when the send makes one: a
|
|
3802
|
+
// provider that is already the session's billing changes nothing. Read
|
|
3803
|
+
// off the target before anyone is asked; a target that cannot be read
|
|
3804
|
+
// keeps the line (the send itself then answers).
|
|
3805
|
+
let cardArgs = args;
|
|
3806
|
+
if (action === 'send' && args.provider) {
|
|
3807
|
+
try {
|
|
3808
|
+
const conversation = await call({ relay, caller, hops, conversationId, signal }, 'GET', sessionPath(args.session), { query: { limit: 1 } });
|
|
3809
|
+
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
3810
|
+
if (toText(runtime.providerType).toLowerCase() === args.provider) cardArgs = { ...args, provider: undefined };
|
|
3811
|
+
} catch {}
|
|
3812
|
+
}
|
|
3725
3813
|
// What an update_session would change, settled on the target before
|
|
3726
3814
|
// anyone is asked: an unknown model or group, a busy session or a change
|
|
3727
3815
|
// to nothing never raises a card.
|
|
@@ -3788,7 +3876,7 @@ export function createRemoteRelayDispatcher({
|
|
|
3788
3876
|
callerContext: caller,
|
|
3789
3877
|
relay: relayTarget(relay),
|
|
3790
3878
|
action,
|
|
3791
|
-
args,
|
|
3879
|
+
args: cardArgs,
|
|
3792
3880
|
summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
3793
3881
|
signal,
|
|
3794
3882
|
agentPrompts: agentPrompts(conversationId).count,
|
|
@@ -3810,7 +3898,7 @@ export function createRemoteRelayDispatcher({
|
|
|
3810
3898
|
callerContext: caller,
|
|
3811
3899
|
relay: relayTarget(relay),
|
|
3812
3900
|
action,
|
|
3813
|
-
args,
|
|
3901
|
+
args: cardArgs,
|
|
3814
3902
|
summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
3815
3903
|
signal,
|
|
3816
3904
|
...(group ? { group } : {}),
|
|
@@ -3897,7 +3985,13 @@ export function createRemoteRelayDispatcher({
|
|
|
3897
3985
|
// wait_seconds is cut to this relay's "longest wait" setting, for every
|
|
3898
3986
|
// target: the waiting is done here, by polling the target.
|
|
3899
3987
|
const { maxWaitSeconds } = agentSessions();
|
|
3900
|
-
|
|
3988
|
+
// The server must always pass the provider lists: without them the
|
|
3989
|
+
// contract checks a provider's shape only (the worker's path).
|
|
3990
|
+
const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action }, {
|
|
3991
|
+
maxWaitSeconds,
|
|
3992
|
+
providers: REMOTE_RELAY_PROVIDERS,
|
|
3993
|
+
sendProviders: REMOTE_RELAY_SEND_PROVIDERS,
|
|
3994
|
+
});
|
|
3901
3995
|
const result = await runValidated({ conversationId, req, signal }, validation);
|
|
3902
3996
|
// The arguments the action does not take, named on the answer (a call
|
|
3903
3997
|
// that came through a worker's tool had them named there already).
|
|
@@ -27,7 +27,7 @@ import { remoteRelayAliases } from '../../shared/remote-relay-mentions.mjs';
|
|
|
27
27
|
// remote_relay_inbound_enabled accept prompts from other relays' agents
|
|
28
28
|
// agent_sessions_enabled this relay's own agents may start and use
|
|
29
29
|
// sessions on it (the local target; off by default)
|
|
30
|
-
// remote_relay_max_wait_seconds the longest wait of one
|
|
30
|
+
// remote_relay_max_wait_seconds the longest wait of one relay_tool call
|
|
31
31
|
//
|
|
32
32
|
// Tokens never leave through listPublic(), the socket event or any route; only
|
|
33
33
|
// the outbound client reads them (list()/get()/resolve() are server-internal).
|
|
@@ -311,6 +311,34 @@ export function applyDeepSeekProviderEnvironment(env = {}, {
|
|
|
311
311
|
return next;
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Claude API: the Claude worker's runner on an API key from the Claude
|
|
316
|
+
* Console instead of the host's subscription login
|
|
317
|
+
* (server/claude-api-worker/). The key travels as OAR_CLAUDE_API_KEY, through
|
|
318
|
+
* the secret file under tmux, and becomes the CLI's ANTHROPIC_API_KEY only
|
|
319
|
+
* inside its spawn environment (claude-api-backend.mjs). The worker shares the
|
|
320
|
+
* host's Claude Code state directory with the Claude provider: transcripts,
|
|
321
|
+
* resume and task output are found where a Claude chat's are.
|
|
322
|
+
*/
|
|
323
|
+
export function applyClaudeApiProviderEnvironment(env = {}, {
|
|
324
|
+
enabled = false,
|
|
325
|
+
model = '',
|
|
326
|
+
apiKey = '',
|
|
327
|
+
} = {}) {
|
|
328
|
+
const next = { ...env };
|
|
329
|
+
if (resolveWorkerKind(next) === 'claude-api') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
|
|
330
|
+
delete next.CLAUDE_API_RELAY_MODEL;
|
|
331
|
+
delete next.OAR_CLAUDE_API_KEY;
|
|
332
|
+
if (!enabled) return next;
|
|
333
|
+
const normalizedApiKey = normalizeText(apiKey);
|
|
334
|
+
if (!normalizedApiKey) throw new Error('claude-api-key-not-configured');
|
|
335
|
+
next.COPILOT_WEB_RELAY_WORKER_KIND = 'claude-api';
|
|
336
|
+
next.OAR_CLAUDE_API_KEY = normalizedApiKey;
|
|
337
|
+
const normalizedModel = normalizeText(model);
|
|
338
|
+
if (normalizedModel) next.CLAUDE_API_RELAY_MODEL = normalizedModel;
|
|
339
|
+
return next;
|
|
340
|
+
}
|
|
341
|
+
|
|
314
342
|
/**
|
|
315
343
|
* The Copilot SDK engine: the same `github`/`openai` providers the extension
|
|
316
344
|
* serves, run as a plain Node worker against the CLI's headless runtime.
|
|
@@ -404,6 +432,10 @@ export function isDeepSeekWorkerEnvironment(env = {}) {
|
|
|
404
432
|
return resolveWorkerKind(env) === 'deepseek';
|
|
405
433
|
}
|
|
406
434
|
|
|
435
|
+
export function isClaudeApiWorkerEnvironment(env = {}) {
|
|
436
|
+
return resolveWorkerKind(env) === 'claude-api';
|
|
437
|
+
}
|
|
438
|
+
|
|
407
439
|
export function isCopilotSdkWorkerEnvironment(env = {}) {
|
|
408
440
|
return resolveWorkerKind(env) === 'copilot-sdk';
|
|
409
441
|
}
|
|
@@ -458,6 +490,16 @@ export function resolveDeepSeekWorkerScriptPath(env = {}) {
|
|
|
458
490
|
return path.join(process.cwd(), 'server', 'deepseek-worker', 'deepseek-session-worker.mjs');
|
|
459
491
|
}
|
|
460
492
|
|
|
493
|
+
export function resolveClaudeApiWorkerScriptPath(env = {}) {
|
|
494
|
+
const explicit = normalizeText(env?.COPILOT_WEB_RELAY_CLAUDE_API_WORKER_PATH);
|
|
495
|
+
if (explicit) return explicit;
|
|
496
|
+
const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
|
|
497
|
+
if (repoRoot) return path.join(repoRoot, 'server', 'claude-api-worker', 'claude-api-session-worker.mjs');
|
|
498
|
+
const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
|
|
499
|
+
if (serverDir) return path.join(serverDir, 'claude-api-worker', 'claude-api-session-worker.mjs');
|
|
500
|
+
return path.join(process.cwd(), 'server', 'claude-api-worker', 'claude-api-session-worker.mjs');
|
|
501
|
+
}
|
|
502
|
+
|
|
461
503
|
export function resolveCopilotSdkWorkerScriptPath(env = {}) {
|
|
462
504
|
const explicit = normalizeText(env?.COPILOT_WEB_RELAY_COPILOT_SDK_WORKER_PATH);
|
|
463
505
|
if (explicit) return explicit;
|
|
@@ -487,6 +529,10 @@ const NODE_WORKER_DESCRIPTORS = Object.freeze({
|
|
|
487
529
|
windowsTitle: 'Copilot SDK Worker',
|
|
488
530
|
}),
|
|
489
531
|
deepseek: Object.freeze({ resolveScriptPath: resolveDeepSeekWorkerScriptPath, windowsTitle: 'DeepSeek Worker' }),
|
|
532
|
+
'claude-api': Object.freeze({
|
|
533
|
+
resolveScriptPath: resolveClaudeApiWorkerScriptPath,
|
|
534
|
+
windowsTitle: 'Claude API Worker',
|
|
535
|
+
}),
|
|
490
536
|
});
|
|
491
537
|
|
|
492
538
|
function resolveNodeWorkerDescriptor(env = {}) {
|
|
@@ -525,7 +571,7 @@ function buildPosixWorkerLaunchCommand(targetSessionId, env = {}, extraCliArgs =
|
|
|
525
571
|
// Server entry shape (the CLI's own `copilot mcp add` writes the same):
|
|
526
572
|
// `{ type: 'local', command, args, env, tools: ['*'], timeout }`. `timeout` is
|
|
527
573
|
// the per-call limit in ms; without it the CLI cuts a tool call off at 180 s,
|
|
528
|
-
// shorter than one
|
|
574
|
+
// shorter than one relay_tool wait.
|
|
529
575
|
|
|
530
576
|
export function resolveOarMcpServerScriptPath(env = {}, { pathImpl = path } = {}) {
|
|
531
577
|
const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
|
|
@@ -662,6 +708,7 @@ export const WORKER_SECRET_ENV_VARS = Object.freeze([
|
|
|
662
708
|
'CURSOR_API_KEY',
|
|
663
709
|
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
664
710
|
'DEEPSEEK_API_KEY',
|
|
711
|
+
'OAR_CLAUDE_API_KEY',
|
|
665
712
|
'ANTHROPIC_API_KEY',
|
|
666
713
|
'ANTHROPIC_AUTH_TOKEN',
|
|
667
714
|
]);
|
|
@@ -682,6 +729,11 @@ export const WORKER_SECRET_ENV_VARS = Object.freeze([
|
|
|
682
729
|
// - The DeepSeek key is put there by its own applier, but the same name could
|
|
683
730
|
// also sit in the relay's environment (a shell that exports it); it is the
|
|
684
731
|
// DeepSeek worker's alone either way.
|
|
732
|
+
// - The Claude API key is the Claude API worker's alone, the same way. It
|
|
733
|
+
// travels under a name of the relay's (OAR_CLAUDE_API_KEY), never as
|
|
734
|
+
// ANTHROPIC_API_KEY: the worker's CLI is the only process that gets it under
|
|
735
|
+
// that name (claude-api-backend.mjs), and a host's ANTHROPIC_API_KEY stays
|
|
736
|
+
// what it is below, no worker's.
|
|
685
737
|
// - A host's ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN are no worker's (an
|
|
686
738
|
// empty list): Claude Code prefers either to the Claude login, so a Claude
|
|
687
739
|
// chat would run, and be billed, on a key nobody chose for it, and every
|
|
@@ -691,6 +743,7 @@ export const WORKER_SECRET_ENV_VARS = Object.freeze([
|
|
|
691
743
|
const WORKER_SECRET_ENV_VAR_KINDS = Object.freeze({
|
|
692
744
|
CLAUDE_CODE_OAUTH_TOKEN: Object.freeze(['claude', 'claude-cloud']),
|
|
693
745
|
DEEPSEEK_API_KEY: Object.freeze(['deepseek']),
|
|
746
|
+
OAR_CLAUDE_API_KEY: Object.freeze(['claude-api']),
|
|
694
747
|
ANTHROPIC_API_KEY: Object.freeze([]),
|
|
695
748
|
ANTHROPIC_AUTH_TOKEN: Object.freeze([]),
|
|
696
749
|
});
|
|
@@ -867,6 +920,12 @@ export const TMUX_WORKER_EXPORTED_ENV_VARS = Object.freeze([
|
|
|
867
920
|
'DEEPSEEK_RELAY_MODEL',
|
|
868
921
|
'DEEPSEEK_BASE_URL',
|
|
869
922
|
'DEEPSEEK_CLAUDE_CONFIG_DIR',
|
|
923
|
+
// The Claude API worker's model; its key comes through the file like
|
|
924
|
+
// DeepSeek's. Tests point the worker's CLI at a fake Anthropic API on this
|
|
925
|
+
// machine the way they point the cloud client (shared/anthropic-api.mjs
|
|
926
|
+
// takes a loopback URL only, and so does the worker).
|
|
927
|
+
'CLAUDE_API_RELAY_MODEL',
|
|
928
|
+
'OAR_ANTHROPIC_API_BASE_URL',
|
|
870
929
|
'GROK_RELAY_MODEL',
|
|
871
930
|
'GROK_CLI_COMMAND',
|
|
872
931
|
'GROK_ALWAYS_APPROVE',
|
|
@@ -1128,7 +1187,7 @@ export async function launchSessionCli({
|
|
|
1128
1187
|
// Set per launch below, never inherited: a relay started from inside a
|
|
1129
1188
|
// worker session would otherwise hand its workers that session's log.
|
|
1130
1189
|
delete launchSessionEnv[WORKER_LOG_FILE_ENV];
|
|
1131
|
-
// The extension engine gets the OAR MCP server (
|
|
1190
|
+
// The extension engine gets the OAR MCP server (relay_tool + preview) the
|
|
1132
1191
|
// node workers carry in-process; every launch path appends the same args.
|
|
1133
1192
|
const extensionCliArgs = resolveNodeWorkerDescriptor(launchSessionEnv)
|
|
1134
1193
|
? []
|
|
@@ -61,6 +61,16 @@ function normalizeStatus(value) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const STEERING_HOLD_REASONS = new Set(['question', 'compaction', 'adoption', 'delivery', 'tool', 'other']);
|
|
64
|
+
// What keeps a Claude worker's CLI from being released for a billing swap.
|
|
65
|
+
const STEERING_RELEASE_HOLDS = new Set(['question', 'compaction', 'turn', 'background']);
|
|
66
|
+
const WORKER_KIND_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
|
67
|
+
|
|
68
|
+
// The kind a worker says it is ("claude", "claude-api", "deepseek", ...):
|
|
69
|
+
// lower case, short, or null when it did not say.
|
|
70
|
+
export function normalizeWorkerKind(value) {
|
|
71
|
+
const kind = String(value || '').trim().toLowerCase();
|
|
72
|
+
return WORKER_KIND_PATTERN.test(kind) ? kind : null;
|
|
73
|
+
}
|
|
64
74
|
const STEERING_MESSAGE_ID_MAX_LENGTH = 64;
|
|
65
75
|
const STEERING_CANCELLABLE_IDS_MAX = 50;
|
|
66
76
|
|
|
@@ -122,6 +132,15 @@ export function normalizeWorkerSteeringSnapshot(raw) {
|
|
|
122
132
|
// Only a snapshot that reports a running CLI carries the key.
|
|
123
133
|
const autoCompactWindow = normalizeSteeringAutoCompactWindow(raw.autoCompactWindow);
|
|
124
134
|
if (autoCompactWindow) snapshot.autoCompactWindow = autoCompactWindow;
|
|
135
|
+
// Whether the relay may replace the worker now (a Claude billing swap), and
|
|
136
|
+
// what holds it when not. Only a worker that reports it carries the keys.
|
|
137
|
+
if (typeof raw.releasable === 'boolean') {
|
|
138
|
+
snapshot.releasable = raw.releasable;
|
|
139
|
+
const hold = String(raw.releaseHold || '').trim().toLowerCase();
|
|
140
|
+
snapshot.releaseHold = !raw.releasable && STEERING_RELEASE_HOLDS.has(hold) ? hold : null;
|
|
141
|
+
const tasks = Number(raw.backgroundTaskCount);
|
|
142
|
+
snapshot.backgroundTaskCount = Number.isInteger(tasks) && tasks > 0 ? Math.min(tasks, 999) : 0;
|
|
143
|
+
}
|
|
125
144
|
return snapshot;
|
|
126
145
|
}
|
|
127
146
|
|
|
@@ -140,6 +159,7 @@ function sanitizeState(raw = null) {
|
|
|
140
159
|
retryCount: toNonNegativeInt(base.retryCount, 0),
|
|
141
160
|
lastError: String(base.lastError || '').trim() || null,
|
|
142
161
|
steering: normalizeWorkerSteeringSnapshot(base.steering),
|
|
162
|
+
kind: normalizeWorkerKind(base.kind),
|
|
143
163
|
updatedAt: normalizeTimestamp(base.updatedAt, nowIso),
|
|
144
164
|
createdAt,
|
|
145
165
|
};
|
|
@@ -244,7 +264,14 @@ export function createSessionWorkerRegistry() {
|
|
|
244
264
|
// the old entry into every upsert, so without this a crashed worker's
|
|
245
265
|
// hold outlived it and the composer showed it until a replacement
|
|
246
266
|
// heartbeated — indefinitely, if none ever did.
|
|
247
|
-
|
|
267
|
+
// The kind is the same: a process's own word. An upsert that does not
|
|
268
|
+
// state it keeps the known one, unless the process it described is gone.
|
|
269
|
+
const statesKind = Object.prototype.hasOwnProperty.call(rawState, 'kind');
|
|
270
|
+
if (!statesKind) next.kind = existing?.kind || null;
|
|
271
|
+
if (existing && isWorkerReplacedOrGone(existing, next)) {
|
|
272
|
+
next.steering = null;
|
|
273
|
+
if (!statesKind) next.kind = null;
|
|
274
|
+
}
|
|
248
275
|
clearIndexesForSession(existing);
|
|
249
276
|
bySession.set(state.sdkSessionId, next);
|
|
250
277
|
updateIndexesForSession(next);
|
|
@@ -108,6 +108,9 @@ export function createSessionWorkerWebSocketService({
|
|
|
108
108
|
onDeliverySendFailed = async () => {},
|
|
109
109
|
onWorkerSocketClosed = async () => {},
|
|
110
110
|
requestWork = async () => null,
|
|
111
|
+
// A worker stated its kind ("claude", "claude-api", ...) in a hello or a
|
|
112
|
+
// ready: (sessionId, kind, pid). The registry keeps it.
|
|
113
|
+
onWorkerKind = () => {},
|
|
111
114
|
// Liveness probe for a socket's worker process. A killed worker's socket
|
|
112
115
|
// survives its process by an event-loop turn (the kill path blocks the loop,
|
|
113
116
|
// so this service's timer runs before the socket close lands), and asking for
|
|
@@ -233,6 +236,15 @@ export function createSessionWorkerWebSocketService({
|
|
|
233
236
|
}
|
|
234
237
|
}
|
|
235
238
|
|
|
239
|
+
// The kind a hello or ready states, kept on the socket (a process does not
|
|
240
|
+
// change kind) and reported once per change.
|
|
241
|
+
function noteWorkerKind(meta, payload) {
|
|
242
|
+
const kind = String(payload?.kind || '').trim().toLowerCase();
|
|
243
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(kind) || meta.workerKind === kind) return;
|
|
244
|
+
meta.workerKind = kind;
|
|
245
|
+
try { onWorkerKind(meta.sessionId, kind, meta.pid); } catch {}
|
|
246
|
+
}
|
|
247
|
+
|
|
236
248
|
function noteHeartbeat(meta, reason = 'worker-message') {
|
|
237
249
|
if (!meta?.sessionId) return;
|
|
238
250
|
try {
|
|
@@ -258,6 +270,9 @@ export function createSessionWorkerWebSocketService({
|
|
|
258
270
|
sessionId: meta.sessionId,
|
|
259
271
|
pid: meta.pid,
|
|
260
272
|
reason,
|
|
273
|
+
// What this socket's worker said it is: a Claude chat's row goes only
|
|
274
|
+
// to a worker of its billing.
|
|
275
|
+
workerKind: meta.workerKind || null,
|
|
261
276
|
});
|
|
262
277
|
touchCli();
|
|
263
278
|
if (!pending || typeof pending !== 'object') return false;
|
|
@@ -404,6 +419,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
404
419
|
if (payload.type === 'worker.hello') {
|
|
405
420
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
406
421
|
meta.lastSeenAtMs = nowMsSafe();
|
|
422
|
+
noteWorkerKind(meta, payload);
|
|
407
423
|
// A worker holding deliveries (open question card, compaction) binds
|
|
408
424
|
// its identity with hello but must not be handed work.
|
|
409
425
|
meta.ready = payload.ready !== false;
|
|
@@ -423,6 +439,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
423
439
|
if (payload.type === 'worker.ready') {
|
|
424
440
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
425
441
|
meta.lastSeenAtMs = nowMsSafe();
|
|
442
|
+
noteWorkerKind(meta, payload);
|
|
426
443
|
meta.ready = true;
|
|
427
444
|
meta.lastReadyAt = nowIso();
|
|
428
445
|
noteHeartbeat(meta, String(payload.reason || 'worker-ready'));
|
|
@@ -175,10 +175,11 @@ export function createUsageLimitPauseService({
|
|
|
175
175
|
* Decide whether a refused turn is paused. Returns the plan, or null when
|
|
176
176
|
* the response is to be handled as the terminal failure it says it is.
|
|
177
177
|
*/
|
|
178
|
-
function planPause({ terminalError, conversationId, providerType, queueRow = null, partialText = '' } = {}) {
|
|
178
|
+
function planPause({ terminalError, conversationId, providerType, chatProviderType = null, queueRow = null, partialText = '' } = {}) {
|
|
179
179
|
if (!isUsageLimitTerminalError(terminalError)) return null;
|
|
180
180
|
// Claude sessions, local and in the cloud: both run Claude Code on the
|
|
181
|
-
// same account and report the same limit.
|
|
181
|
+
// same account and report the same limit. `providerType` is what the
|
|
182
|
+
// refused turn ran on.
|
|
182
183
|
if (!['claude', 'claude-cloud'].includes(String(providerType || '').trim().toLowerCase())) return null;
|
|
183
184
|
if (!String(conversationId || '').trim()) return null;
|
|
184
185
|
const at = now();
|
|
@@ -187,6 +188,21 @@ export function createUsageLimitPauseService({
|
|
|
187
188
|
// What the turn had written before the refusal stays above the note.
|
|
188
189
|
const written = String(partialText || '').trim();
|
|
189
190
|
const withWritten = (note) => (written ? `${written}\n\n${note}` : note);
|
|
191
|
+
// The chat moved to the API key while this subscription turn ran: its
|
|
192
|
+
// follow-up is not held for the subscription's reset, it runs on the key
|
|
193
|
+
// at once (a row on the key is never held for the usage limit).
|
|
194
|
+
if (String(chatProviderType || '').trim().toLowerCase() === 'claude-api') {
|
|
195
|
+
return {
|
|
196
|
+
conversationId: String(conversationId),
|
|
197
|
+
rateLimitType,
|
|
198
|
+
resetsAt: null,
|
|
199
|
+
resumeAt: null,
|
|
200
|
+
auto: false,
|
|
201
|
+
retries: 0,
|
|
202
|
+
onApiKey: true,
|
|
203
|
+
noteText: withWritten(`⏸ Stopped at the Claude ${label}. This chat now runs on the Claude API key, so the turn carries on there.`),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
190
206
|
const resetsAtMs = Date.parse(String(terminalError.resetsAt || ''));
|
|
191
207
|
if (!Number.isFinite(resetsAtMs) || resetsAtMs <= at) {
|
|
192
208
|
// Counted along the follow-ups: the refused row is one itself when the
|
|
@@ -232,7 +248,8 @@ export function createUsageLimitPauseService({
|
|
|
232
248
|
function schedulePause(plan, { queueRow = null } = {}) {
|
|
233
249
|
if (!plan?.conversationId) return null;
|
|
234
250
|
const at = new Date(now()).toISOString();
|
|
235
|
-
|
|
251
|
+
// On the key: due now, never held.
|
|
252
|
+
const heldUntil = plan.onApiKey ? null : (plan.auto ? plan.resumeAt : HELD_UNTIL_RESUMED);
|
|
236
253
|
// What this refusal cut off. A refused follow-up stands for what it
|
|
237
254
|
// quoted; a turn of the CLI's own has no prompt.
|
|
238
255
|
const refusedFollowUp = parseJson(queueRow?.usage_limit_pause);
|
|
@@ -251,6 +268,7 @@ export function createUsageLimitPauseService({
|
|
|
251
268
|
resetsAt: plan.resetsAt,
|
|
252
269
|
auto: plan.auto,
|
|
253
270
|
retries: Number(plan.retries || 0),
|
|
271
|
+
...(plan.onApiKey ? { onApiKey: true, resumedAt: at, resumedBy: 'billing-switch' } : {}),
|
|
254
272
|
pausedAt: at,
|
|
255
273
|
refusedMessageId: queueRow?.id ? String(queueRow.id) : null,
|
|
256
274
|
prompts,
|
|
@@ -345,8 +363,15 @@ export function createUsageLimitPauseService({
|
|
|
345
363
|
} catch {}
|
|
346
364
|
}
|
|
347
365
|
const state = getPause(plan.conversationId);
|
|
348
|
-
try {
|
|
366
|
+
try {
|
|
367
|
+
logger.log?.(`[usage-limit] conv=${plan.conversationId.slice(0, 8)} ${plan.onApiKey
|
|
368
|
+
? `stopped at the ${usageLimitWindowLabel(plan.rateLimitType)}; carries on on the API key`
|
|
369
|
+
: `paused at the ${usageLimitWindowLabel(plan.rateLimitType)}; ${plan.auto ? `resumes ${plan.resumeAt}` : `resets ${plan.resetsAt}, waits for the user`}`}`);
|
|
370
|
+
} catch {}
|
|
349
371
|
emitPause(plan.conversationId, state);
|
|
372
|
+
if (plan.onApiKey) {
|
|
373
|
+
try { onQueueChanged('usage-limit-on-api-key'); } catch {}
|
|
374
|
+
}
|
|
350
375
|
return { messageId, created: Boolean(created), state };
|
|
351
376
|
}
|
|
352
377
|
|
|
@@ -375,6 +400,31 @@ export function createUsageLimitPauseService({
|
|
|
375
400
|
return { resumed: true, messageId: String(row.id), state: null };
|
|
376
401
|
}
|
|
377
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Release the held follow-up because the chat moved to the API key: it
|
|
405
|
+
* runs now, on the key (the billing switch re-stamped it). Called inside
|
|
406
|
+
* the switch's transaction; `announce()` tells the clients once that has
|
|
407
|
+
* committed. Null when nothing is held.
|
|
408
|
+
*/
|
|
409
|
+
function releaseForBillingSwitch({ conversationId, by = 'billing-switch' } = {}) {
|
|
410
|
+
const id = String(conversationId || '').trim();
|
|
411
|
+
const row = id ? findPausedRowForConversation.get(id) : null;
|
|
412
|
+
if (!row || !stateFromRow(row)) return null;
|
|
413
|
+
const pause = parseJson(row.usage_limit_pause) || {};
|
|
414
|
+
const at = new Date(now()).toISOString();
|
|
415
|
+
setPause.run(null, JSON.stringify({ ...pause, resumedAt: at, resumedBy: String(by || 'billing-switch') }), row.id);
|
|
416
|
+
const messageId = String(row.id);
|
|
417
|
+
return {
|
|
418
|
+
messageId,
|
|
419
|
+
announce() {
|
|
420
|
+
try { logger.log?.(`[usage-limit] conv=${id.slice(0, 8)} released by the switch to the API key`); } catch {}
|
|
421
|
+
emitPause(id, null);
|
|
422
|
+
try { emit('message_status', { messageId, conversationId: id, status: 'pending' }); } catch {}
|
|
423
|
+
try { onQueueChanged('usage-limit-billing-switch'); } catch {}
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
378
428
|
/** Drop the held follow-up: the paused turn is not carried on. */
|
|
379
429
|
function cancel({ conversationId } = {}) {
|
|
380
430
|
const id = String(conversationId || '').trim();
|
|
@@ -436,6 +486,7 @@ export function createUsageLimitPauseService({
|
|
|
436
486
|
getPause,
|
|
437
487
|
listPauses,
|
|
438
488
|
resumeNow,
|
|
489
|
+
releaseForBillingSwitch,
|
|
439
490
|
cancel,
|
|
440
491
|
recordReport,
|
|
441
492
|
getAccountState: accountState,
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
// A provider registers its key by one entry in SAVED_PROVIDER_KEY_SETTINGS.
|
|
46
46
|
|
|
47
47
|
import { DEEPSEEK_PROVIDER_TYPE } from '../../shared/deepseek-models.mjs';
|
|
48
|
+
import { CLAUDE_API_KEY_SETTING_KEY, CLAUDE_API_PROVIDER_TYPE } from './claude-api-settings-service.mjs';
|
|
48
49
|
import { DEEPSEEK_API_KEY_SETTING_KEY } from './deepseek-settings-service.mjs';
|
|
49
50
|
|
|
50
51
|
export const REDACTED_SECRET_TEXT = '[redacted]';
|
|
@@ -64,6 +65,7 @@ export const WORKER_SESSION_HEADER = 'x-relay-session-id';
|
|
|
64
65
|
/** The providers whose workers get a key saved in the relay's settings. */
|
|
65
66
|
export const SAVED_PROVIDER_KEY_SETTINGS = Object.freeze([
|
|
66
67
|
Object.freeze({ provider: DEEPSEEK_PROVIDER_TYPE, settingKey: DEEPSEEK_API_KEY_SETTING_KEY }),
|
|
68
|
+
Object.freeze({ provider: CLAUDE_API_PROVIDER_TYPE, settingKey: CLAUDE_API_KEY_SETTING_KEY }),
|
|
67
69
|
]);
|
|
68
70
|
|
|
69
71
|
// JSON bodies are shallow in practice; a deeper one is left as it is below
|