@centerforagenticai/pi-multi-account 0.1.4 → 0.1.6

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.
Files changed (35) hide show
  1. package/README.md +17 -3
  2. package/package.json +5 -5
  3. package/packages/pi-anthropic-oauth/package.json +2 -2
  4. package/packages/pi-anthropic-oauth/src/stream.ts +26 -8
  5. package/packages/pi-anthropic-oauth/src/transport-activity.ts +59 -0
  6. package/packages/pi-antigravity/package.json +2 -2
  7. package/packages/pi-antigravity/src/models/discovery.ts +2 -1
  8. package/packages/pi-antigravity/src/models/grouping.ts +12 -10
  9. package/packages/pi-antigravity/src/models/models.ts +11 -4
  10. package/src/account-group-failure.ts +149 -0
  11. package/src/account-group-members.ts +139 -0
  12. package/src/anthropic-adaptive-stream.ts +18 -9
  13. package/src/anthropic-alias-stream.ts +13 -68
  14. package/src/codex-adapter.ts +78 -50
  15. package/src/commands.ts +3 -3
  16. package/src/config.ts +197 -2
  17. package/src/diagnostic-store.ts +17 -15
  18. package/src/diagnostics.ts +97 -35
  19. package/src/host-final-stop-message.ts +20 -118
  20. package/src/index.ts +452 -87
  21. package/src/logical-dispatch.ts +12 -13
  22. package/src/logical-provider.ts +1282 -355
  23. package/src/model-fallback-policy.ts +384 -0
  24. package/src/models-declaration.ts +15 -8
  25. package/src/public-assistant-projection.ts +163 -0
  26. package/src/recovery-engine.ts +648 -110
  27. package/src/recovery-plan.ts +7 -1
  28. package/src/recovery-send-evidence.ts +29 -0
  29. package/src/refusal-advice.ts +139 -0
  30. package/src/routing.ts +4 -8
  31. package/src/runtime-state.ts +7 -0
  32. package/src/shared-usage.ts +21 -4
  33. package/src/upstream-anthropic.ts +12 -4
  34. package/src/upstream-antigravity.ts +2 -38
  35. package/src/usage-fetch.ts +42 -59
@@ -12,7 +12,11 @@ import { randomUUID } from "node:crypto";
12
12
  import { dirname, join } from "node:path";
13
13
  import {
14
14
  sanitizeDiagnosticText,
15
+ projectResponseHeaders,
16
+ isHeaderDiagnostic,
17
+ HEADER_DIAGNOSTIC_MESSAGE,
15
18
  sanitizeForJson,
19
+ retainDiagnosticEvents,
16
20
  type DiagnosticEvent,
17
21
  type DiagnosticLevel,
18
22
  type DiagnosticPersistence,
@@ -84,13 +88,16 @@ function projectEvent(value: unknown): DiagnosticEvent | undefined {
84
88
  if (typeof candidate.level !== "string" || !LEVELS.has(candidate.level as DiagnosticLevel)) {
85
89
  return undefined;
86
90
  }
87
- const fields = projectFields(candidate.fields);
91
+ const fields = isHeaderDiagnostic(candidate.category, candidate.message)
92
+ ? projectResponseHeaders(candidate.fields)
93
+ : projectFields(candidate.fields);
88
94
  if (fields === undefined) return undefined;
89
95
  return {
90
96
  timestampMs: candidate.timestampMs,
91
97
  level: candidate.level as DiagnosticLevel,
92
98
  category: sanitizeDiagnosticText(candidate.category),
93
- message: sanitizeDiagnosticText(candidate.message),
99
+ message: isHeaderDiagnostic(candidate.category, candidate.message)
100
+ ? HEADER_DIAGNOSTIC_MESSAGE : sanitizeDiagnosticText(candidate.message),
94
101
  fields,
95
102
  };
96
103
  }
@@ -110,20 +117,15 @@ function parseCompleteEvents(raw: string): readonly DiagnosticEvent[] {
110
117
  return events;
111
118
  }
112
119
 
113
- function newestLinesWithin(
120
+ function priorityLinesWithin(
114
121
  events: readonly DiagnosticEvent[],
115
122
  budgetBytes: number,
116
123
  ): string {
117
- const retained: string[] = [];
118
- let used = 0;
119
- for (let index = events.length - 1; index >= 0; index -= 1) {
120
- const line = `${JSON.stringify(events[index])}\n`;
121
- const bytes = Buffer.byteLength(line, "utf8");
122
- if (bytes > budgetBytes - used) break;
123
- retained.push(line);
124
- used += bytes;
125
- }
126
- return retained.reverse().join("");
124
+ return retainDiagnosticEvents(
125
+ events,
126
+ budgetBytes,
127
+ (event) => Buffer.byteLength(`${JSON.stringify(event)}\n`, "utf8"),
128
+ ).map((event) => `${JSON.stringify(event)}\n`).join("");
127
129
  }
128
130
 
129
131
  /** Machine-global, credential-free, bounded diagnostic event history. */
@@ -164,7 +166,7 @@ export class DiagnosticStore implements DiagnosticPersistence {
164
166
  : 100;
165
167
  if (safeLimit === 0) return [];
166
168
  try {
167
- return parseCompleteEvents(readFileSync(this.#path, "utf8")).slice(-safeLimit);
169
+ return retainDiagnosticEvents(parseCompleteEvents(readFileSync(this.#path, "utf8")), safeLimit);
168
170
  } catch {
169
171
  return [];
170
172
  }
@@ -251,7 +253,7 @@ export class DiagnosticStore implements DiagnosticPersistence {
251
253
  Math.floor(this.#maxBytes / 2),
252
254
  this.#maxBytes - tailBytes,
253
255
  );
254
- const compacted = newestLinesWithin(events, targetBytes);
256
+ const compacted = priorityLinesWithin(events, targetBytes);
255
257
  const encoded = compacted + tail;
256
258
  if (Buffer.byteLength(encoded, "utf8") > this.#maxBytes) return false;
257
259
  temporaryPath = `${this.#path}.${process.pid}.${randomUUID()}.tmp`;
@@ -2,17 +2,38 @@ const MAX_DIAGNOSTIC_LENGTH = 512;
2
2
  const REDACTED = "[REDACTED]";
3
3
  const SENSITIVE_NAME_FRAGMENT = /(?:authorization|authentication|auth|token|credential|secret|cookie|apikey)/;
4
4
  const PRIVATE_REASONING_KEY = /^(?:thinking|reasoning)/;
5
- const TOKEN_QUOTA_REMAINING_HEADERS = new Set([
6
- "x-ratelimit-remaining-tokens",
7
- "x-ratelimit-tokens-remaining",
8
- "anthropic-ratelimit-tokens-remaining",
5
+ // Closed response observations: unknown names (including identifiers) never cross
6
+ // the callback or retained-diagnostic boundary, even when their value looks safe.
7
+ const COUNT_HEADERS = new Set([
8
+ "x-ratelimit-remaining", "x-ratelimit-remaining-requests", "x-ratelimit-requests-remaining",
9
+ "x-ratelimit-remaining-tokens", "x-ratelimit-tokens-remaining",
10
+ "anthropic-ratelimit-requests-remaining", "anthropic-ratelimit-tokens-remaining",
11
+ "x-ratelimit-limit-requests", "x-ratelimit-limit-tokens",
12
+ "anthropic-ratelimit-requests-limit", "anthropic-ratelimit-tokens-limit",
13
+ "retry-after-ms",
9
14
  ]);
10
- const TOKEN_QUOTA_DURATION_RESET_HEADERS = new Set([
11
- "x-ratelimit-reset-tokens",
15
+ const DURATION_RESET_HEADERS = new Set(["x-ratelimit-reset-requests", "x-ratelimit-reset-tokens"]);
16
+ const ABSOLUTE_RESET_HEADERS = new Set([
17
+ "x-ratelimit-reset", "anthropic-ratelimit-requests-reset", "anthropic-ratelimit-tokens-reset",
18
+ "x-codex-primary-reset-at", "x-codex-secondary-reset-at",
12
19
  ]);
13
- const TOKEN_QUOTA_ABSOLUTE_RESET_HEADERS = new Set([
14
- "anthropic-ratelimit-tokens-reset",
20
+ const SECONDS_HEADERS = new Set([
21
+ "x-codex-primary-reset-after-seconds", "x-codex-secondary-reset-after-seconds",
15
22
  ]);
23
+ const PERCENT_HEADERS = new Set(["x-codex-primary-used-percent", "x-codex-secondary-used-percent"]);
24
+ const UTILIZATION_HEADERS = new Set([
25
+ "anthropic-ratelimit-unified-5h-utilization", "anthropic-ratelimit-unified-7d-utilization",
26
+ "anthropic-ratelimit-unified-7d-sonnet-utilization", "anthropic-ratelimit-unified-7d-opus-utilization",
27
+ ]);
28
+ const STATUS_HEADERS = new Set(["anthropic-ratelimit-unified-status"]);
29
+ const RESPONSE_HEADER_NAMES = new Set([
30
+ ...COUNT_HEADERS, ...DURATION_RESET_HEADERS, ...ABSOLUTE_RESET_HEADERS,
31
+ ...SECONDS_HEADERS, ...PERCENT_HEADERS, ...UTILIZATION_HEADERS, ...STATUS_HEADERS, "retry-after",
32
+ ]);
33
+ export const HEADER_DIAGNOSTIC_MESSAGE = "Provider response headers observed.";
34
+ export function isHeaderDiagnostic(category: unknown, message: unknown): boolean {
35
+ return category === "provider.response" || message === HEADER_DIAGNOSTIC_MESSAGE;
36
+ }
16
37
 
17
38
  export type DiagnosticLevel = "info" | "warning" | "error";
18
39
 
@@ -128,22 +149,22 @@ function validAbsoluteReset(value: string): boolean {
128
149
  return Number.isFinite(epochMs) && epochMs >= 0 && new Date(epochMs).toUTCString() === value;
129
150
  }
130
151
 
131
- function validatedTokenQuotaValue(name: string, value: unknown): string | undefined {
152
+ function validatedResponseHeaderValue(name: string, value: unknown): string | undefined {
132
153
  const normalized = name.toLowerCase();
133
- const isTokenQuotaHeader = TOKEN_QUOTA_REMAINING_HEADERS.has(normalized)
134
- || TOKEN_QUOTA_DURATION_RESET_HEADERS.has(normalized)
135
- || TOKEN_QUOTA_ABSOLUTE_RESET_HEADERS.has(normalized);
136
- if (!isTokenQuotaHeader || typeof value !== "string") return undefined;
137
-
138
- const trimmed = value.trim();
139
- if (value !== trimmed) return undefined;
140
- if (TOKEN_QUOTA_REMAINING_HEADERS.has(normalized)) {
141
- return validTokenCount(trimmed) ? trimmed : undefined;
154
+ if (!RESPONSE_HEADER_NAMES.has(normalized) || typeof value !== "string" || value !== value.trim()) return undefined;
155
+ if (COUNT_HEADERS.has(normalized)) return validTokenCount(value) ? value : undefined;
156
+ if (DURATION_RESET_HEADERS.has(normalized)) {
157
+ return validDuration(value) || validAbsoluteReset(value) ? value : undefined;
142
158
  }
143
- if (TOKEN_QUOTA_DURATION_RESET_HEADERS.has(normalized)) {
144
- return validDuration(trimmed) || validAbsoluteReset(trimmed) ? trimmed : undefined;
159
+ if (ABSOLUTE_RESET_HEADERS.has(normalized)) return validAbsoluteReset(value) ? value : undefined;
160
+ if (STATUS_HEADERS.has(normalized)) {
161
+ return ["allowed", "allowed_warning", "rejected"].includes(value) ? value : undefined;
145
162
  }
146
- return validAbsoluteReset(trimmed) ? trimmed : undefined;
163
+ if (normalized === "retry-after" && validAbsoluteReset(value)) return value;
164
+ if (value.length > 32 || !/^\d+(?:\.\d+)?$/.test(value)) return undefined;
165
+ const number = Number(value);
166
+ const maximum = PERCENT_HEADERS.has(normalized) ? 100 : UTILIZATION_HEADERS.has(normalized) ? 1 : Number.MAX_SAFE_INTEGER / 1000;
167
+ return Number.isFinite(number) && number >= 0 && number <= maximum ? value : undefined;
147
168
  }
148
169
 
149
170
  /** Sanitizes and bounds one upstream-derived diagnostic value before storage. */
@@ -151,11 +172,20 @@ export function sanitizeDiagnosticText(value: unknown): string {
151
172
  return bound(redactDiagnosticText(value));
152
173
  }
153
174
 
154
- /** Sensitive headers are replaced wholesale except for strictly validated token-quota observations. */
175
+ /** Compatibility scalar API; unsupported names and malformed observations are redacted. */
155
176
  export function sanitizeHeaderValue(name: string, value: unknown): string {
156
- const tokenQuotaValue = validatedTokenQuotaValue(name, value);
157
- if (tokenQuotaValue !== undefined) return tokenQuotaValue;
158
- return isSensitiveName(name) ? REDACTED : sanitizeDiagnosticText(value);
177
+ return validatedResponseHeaderValue(name, value) ?? REDACTED;
178
+ }
179
+
180
+ /** Copy only named, validated quota/retry facts. Never retain caller keys or prose. */
181
+ export function projectResponseHeaders(headers: unknown): Record<string, string> {
182
+ const projected: Record<string, string> = {};
183
+ if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return projected;
184
+ for (const [name, value] of Object.entries(headers)) {
185
+ const validated = validatedResponseHeaderValue(name, value);
186
+ if (validated !== undefined) projected[name.toLowerCase()] = validated;
187
+ }
188
+ return projected;
159
189
  }
160
190
 
161
191
  function sanitizeFields(value: unknown, depth = 0): unknown {
@@ -202,6 +232,35 @@ export function sanitizedJson(value: unknown): string {
202
232
  return JSON.stringify(sanitizeForJson(value));
203
233
  }
204
234
 
235
+ /**
236
+ * Reserve the budget for newest failures/routing facts before routine observations.
237
+ * Both classes share one hard bound; a priority flood evicts older priority facts.
238
+ * Return append order, not priority order or wall-clock order. Oversized records
239
+ * are skipped so one record cannot prevent smaller, useful facts from fitting.
240
+ */
241
+ export function retainDiagnosticEvents(
242
+ events: readonly DiagnosticEvent[],
243
+ budget: number,
244
+ sizeOf: (event: DiagnosticEvent) => number = () => 1,
245
+ ): readonly DiagnosticEvent[] {
246
+ const retained = new Set<number>();
247
+ const sizes = events.map(sizeOf);
248
+ let used = 0;
249
+ for (const priority of [true, false]) {
250
+ for (let index = events.length - 1; index >= 0; index -= 1) {
251
+ const event = events[index]!;
252
+ const isPriority = event.level !== "info" || event.category === "routing" ||
253
+ event.category.startsWith("routing.") || event.category === "logical.provider";
254
+ if (isPriority !== priority) continue;
255
+ const size = sizes[index]!;
256
+ if (size > budget - used) continue;
257
+ retained.add(index);
258
+ used += size;
259
+ }
260
+ }
261
+ return events.filter((_event, index) => retained.has(index));
262
+ }
263
+
205
264
  export class DiagnosticLog {
206
265
  readonly #events: DiagnosticEvent[] = [];
207
266
  readonly #maxEvents: number;
@@ -224,7 +283,9 @@ export class DiagnosticLog {
224
283
  for (const event of persisted.slice(-this.#maxEvents)) {
225
284
  this.#events.push(Object.freeze({
226
285
  ...event,
227
- fields: Object.freeze({ ...event.fields }),
286
+ message: isHeaderDiagnostic(event.category, event.message) ? HEADER_DIAGNOSTIC_MESSAGE : event.message,
287
+ fields: Object.freeze(isHeaderDiagnostic(event.category, event.message)
288
+ ? projectResponseHeaders(event.fields) : { ...event.fields }),
228
289
  }));
229
290
  }
230
291
  } catch {
@@ -239,18 +300,22 @@ export class DiagnosticLog {
239
300
  fields: Readonly<Record<string, unknown>> = {},
240
301
  ): DiagnosticEvent {
241
302
  const safeFields: Record<string, string> = {};
242
- for (const [key, value] of Object.entries(fields)) {
303
+ const projectedFields = isHeaderDiagnostic(category, message) ? projectResponseHeaders(fields) : fields;
304
+ for (const [key, value] of Object.entries(projectedFields)) {
243
305
  safeFields[sanitizeDiagnosticText(key)] = sanitizeDiagnosticText(value);
244
306
  }
245
307
  const event = Object.freeze({
246
308
  timestampMs: this.#now(),
247
309
  level,
248
310
  category: sanitizeDiagnosticText(category),
249
- message: sanitizeDiagnosticText(message),
311
+ message: isHeaderDiagnostic(category, message) ? HEADER_DIAGNOSTIC_MESSAGE : sanitizeDiagnosticText(message),
250
312
  fields: Object.freeze(safeFields),
251
313
  });
252
314
  this.#events.push(event);
253
- if (this.#events.length > this.#maxEvents) this.#events.splice(0, this.#events.length - this.#maxEvents);
315
+ if (this.#events.length > this.#maxEvents) {
316
+ const retained = retainDiagnosticEvents(this.#events, this.#maxEvents);
317
+ this.#events.splice(0, this.#events.length, ...retained);
318
+ }
254
319
  try {
255
320
  this.#persistence?.append(event);
256
321
  } catch {
@@ -273,16 +338,13 @@ export class DiagnosticLog {
273
338
  }
274
339
 
275
340
  recordHeaders(category: string, headers: Readonly<Record<string, unknown>>): DiagnosticEvent {
276
- const safeHeaders: Record<string, string> = {};
277
- for (const [name, value] of Object.entries(headers)) {
278
- safeHeaders[sanitizeDiagnosticText(name)] = sanitizeHeaderValue(name, value);
279
- }
280
- return this.record("info", category, "Provider response headers observed.", safeHeaders);
341
+ return this.record("info", category, HEADER_DIAGNOSTIC_MESSAGE, projectResponseHeaders(headers));
281
342
  }
282
343
 
283
344
  recent(limit = 20): readonly DiagnosticEvent[] {
284
345
  const safeLimit = Number.isSafeInteger(limit) ? Math.max(0, Math.min(limit, 100)) : 20;
285
- return this.#events.slice(-safeLimit).map((event) => ({ ...event, fields: { ...event.fields } }));
346
+ return retainDiagnosticEvents(this.#events, safeLimit)
347
+ .map((event) => ({ ...event, fields: { ...event.fields } }));
286
348
  }
287
349
 
288
350
  formatRecent(limit = 20): string {
@@ -1,124 +1,26 @@
1
- import {
2
- isContextOverflow,
3
- isRetryableAssistantError,
4
- type AssistantMessage,
5
- } from "@earendil-works/pi-ai";
6
- import { sanitizeDiagnosticText } from "./diagnostics.js";
1
+ import { isContextOverflow, isRetryableAssistantError, type AssistantMessage } from "@earendil-works/pi-ai";
7
2
 
8
- /** Fixed fallbacks for a structured provider stop; none matches a host re-dispatch pattern. */
3
+ /** Fixed public reasons: provider prose and unknown reason payloads are never retained. */
9
4
  export const REFUSAL_FALLBACK_MESSAGE = "The model refused to complete the request";
10
- export const UNKNOWN_STOP_FALLBACK_MESSAGE =
11
- "Provider stopped with an unrecognized stop reason";
5
+ export const UNKNOWN_STOP_FALLBACK_MESSAGE = "Provider stopped with an unrecognized stop reason";
6
+ // Kept for source compatibility; reasons are now entirely withheld, not reworded.
7
+ export const PARTLY_WITHHELD_MARKER = " (partly withheld)";
12
8
 
13
- /** Upper bound on the normalized stop reason named in a reworded message. */
14
- const MAX_NAMED_REASON_LENGTH = 64;
15
-
16
- /**
17
- * Whether the pinned host would re-dispatch an error terminal carrying `text`.
18
- *
19
- * The host re-dispatches when either retry predicate matches the prose:
20
- * `AgentSession._isRetryableError` runs pi-ai `isRetryableAssistantError`, and
21
- * `_checkCompaction` runs `isContextOverflow` before compacting and retrying once
22
- * (pi-coding-agent dist/core/agent-session.js, both from `@earendil-works/pi-ai`).
23
- * An unreadable predicate result counts as a match, so the caller keeps looking
24
- * for a safer form.
25
- */
26
- function hostWouldRedispatch(message: AssistantMessage, text: string): boolean {
27
- try {
28
- const probe = { ...message, errorMessage: text };
29
- return isRetryableAssistantError(probe) || isContextOverflow(probe, 0);
30
- } catch {
31
- return true;
32
- }
33
- }
34
-
35
- function namedReasonMessage(reason: string): string {
36
- return `Provider stopped (reason: ${reason})`;
37
- }
38
-
39
- /**
40
- * Normalizes a bounded, sanitized raw stop reason into plain words: every run of
41
- * non-alphanumeric characters (underscores included) becomes one space, and the
42
- * result is capped at a word boundary where possible.
43
- */
44
- function normalizedReasonWords(rawStopReason: unknown): string[] {
45
- if (typeof rawStopReason !== "string") return [];
46
- const words = sanitizeDiagnosticText(rawStopReason)
47
- .replace(/[^A-Za-z0-9]+/g, " ")
48
- .trim()
49
- .split(" ")
50
- .filter((word) => word.length > 0);
51
- const kept: string[] = [];
52
- let length = 0;
53
- for (const word of words) {
54
- const next = length === 0 ? word.length : length + 1 + word.length;
55
- if (next > MAX_NAMED_REASON_LENGTH) {
56
- if (kept.length === 0) kept.push(word.slice(0, MAX_NAMED_REASON_LENGTH));
57
- break;
58
- }
59
- kept.push(word);
60
- length = next;
61
- }
62
- return kept;
63
- }
64
-
65
- /**
66
- * Names an unknown stop reason in a message neither host predicate acts on.
67
- *
68
- * The first form keeps every normalized word. When that still matches a host
69
- * predicate (a reason containing "overloaded" or "timeout", say), the second
70
- * form admits words in order and drops each one whose addition would make the
71
- * message match. Every admitted prefix was probed, so the result is host-final
72
- * by construction. An empty result yields `undefined`.
73
- */
74
- function unknownStopNamingReason(
75
- message: AssistantMessage,
76
- rawStopReason: unknown,
77
- ): string | undefined {
78
- const words = normalizedReasonWords(rawStopReason);
79
- if (words.length === 0) return undefined;
80
- const reworded = namedReasonMessage(words.join(" "));
81
- if (!hostWouldRedispatch(message, reworded)) return reworded;
82
- const kept: string[] = [];
83
- for (const word of words) {
84
- const tentative = namedReasonMessage([...kept, word].join(" "));
85
- if (!hostWouldRedispatch(message, tentative)) kept.push(word);
86
- }
87
- return kept.length === 0 ? undefined : namedReasonMessage(kept.join(" "));
9
+ /** Probe pinned host predicates without publishing the provider DTO. */
10
+ export function hostWouldRedispatch(message: AssistantMessage, text: string): boolean {
11
+ try {
12
+ // Private predicate probe only; this object is never published or retained.
13
+ const probe = { ...message, errorMessage: text };
14
+ return isRetryableAssistantError(probe) || isContextOverflow(probe, 0);
15
+ } catch { return true; }
88
16
  }
89
17
 
90
- /**
91
- * The public error text for a structured refusal or unknown provider stop.
92
- *
93
- * The pinned host re-dispatches an error terminal from its prose alone (see
94
- * `hostWouldRedispatch`). A refusal explanation or stop reason is
95
- * provider-authored, so text such as "overloaded", "prompt is too long", or
96
- * "model_context_window_exceeded" would make the host send the stopped request
97
- * again. The structured code alone selects this path; the provider text is kept
98
- * only when neither host predicate would act on it. An unknown stop is then
99
- * reworded so it still names its reason, and only when no reworded form is safe
100
- * is a fixed fallback published. A refusal whose explanation is unsafe publishes
101
- * its fixed fallback directly. A message without the structured code, or that is
102
- * not an error terminal, yields `undefined` and must be published unchanged.
103
- */
104
- export function hostFinalStopMessage(
105
- message: AssistantMessage,
106
- ): { readonly errorMessage: string } | undefined {
107
- const code = (message as { code?: unknown }).code;
108
- if (message.stopReason !== "error") return undefined;
109
- if (code !== "refusal" && code !== "unknown_stop") return undefined;
110
- const candidate = message.errorMessage;
111
- if (
112
- typeof candidate === "string" &&
113
- candidate.length > 0 &&
114
- !hostWouldRedispatch(message, candidate)
115
- ) {
116
- return { errorMessage: candidate };
117
- }
118
- if (code === "refusal") return { errorMessage: REFUSAL_FALLBACK_MESSAGE };
119
- return {
120
- errorMessage:
121
- unknownStopNamingReason(message, message.rawStopReason) ??
122
- UNKNOWN_STOP_FALLBACK_MESSAGE,
123
- };
18
+ /** A structured stop is an answer, never retry/overflow evidence or a prose retention permission. */
19
+ export function hostFinalStopMessage(message: AssistantMessage): {
20
+ readonly errorMessage: string;
21
+ readonly rawStopReason: "refusal" | "unknown_stop";
22
+ } | undefined {
23
+ const code = Object.getOwnPropertyDescriptor(message, "code")?.value;
24
+ if (message.stopReason !== "error" || (code !== "refusal" && code !== "unknown_stop")) return undefined;
25
+ return { errorMessage: code === "refusal" ? REFUSAL_FALLBACK_MESSAGE : UNKNOWN_STOP_FALLBACK_MESSAGE, rawStopReason: code };
124
26
  }