@oar-sh/oar 0.9.15 → 0.9.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/CHANGELOG.md +305 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +131 -33
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
- package/server/db-schema.mjs +115 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +162 -16
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +367 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +54 -5
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- 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 +37 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +567 -35
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +110 -17
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +19 -3
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +195 -18
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +932 -97
- package/server/server-runtime.mjs +203 -11
- package/server/services/about-service.mjs +1 -0
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +958 -31
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +554 -26
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CLAUDE_LIVE_FLAG_SETTINGS_CODE,
|
|
4
|
+
CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE,
|
|
5
|
+
classifyClaudeResultFailure,
|
|
6
|
+
claudeFailureGuidance,
|
|
7
|
+
} from './claude-turn-failure.mjs';
|
|
3
8
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
4
9
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
5
10
|
import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
|
|
6
11
|
import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
|
|
7
12
|
import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
13
|
+
import { claudeSearchTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
|
|
8
14
|
|
|
9
15
|
const PLAN_BOARD_ACTIONS = [
|
|
10
16
|
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
@@ -61,7 +67,53 @@ export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}
|
|
|
61
67
|
* mutable turn state, so the session process can run any number of turns
|
|
62
68
|
* (delivered or background-continuation) through one publisher.
|
|
63
69
|
*/
|
|
64
|
-
export function createClaudeTurnPublisher({
|
|
70
|
+
export function createClaudeTurnPublisher({
|
|
71
|
+
api,
|
|
72
|
+
dbg = () => {},
|
|
73
|
+
takeWorkflowRuns = null,
|
|
74
|
+
// A backend's own reading of a failed request (`{ code, guidance }` or
|
|
75
|
+
// null) and its name in the notes; null for Claude.
|
|
76
|
+
describeFailure = null,
|
|
77
|
+
runtimeLabel = 'Claude',
|
|
78
|
+
// The namespace of a backend's failure codes (DeepSeek: `deepseek`): its
|
|
79
|
+
// failures print as `relay.deepseek-<code>`, which the browser's buttons
|
|
80
|
+
// key on (relay-error-ctas.mjs), not as Claude's `relay.claude-<code>`.
|
|
81
|
+
failureCodePrefix = null,
|
|
82
|
+
// False for a backend that is not Claude's: no Claude plan windows or cost.
|
|
83
|
+
reportsPlanUsage = true,
|
|
84
|
+
// A backend's own usage route (DeepSeek: /api/deepseek-plan-usage), which
|
|
85
|
+
// takes the CLI's token totals per model in place of the Claude plan
|
|
86
|
+
// usage, and the relay's id of a model the CLI names.
|
|
87
|
+
usageRoute = null,
|
|
88
|
+
usageModelKey = null,
|
|
89
|
+
// The relay's id of a model the backend's CLI names ("deepseek-flash[1m]"
|
|
90
|
+
// → "deepseek-flash"); identity for Claude, whose `[1m]` is a real tier.
|
|
91
|
+
relayModel = (value) => value,
|
|
92
|
+
} = {}) {
|
|
93
|
+
/** `{ code, stableCode }` of a failure code, in the backend's namespace. */
|
|
94
|
+
function failureCodes(code) {
|
|
95
|
+
if (!failureCodePrefix) return { code, stableCode: `claude.${code}` };
|
|
96
|
+
const prefixed = String(code).startsWith(`${failureCodePrefix}-`) ? code : `${failureCodePrefix}-${String(code).replace(/_/g, '-')}`;
|
|
97
|
+
return { code: prefixed, stableCode: prefixed };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The Claude reading of a failed result, in the backend's namespace. A
|
|
102
|
+
* backend gets no Claude account or sign-in advice: the relay's default
|
|
103
|
+
* fits where the backend has none of its own.
|
|
104
|
+
*/
|
|
105
|
+
function classifyResultFailure(result) {
|
|
106
|
+
const classified = classifyClaudeResultFailure(result);
|
|
107
|
+
if (!failureCodePrefix) return classified;
|
|
108
|
+
return { ...failureCodes(classified.code), guidance: classified.code === 'model_not_found' ? classified.guidance : null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function backendFailure(errorText, status = null) {
|
|
112
|
+
if (typeof describeFailure !== 'function') return null;
|
|
113
|
+
const described = describeFailure(errorText, { status });
|
|
114
|
+
return described?.code ? described : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
65
117
|
function drainWorkflowRuns({ terminal = false } = {}) {
|
|
66
118
|
if (terminal || typeof takeWorkflowRuns !== 'function') return null;
|
|
67
119
|
return takeWorkflowRuns();
|
|
@@ -150,11 +202,14 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
150
202
|
*/
|
|
151
203
|
async function publishContextUsage({ message, state, model, sdkSessionId }) {
|
|
152
204
|
if (!state.contextUsage && !state.modelUsage) return;
|
|
205
|
+
const contextUsage = state.contextUsage && typeof state.contextUsage === 'object' && state.contextUsage.model
|
|
206
|
+
? { ...state.contextUsage, model: relayModel(state.contextUsage.model) }
|
|
207
|
+
: state.contextUsage;
|
|
153
208
|
await api('POST', '/api/claude-context-usage', {
|
|
154
209
|
conversationId: message.conversationId,
|
|
155
210
|
sdkSessionId,
|
|
156
|
-
model: state.responseModel || model || null,
|
|
157
|
-
contextUsage
|
|
211
|
+
model: relayModel(state.responseModel || model || null),
|
|
212
|
+
contextUsage,
|
|
158
213
|
modelUsage: state.modelUsage,
|
|
159
214
|
}).catch((error) => {
|
|
160
215
|
dbg('context usage publish failed', error?.message || String(error));
|
|
@@ -167,7 +222,12 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
167
222
|
* control request is unavailable, so the card still gets session cost.
|
|
168
223
|
* Advisory: a failure here must not disturb the turn's response.
|
|
169
224
|
*/
|
|
170
|
-
async function publishPlanUsage({ message, state, sdkSessionId }) {
|
|
225
|
+
async function publishPlanUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
|
|
226
|
+
if (usageRoute) {
|
|
227
|
+
await publishBackendUsage({ message, state, sdkSessionId, runId, resumedSessionId });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!reportsPlanUsage) return;
|
|
171
231
|
const totalCostUsd = state.result?.totalCostUsd ?? null;
|
|
172
232
|
// A cost with no per-model breakdown is still a usable fallback payload, so
|
|
173
233
|
// it has to count as a reason to publish or the card loses session cost on
|
|
@@ -184,6 +244,179 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
184
244
|
});
|
|
185
245
|
}
|
|
186
246
|
|
|
247
|
+
// The CLI process's own token totals and web search and fetch counts per
|
|
248
|
+
// model, counted from zero for each process (`runId`) and CLI session id
|
|
249
|
+
// (a `/clear` changes it and starts the CLI's totals over). `lastSession`
|
|
250
|
+
// is the CLI's running totals per model at the last result they were taken
|
|
251
|
+
// from, `lastSearches` its running search counts the same way, `results`
|
|
252
|
+
// how many results the process has reported, and
|
|
253
|
+
// `countedStates` the turn states already added: a turn published twice
|
|
254
|
+
// (a stopped turn's chain and the stream's end) counts once.
|
|
255
|
+
const run = { key: '', runId: '', results: 0, totals: new Map(), requests: new Map(), lastSession: new Map(), lastSearches: new Map() };
|
|
256
|
+
const countedStates = new WeakSet();
|
|
257
|
+
|
|
258
|
+
const addTotals = (into, entries) => {
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
const current = into.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
|
|
261
|
+
current.hitTokens += entry.hitTokens;
|
|
262
|
+
current.missTokens += entry.missTokens;
|
|
263
|
+
current.outputTokens += entry.outputTokens;
|
|
264
|
+
into.set(entry.model, current);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The process's running totals and request counts after this turn, as
|
|
270
|
+
* `{ totals: [{ model, hitTokens, missTokens, outputTokens }], requests:
|
|
271
|
+
* [{ model, searchRequests, fetchRequests }] }` without the all-zero
|
|
272
|
+
* models. Without a run id (a caller that has none) the CLI's own totals
|
|
273
|
+
* and the turn's counts, as before.
|
|
274
|
+
*
|
|
275
|
+
* A result's `modelUsage` is the CLI session's running total per model
|
|
276
|
+
* (sub-agents included), so the turn's share is what it grew by since the
|
|
277
|
+
* process's last result. The first result of a process spawned to resume
|
|
278
|
+
* a session carries that session's earlier turns too, which the relay has
|
|
279
|
+
* already booked: that turn's share is the result's `usage`, the turn's
|
|
280
|
+
* own count, under the turn's model (it leaves out what sub-agents spent
|
|
281
|
+
* in that one turn). A process spawned fresh, or a session the CLI
|
|
282
|
+
* started over, reports from zero: its first result is all its own. A
|
|
283
|
+
* result without `modelUsage` adds its `usage`. The baseline the growth
|
|
284
|
+
* is measured from only rises, field by field per model: a result's
|
|
285
|
+
* totals below it belong to an older turn published late.
|
|
286
|
+
*
|
|
287
|
+
* Web searches come from the same place as the tokens: the CLI counts
|
|
288
|
+
* them per model in `modelUsage`, the WebSearch tool's own API request
|
|
289
|
+
* included, which the message stream (`serverToolUse`) never shows. They
|
|
290
|
+
* grow, rise and resume like the tokens; the stream's count stands in
|
|
291
|
+
* where the tokens fall back to `usage` (no `modelUsage`, the resumed
|
|
292
|
+
* first result) and for a CLI that reports no search count. Fetches only
|
|
293
|
+
* the stream counts.
|
|
294
|
+
*/
|
|
295
|
+
function runningCounts(state, result, { runId, sessionId, resumedSessionId, modelKey }) {
|
|
296
|
+
const model = result.model || state.responseModel;
|
|
297
|
+
const turnRequests = claudeToolRequests(result.serverToolUse, modelKey);
|
|
298
|
+
if (!runId) return { totals: claudeUsageTotals(result.modelUsage, { usage: result.usage, model, ...modelKey }), requests: turnRequests };
|
|
299
|
+
if (run.runId !== runId) {
|
|
300
|
+
run.runId = runId;
|
|
301
|
+
run.results = 0;
|
|
302
|
+
}
|
|
303
|
+
const key = `${runId}\n${sessionId}`;
|
|
304
|
+
if (run.key !== key) {
|
|
305
|
+
run.key = key;
|
|
306
|
+
run.totals = new Map();
|
|
307
|
+
run.requests = new Map();
|
|
308
|
+
run.lastSession = new Map();
|
|
309
|
+
run.lastSearches = new Map();
|
|
310
|
+
}
|
|
311
|
+
if (!countedStates.has(state)) {
|
|
312
|
+
countedStates.add(state);
|
|
313
|
+
const hasModelUsage = Boolean(result.modelUsage) && typeof result.modelUsage === 'object' && !Array.isArray(result.modelUsage);
|
|
314
|
+
const turnUsage = claudeUsageTotals(null, { usage: result.usage, model, ...modelKey });
|
|
315
|
+
const sessionSearches = hasModelUsage ? claudeSearchTotals(result.modelUsage, modelKey) : [];
|
|
316
|
+
// The searches this turn adds, per model: the stream's unless the
|
|
317
|
+
// CLI's running count below says more reliably.
|
|
318
|
+
let turnSearches = turnRequests.map((entry) => ({ model: entry.model, searchRequests: entry.searchRequests }));
|
|
319
|
+
if (!hasModelUsage) {
|
|
320
|
+
// The CLI's running totals grew by the turn, as far as this goes.
|
|
321
|
+
addTotals(run.totals, turnUsage);
|
|
322
|
+
addTotals(run.lastSession, turnUsage);
|
|
323
|
+
for (const entry of turnSearches) {
|
|
324
|
+
run.lastSearches.set(entry.model, (run.lastSearches.get(entry.model) || 0) + entry.searchRequests);
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
const session = claudeUsageTotals(result.modelUsage, modelKey);
|
|
328
|
+
const resumed = run.results === 0 && Boolean(resumedSessionId) && sessionId === resumedSessionId;
|
|
329
|
+
if (resumed) {
|
|
330
|
+
addTotals(run.totals, turnUsage);
|
|
331
|
+
} else {
|
|
332
|
+
const grown = session.map((entry) => {
|
|
333
|
+
const last = run.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0 };
|
|
334
|
+
return {
|
|
335
|
+
model: entry.model,
|
|
336
|
+
hitTokens: Math.max(0, entry.hitTokens - last.hitTokens),
|
|
337
|
+
missTokens: Math.max(0, entry.missTokens - last.missTokens),
|
|
338
|
+
outputTokens: Math.max(0, entry.outputTokens - last.outputTokens),
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
addTotals(run.totals, grown);
|
|
342
|
+
if (sessionSearches.length) {
|
|
343
|
+
turnSearches = sessionSearches.map((entry) => ({
|
|
344
|
+
model: entry.model,
|
|
345
|
+
searchRequests: Math.max(0, entry.searchRequests - (run.lastSearches.get(entry.model) || 0)),
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// The baseline only ever rises: the CLI's totals are cumulative, so a
|
|
350
|
+
// snapshot below the last one is an older result arriving late (a
|
|
351
|
+
// stopped turn's publish chain behind a newer turn's), and taking it
|
|
352
|
+
// as the baseline would book the newer turn's growth a second time.
|
|
353
|
+
for (const entry of session) {
|
|
354
|
+
const last = run.lastSession.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
|
|
355
|
+
run.lastSession.set(entry.model, {
|
|
356
|
+
model: entry.model,
|
|
357
|
+
hitTokens: Math.max(last.hitTokens, entry.hitTokens),
|
|
358
|
+
missTokens: Math.max(last.missTokens, entry.missTokens),
|
|
359
|
+
outputTokens: Math.max(last.outputTokens, entry.outputTokens),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
for (const entry of sessionSearches) {
|
|
363
|
+
run.lastSearches.set(entry.model, Math.max(run.lastSearches.get(entry.model) || 0, entry.searchRequests));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const addRequests = (entryModel, searchRequests, fetchRequests) => {
|
|
367
|
+
const current = run.requests.get(entryModel) || { model: entryModel, searchRequests: 0, fetchRequests: 0 };
|
|
368
|
+
current.searchRequests += searchRequests;
|
|
369
|
+
current.fetchRequests += fetchRequests;
|
|
370
|
+
run.requests.set(entryModel, current);
|
|
371
|
+
};
|
|
372
|
+
for (const entry of turnSearches) addRequests(entry.model, entry.searchRequests, 0);
|
|
373
|
+
for (const entry of turnRequests) addRequests(entry.model, 0, entry.fetchRequests);
|
|
374
|
+
run.results += 1;
|
|
375
|
+
}
|
|
376
|
+
// Copies: the running entries go on changing after the post.
|
|
377
|
+
return {
|
|
378
|
+
totals: [...run.totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0).map((entry) => ({ ...entry })),
|
|
379
|
+
requests: [...run.requests.values()].filter((entry) => entry.searchRequests + entry.fetchRequests > 0).map((entry) => ({ ...entry })),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Ship the CLI process's token totals per model after a turn, with the
|
|
385
|
+
* CLI's session id, the id of the process (`runId`) and the time. They are
|
|
386
|
+
* the process's own running totals, counted from zero (runningCounts): the
|
|
387
|
+
* relay books what each report adds under the run id, a report below the
|
|
388
|
+
* last one under it is stale, and a new run id is booked in full. The
|
|
389
|
+
* process's running counts of web searches and fetches ride along as
|
|
390
|
+
* `requests`, per model, booked the same way. A caller without a run id
|
|
391
|
+
* sends the CLI's session totals and the turn's counts, which the relay
|
|
392
|
+
* books as an older worker's. `resumedSessionId` is the session the process
|
|
393
|
+
* was spawned to resume, if any. Advisory, like the plan usage.
|
|
394
|
+
*/
|
|
395
|
+
async function publishBackendUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
|
|
396
|
+
const result = state.result;
|
|
397
|
+
const claudeNativeSessionId = String(result?.sessionId || '').trim();
|
|
398
|
+
if (!usageRoute || !claudeNativeSessionId) return;
|
|
399
|
+
const modelKey = typeof usageModelKey === 'function' ? { modelKey: usageModelKey } : {};
|
|
400
|
+
const { totals, requests } = runningCounts(state, result, {
|
|
401
|
+
runId: String(runId || ''),
|
|
402
|
+
sessionId: claudeNativeSessionId,
|
|
403
|
+
resumedSessionId: String(resumedSessionId || '').trim(),
|
|
404
|
+
modelKey,
|
|
405
|
+
});
|
|
406
|
+
if (!totals.length && !requests.length) return;
|
|
407
|
+
await api('POST', usageRoute, {
|
|
408
|
+
conversationId: message.conversationId,
|
|
409
|
+
sdkSessionId,
|
|
410
|
+
claudeNativeSessionId,
|
|
411
|
+
...(runId ? { runId } : {}),
|
|
412
|
+
at: new Date().toISOString(),
|
|
413
|
+
totals,
|
|
414
|
+
...(requests.length ? { requests } : {}),
|
|
415
|
+
}).catch((error) => {
|
|
416
|
+
dbg('backend usage publish failed', error?.message || String(error));
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
187
420
|
/**
|
|
188
421
|
* Ship what the CLI reported about the subscription's usage limit: the
|
|
189
422
|
* relay warns before the limit and shows the reset once it is reached.
|
|
@@ -353,7 +586,8 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
353
586
|
async function publishErrorResult({ message, state, responseModel, consumedSteerIds = null }) {
|
|
354
587
|
const result = state.result;
|
|
355
588
|
const errorText = result.text
|
|
356
|
-
||
|
|
589
|
+
|| `${runtimeLabel} turn failed (${result.subtype || 'unknown error'}).`;
|
|
590
|
+
const described = backendFailure(errorText, result.apiErrorStatus);
|
|
357
591
|
await publishFinalStream(message, state.lastStreamedText);
|
|
358
592
|
// Refused at the usage limit: a relay that knows the kind pauses the turn
|
|
359
593
|
// until the reset instead of failing it.
|
|
@@ -382,7 +616,8 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
382
616
|
// Never built from the subtype alone: a refused turn ends with
|
|
383
617
|
// subtype 'success'. An account failure brings its own advice, since
|
|
384
618
|
// sending the message again cannot help before the account is fixed.
|
|
385
|
-
...
|
|
619
|
+
...classifyResultFailure(result),
|
|
620
|
+
...(described ? { ...failureCodes(described.code), guidance: described.guidance } : {}),
|
|
386
621
|
message: errorText,
|
|
387
622
|
failedAt: new Date().toISOString(),
|
|
388
623
|
queueMessageId: String(message.id || '') || null,
|
|
@@ -400,6 +635,24 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
400
635
|
});
|
|
401
636
|
return;
|
|
402
637
|
}
|
|
638
|
+
// Claude Code did not take a backend's connection settings (its key and
|
|
639
|
+
// endpoint pins): nothing was sent, and no sign-in changes that.
|
|
640
|
+
if (error?.code === CLAUDE_LIVE_FLAG_SETTINGS_CODE) {
|
|
641
|
+
await publishResponse(message, {
|
|
642
|
+
text: `System note: ${error.message} ${CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE}`,
|
|
643
|
+
model: null,
|
|
644
|
+
terminalError: {
|
|
645
|
+
kind: 'claude-turn-failed',
|
|
646
|
+
code: CLAUDE_LIVE_FLAG_SETTINGS_CODE,
|
|
647
|
+
stableCode: `claude.${CLAUDE_LIVE_FLAG_SETTINGS_CODE}`,
|
|
648
|
+
message: error.message,
|
|
649
|
+
guidance: CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE,
|
|
650
|
+
failedAt: new Date().toISOString(),
|
|
651
|
+
queueMessageId: String(message.id || '') || null,
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
403
656
|
// The conversation's folder is gone: the error names it and says what to
|
|
404
657
|
// do, which neither a retry nor a sign-in changes.
|
|
405
658
|
if (isWorkingDirectoryMissingError(error)) {
|
|
@@ -408,8 +661,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
408
661
|
model: null,
|
|
409
662
|
terminalError: {
|
|
410
663
|
kind: 'claude-turn-failed',
|
|
411
|
-
|
|
412
|
-
stableCode: `claude.${WORKING_DIRECTORY_MISSING_CODE}`,
|
|
664
|
+
...failureCodes(WORKING_DIRECTORY_MISSING_CODE),
|
|
413
665
|
message: error.message,
|
|
414
666
|
guidance: error.guidance,
|
|
415
667
|
failedAt: new Date().toISOString(),
|
|
@@ -418,16 +670,33 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
418
670
|
});
|
|
419
671
|
return;
|
|
420
672
|
}
|
|
421
|
-
const
|
|
673
|
+
const described = backendFailure(errorText);
|
|
674
|
+
if (described) {
|
|
675
|
+
await publishResponse(message, {
|
|
676
|
+
text: `System note: the ${runtimeLabel} turn failed (${errorText}). ${described.guidance}`,
|
|
677
|
+
model: null,
|
|
678
|
+
terminalError: {
|
|
679
|
+
kind: 'claude-turn-failed',
|
|
680
|
+
...failureCodes(described.code),
|
|
681
|
+
message: errorText,
|
|
682
|
+
guidance: described.guidance,
|
|
683
|
+
failedAt: new Date().toISOString(),
|
|
684
|
+
queueMessageId: String(message.id || '') || null,
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
// A backend's sign-in failures are its describeFailure's; Claude's sign-in
|
|
690
|
+
// advice is never a backend's.
|
|
691
|
+
const isAuthError = !failureCodePrefix && /authentication|logged in|login|credential|api key/i.test(errorText);
|
|
422
692
|
await publishResponse(message, {
|
|
423
693
|
text: isAuthError
|
|
424
694
|
? `System note: the Claude runtime could not authenticate (${errorText}). Log in again from Settings → Providers → Claude → Relogin (or run \`claude\` on the relay host), then retry.`
|
|
425
|
-
: `System note: the
|
|
695
|
+
: `System note: the ${runtimeLabel} turn failed (${errorText}). Retry or send a new message.`,
|
|
426
696
|
model: null,
|
|
427
697
|
terminalError: {
|
|
428
698
|
kind: 'claude-turn-failed',
|
|
429
|
-
|
|
430
|
-
stableCode: isAuthError ? 'claude.authentication_failed' : 'claude.turn-error',
|
|
699
|
+
...failureCodes(isAuthError ? 'authentication_failed' : 'turn-error'),
|
|
431
700
|
message: errorText,
|
|
432
701
|
...(isAuthError ? { guidance: claudeFailureGuidance('authentication_failed') } : {}),
|
|
433
702
|
failedAt: new Date().toISOString(),
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The CLI's token totals per model, out of a result message.
|
|
2
|
+
//
|
|
3
|
+
// A result's `modelUsage` is the CLI session's running total, not the turn's:
|
|
4
|
+
// a long-lived CLI reports more on every turn, a CLI resumed on a session
|
|
5
|
+
// starts from the totals that session's transcript saved (its first result
|
|
6
|
+
// already carries the earlier turns), and a `/clear` starts it over. Its
|
|
7
|
+
// `usage` is the turn's own, and the main agent loop's only (sub-agents and
|
|
8
|
+
// auxiliary calls are left out). The publisher turns these into the
|
|
9
|
+
// process's own running totals (claude-turn-publisher.mjs, runningCounts),
|
|
10
|
+
// which the relay books per process (plan-usage-service.mjs,
|
|
11
|
+
// recordDeepSeekUsageReport).
|
|
12
|
+
|
|
13
|
+
function count(value) {
|
|
14
|
+
const number = Number(value ?? 0);
|
|
15
|
+
return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function addTo(totals, model, hit, miss, output) {
|
|
19
|
+
const current = totals.get(model) || { model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
|
|
20
|
+
current.hitTokens += hit;
|
|
21
|
+
current.missTokens += miss;
|
|
22
|
+
current.outputTokens += output;
|
|
23
|
+
totals.set(model, current);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `[{ model, hitTokens, missTokens, outputTokens }]`: cache reads are hits,
|
|
28
|
+
* fresh and cache-creation input are misses. `modelKey` maps the CLI's name
|
|
29
|
+
* of a model to the relay's (DeepSeek: without the `[1m]` suffix); entries
|
|
30
|
+
* that map to the same id are summed. `usage` stands in only for a result
|
|
31
|
+
* without `modelUsage`, under the turn's model (pass `modelUsage` as null to
|
|
32
|
+
* read `usage` alone). Models with no tokens are left out.
|
|
33
|
+
*/
|
|
34
|
+
export function claudeUsageTotals(modelUsage, {
|
|
35
|
+
usage = null,
|
|
36
|
+
model = '',
|
|
37
|
+
modelKey = (value) => String(value ?? '').trim(),
|
|
38
|
+
} = {}) {
|
|
39
|
+
const keyOf = (value) => String(modelKey(value) ?? '').trim().toLowerCase();
|
|
40
|
+
const totals = new Map();
|
|
41
|
+
if (modelUsage && typeof modelUsage === 'object' && !Array.isArray(modelUsage)) {
|
|
42
|
+
for (const [name, entry] of Object.entries(modelUsage)) {
|
|
43
|
+
const key = keyOf(name);
|
|
44
|
+
if (!key || !entry || typeof entry !== 'object') continue;
|
|
45
|
+
addTo(
|
|
46
|
+
totals,
|
|
47
|
+
key,
|
|
48
|
+
count(entry.cacheReadInputTokens),
|
|
49
|
+
count(entry.inputTokens) + count(entry.cacheCreationInputTokens),
|
|
50
|
+
count(entry.outputTokens),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
const key = keyOf(model);
|
|
55
|
+
if (key && usage && typeof usage === 'object') {
|
|
56
|
+
addTo(
|
|
57
|
+
totals,
|
|
58
|
+
key,
|
|
59
|
+
count(usage.cache_read_input_tokens),
|
|
60
|
+
count(usage.input_tokens) + count(usage.cache_creation_input_tokens),
|
|
61
|
+
count(usage.output_tokens),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The CLI's running count of web searches per model, `[{ model,
|
|
70
|
+
* searchRequests }]`, out of a result's `modelUsage`: the CLI adds every API
|
|
71
|
+
* request's `server_tool_use.web_search_requests` to it, the internal request
|
|
72
|
+
* its WebSearch tool makes included, which never shows up in the message
|
|
73
|
+
* stream. Same `modelKey` and summing as the token totals. Only the entries
|
|
74
|
+
* that carry the count, zero included, so an empty list means the CLI does
|
|
75
|
+
* not report it. It has no fetch count.
|
|
76
|
+
*/
|
|
77
|
+
export function claudeSearchTotals(modelUsage, { modelKey = (value) => String(value ?? '').trim() } = {}) {
|
|
78
|
+
const searches = new Map();
|
|
79
|
+
if (!modelUsage || typeof modelUsage !== 'object' || Array.isArray(modelUsage)) return [];
|
|
80
|
+
for (const [name, entry] of Object.entries(modelUsage)) {
|
|
81
|
+
const key = String(modelKey(name) ?? '').trim().toLowerCase();
|
|
82
|
+
if (!key || !entry || typeof entry !== 'object' || !Number.isFinite(Number(entry.webSearchRequests ?? NaN))) continue;
|
|
83
|
+
const current = searches.get(key) || { model: key, searchRequests: 0 };
|
|
84
|
+
current.searchRequests += count(entry.webSearchRequests);
|
|
85
|
+
searches.set(key, current);
|
|
86
|
+
}
|
|
87
|
+
return [...searches.values()];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The turn's web searches and fetches per model, `[{ model, searchRequests,
|
|
92
|
+
* fetchRequests }]`, from the result's `serverToolUse` (the normalizer's
|
|
93
|
+
* per-turn count, not a running total): the message stream's, which leaves
|
|
94
|
+
* out the WebSearch tool's own request. Same `modelKey` and summing as the
|
|
95
|
+
* token totals; models with no requests are left out.
|
|
96
|
+
*/
|
|
97
|
+
export function claudeToolRequests(serverToolUse, { modelKey = (value) => String(value ?? '').trim() } = {}) {
|
|
98
|
+
const requests = new Map();
|
|
99
|
+
for (const entry of Array.isArray(serverToolUse) ? serverToolUse : []) {
|
|
100
|
+
const key = String(modelKey(entry?.model) ?? '').trim().toLowerCase();
|
|
101
|
+
if (!key || !entry || typeof entry !== 'object') continue;
|
|
102
|
+
const current = requests.get(key) || { model: key, searchRequests: 0, fetchRequests: 0 };
|
|
103
|
+
current.searchRequests += count(entry.webSearchRequests);
|
|
104
|
+
current.fetchRequests += count(entry.webFetchRequests);
|
|
105
|
+
requests.set(key, current);
|
|
106
|
+
}
|
|
107
|
+
return [...requests.values()].filter((entry) => entry.searchRequests + entry.fetchRequests > 0);
|
|
108
|
+
}
|
|
@@ -198,6 +198,57 @@ export function createSdkMessageNormalizer() {
|
|
|
198
198
|
// The `error` of the turn's last main-thread assistant message: the SDK's
|
|
199
199
|
// name for the failure a refused turn ended on.
|
|
200
200
|
let lastAssistantError = '';
|
|
201
|
+
// The turn's web searches and fetches, per API message id: the API counts
|
|
202
|
+
// them in each message's `usage.server_tool_use`, and the SDK repeats a
|
|
203
|
+
// message's usage with every content block it delivers, so a message is
|
|
204
|
+
// counted once. Summed per model on the result; DeepSeek bills them per
|
|
205
|
+
// request (shared/deepseek-pricing.mjs).
|
|
206
|
+
const serverToolUse = new Map(); // message id -> { model, webSearchRequests, webFetchRequests }
|
|
207
|
+
let unnamedServerToolUseMessages = 0;
|
|
208
|
+
|
|
209
|
+
function requestCount(value) {
|
|
210
|
+
const number = Number(value ?? 0);
|
|
211
|
+
return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function noteServerToolUse(messageId, message) {
|
|
215
|
+
const use = message?.usage?.server_tool_use;
|
|
216
|
+
if (!use || typeof use !== 'object') return;
|
|
217
|
+
const webSearchRequests = requestCount(use.web_search_requests);
|
|
218
|
+
const webFetchRequests = requestCount(use.web_fetch_requests);
|
|
219
|
+
if (!webSearchRequests && !webFetchRequests) return;
|
|
220
|
+
const key = messageId || `unnamed-${++unnamedServerToolUseMessages}`;
|
|
221
|
+
const current = serverToolUse.get(key)
|
|
222
|
+
|| { model: String(message?.model || '').trim() || initModel, webSearchRequests: 0, webFetchRequests: 0 };
|
|
223
|
+
// A message's usage is the message's total, so repeats do not add up.
|
|
224
|
+
current.webSearchRequests = Math.max(current.webSearchRequests, webSearchRequests);
|
|
225
|
+
current.webFetchRequests = Math.max(current.webFetchRequests, webFetchRequests);
|
|
226
|
+
serverToolUse.set(key, current);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The turn's `[{ model, webSearchRequests, webFetchRequests }]`, and the count starts over. */
|
|
230
|
+
function takeServerToolUse(resultUsage) {
|
|
231
|
+
const byModel = new Map();
|
|
232
|
+
const add = (model, webSearchRequests, webFetchRequests) => {
|
|
233
|
+
const key = String(model || '').trim() || initModel;
|
|
234
|
+
const current = byModel.get(key) || { model: key, webSearchRequests: 0, webFetchRequests: 0 };
|
|
235
|
+
current.webSearchRequests += webSearchRequests;
|
|
236
|
+
current.webFetchRequests += webFetchRequests;
|
|
237
|
+
byModel.set(key, current);
|
|
238
|
+
};
|
|
239
|
+
for (const entry of serverToolUse.values()) add(entry.model, entry.webSearchRequests, entry.webFetchRequests);
|
|
240
|
+
serverToolUse.clear();
|
|
241
|
+
unnamedServerToolUseMessages = 0;
|
|
242
|
+
// A result without assistant messages seen still carries the turn's
|
|
243
|
+
// counts in its own usage, under the turn's model.
|
|
244
|
+
const use = resultUsage?.server_tool_use;
|
|
245
|
+
if (!byModel.size && use && typeof use === 'object') {
|
|
246
|
+
const webSearchRequests = requestCount(use.web_search_requests);
|
|
247
|
+
const webFetchRequests = requestCount(use.web_fetch_requests);
|
|
248
|
+
if (webSearchRequests || webFetchRequests) add(initModel, webSearchRequests, webFetchRequests);
|
|
249
|
+
}
|
|
250
|
+
return [...byModel.values()];
|
|
251
|
+
}
|
|
201
252
|
|
|
202
253
|
function threadKeyFor(parentToolUseId) {
|
|
203
254
|
return String(parentToolUseId || '').trim() || 'main';
|
|
@@ -592,6 +643,7 @@ export function createSdkMessageNormalizer() {
|
|
|
592
643
|
const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
|
|
593
644
|
const messageId = String(sdkMessage?.message?.id || '').trim();
|
|
594
645
|
if (!parentToolUseId) lastAssistantError = String(sdkMessage?.error || '').trim();
|
|
646
|
+
noteServerToolUse(messageId, sdkMessage?.message);
|
|
595
647
|
return actionsForAssistantMessage(content, parentToolUseId, messageId);
|
|
596
648
|
}
|
|
597
649
|
|
|
@@ -683,6 +735,9 @@ export function createSdkMessageNormalizer() {
|
|
|
683
735
|
totalCostUsd: Number.isFinite(Number(sdkMessage.total_cost_usd))
|
|
684
736
|
? Number(sdkMessage.total_cost_usd)
|
|
685
737
|
: null,
|
|
738
|
+
// This turn's web searches and fetches per model (not running
|
|
739
|
+
// totals): `[{ model, webSearchRequests, webFetchRequests }]`.
|
|
740
|
+
serverToolUse: takeServerToolUse(sdkMessage.usage),
|
|
686
741
|
},
|
|
687
742
|
}];
|
|
688
743
|
}
|
|
@@ -392,8 +392,11 @@ export function isCopilotQuotaError(data) {
|
|
|
392
392
|
* "Open Check Usage" pointer shipped in 37d2899. The structured quota verdict
|
|
393
393
|
* is passed to that classifier as a hint rather than smuggled in as forged
|
|
394
394
|
* prose, so a quota error is non-retryable however GitHub worded it.
|
|
395
|
+
*
|
|
396
|
+
* `provider` is the worker's provider (`openai` for a BYOK worker): the note
|
|
397
|
+
* names it, so an OpenAI turn does not read as a Copilot one.
|
|
395
398
|
*/
|
|
396
|
-
export function classifyCopilotSessionError(data) {
|
|
399
|
+
export function classifyCopilotSessionError(data, { provider = '' } = {}) {
|
|
397
400
|
const detail = String(data?.message || '').trim() || 'the Copilot runtime reported a session error';
|
|
398
401
|
const quota = isCopilotQuotaError(data);
|
|
399
402
|
const probe = new Error(detail);
|
|
@@ -420,7 +423,7 @@ export function classifyCopilotSessionError(data) {
|
|
|
420
423
|
return {
|
|
421
424
|
code,
|
|
422
425
|
stableCode: `copilot.${code}`,
|
|
423
|
-
text:
|
|
426
|
+
text: turnFailedText(detail, provider),
|
|
424
427
|
// Without one the relay adds its default, which advises a retry.
|
|
425
428
|
guidance: COPILOT_TURN_FAILURE_GUIDANCE,
|
|
426
429
|
detail,
|
|
@@ -490,6 +493,17 @@ export function isCopilotAuthError(error) {
|
|
|
490
493
|
return AUTH_ERROR_RE.test(String(error?.message || error || ''));
|
|
491
494
|
}
|
|
492
495
|
|
|
496
|
+
/**
|
|
497
|
+
* The generic failure note. On the SDK engine the provider is GitHub Copilot or,
|
|
498
|
+
* for a BYOK worker, OpenAI; the note names the one the turn ran on.
|
|
499
|
+
*/
|
|
500
|
+
function turnFailedText(detail, provider) {
|
|
501
|
+
const subject = String(provider || '').trim().toLowerCase() === 'openai'
|
|
502
|
+
? 'the OpenAI turn on the Copilot SDK engine'
|
|
503
|
+
: 'the Copilot turn';
|
|
504
|
+
return `System note: ${subject} failed (${detail}). Retry or send a new message.`;
|
|
505
|
+
}
|
|
506
|
+
|
|
493
507
|
/**
|
|
494
508
|
* What the relay appends to a failed Copilot turn in place of its own default
|
|
495
509
|
* (which advises a retry). The worker deals with its runtime itself: a delivered
|
|
@@ -509,7 +523,7 @@ export const COPILOT_TURN_CONTINUE_GUIDANCE =
|
|
|
509
523
|
* than a `session.error` event. Auth gets its own branch because the fix is a
|
|
510
524
|
* user action on the relay host, not a retry.
|
|
511
525
|
*/
|
|
512
|
-
export function classifyCopilotTurnException(error) {
|
|
526
|
+
export function classifyCopilotTurnException(error, { provider = '' } = {}) {
|
|
513
527
|
const detail = String(error?.message || error || '').trim() || 'unknown error';
|
|
514
528
|
// The conversation's folder is gone; the message names it.
|
|
515
529
|
if (isWorkingDirectoryMissingError(error)) {
|
|
@@ -563,7 +577,7 @@ export function classifyCopilotTurnException(error) {
|
|
|
563
577
|
return {
|
|
564
578
|
code: 'turn-error',
|
|
565
579
|
stableCode: 'copilot.turn-error',
|
|
566
|
-
text:
|
|
580
|
+
text: turnFailedText(detail, provider),
|
|
567
581
|
guidance: COPILOT_TURN_FAILURE_GUIDANCE,
|
|
568
582
|
detail,
|
|
569
583
|
};
|