@centerforagenticai/pi-multi-account 0.1.2 → 0.1.5

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.
@@ -0,0 +1,136 @@
1
+ import {
2
+ isContextOverflow,
3
+ isRetryableAssistantError,
4
+ type AssistantMessage,
5
+ } from "@earendil-works/pi-ai";
6
+ import { sanitizeDiagnosticText } from "./diagnostics.js";
7
+
8
+ /** Fixed fallbacks for a structured provider stop; none matches a host re-dispatch pattern. */
9
+ 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";
12
+
13
+ /**
14
+ * Appended when a reworded unknown stop reason omits words a host predicate acts
15
+ * on, so the published reason does not read as complete. Neither pinned
16
+ * predicate matches it, and the marked message is probed again before use.
17
+ */
18
+ export const PARTLY_WITHHELD_MARKER = " (partly withheld)";
19
+
20
+ /** Upper bound on the normalized stop reason named in a reworded message. */
21
+ const MAX_NAMED_REASON_LENGTH = 64;
22
+
23
+ /**
24
+ * Whether the pinned host would re-dispatch an error terminal carrying `text`.
25
+ *
26
+ * The host re-dispatches when either retry predicate matches the prose:
27
+ * `AgentSession._isRetryableError` runs pi-ai `isRetryableAssistantError`, and
28
+ * `_checkCompaction` runs `isContextOverflow` before compacting and retrying once
29
+ * (pi-coding-agent dist/core/agent-session.js, both from `@earendil-works/pi-ai`).
30
+ * An unreadable predicate result counts as a match, so the caller keeps looking
31
+ * for a safer form.
32
+ */
33
+ export function hostWouldRedispatch(message: AssistantMessage, text: string): boolean {
34
+ try {
35
+ const probe = { ...message, errorMessage: text };
36
+ return isRetryableAssistantError(probe) || isContextOverflow(probe, 0);
37
+ } catch {
38
+ return true;
39
+ }
40
+ }
41
+
42
+ function namedReasonMessage(reason: string): string {
43
+ return `Provider stopped (reason: ${reason})`;
44
+ }
45
+
46
+ /**
47
+ * Normalizes a bounded, sanitized raw stop reason into plain words: every run of
48
+ * non-alphanumeric characters (underscores included) becomes one space, and the
49
+ * result is capped at a word boundary where possible.
50
+ */
51
+ function normalizedReasonWords(rawStopReason: unknown): string[] {
52
+ if (typeof rawStopReason !== "string") return [];
53
+ const words = sanitizeDiagnosticText(rawStopReason)
54
+ .replace(/[^A-Za-z0-9]+/g, " ")
55
+ .trim()
56
+ .split(" ")
57
+ .filter((word) => word.length > 0);
58
+ const kept: string[] = [];
59
+ let length = 0;
60
+ for (const word of words) {
61
+ const next = length === 0 ? word.length : length + 1 + word.length;
62
+ if (next > MAX_NAMED_REASON_LENGTH) {
63
+ if (kept.length === 0) kept.push(word.slice(0, MAX_NAMED_REASON_LENGTH));
64
+ break;
65
+ }
66
+ kept.push(word);
67
+ length = next;
68
+ }
69
+ return kept;
70
+ }
71
+
72
+ /**
73
+ * Names an unknown stop reason in a message neither host predicate acts on.
74
+ *
75
+ * The first form keeps every normalized word. When that still matches a host
76
+ * predicate (a reason containing "overloaded" or "timeout", say), the second
77
+ * form admits words in order and drops each one whose addition would make the
78
+ * message match. When any word was dropped the result ends with
79
+ * `PARTLY_WITHHELD_MARKER`; that marked text is probed too, and an unsafe or
80
+ * empty result yields `undefined`.
81
+ */
82
+ function unknownStopNamingReason(
83
+ message: AssistantMessage,
84
+ rawStopReason: unknown,
85
+ ): string | undefined {
86
+ const words = normalizedReasonWords(rawStopReason);
87
+ if (words.length === 0) return undefined;
88
+ const reworded = namedReasonMessage(words.join(" "));
89
+ if (!hostWouldRedispatch(message, reworded)) return reworded;
90
+ const kept: string[] = [];
91
+ for (const word of words) {
92
+ const tentative = namedReasonMessage([...kept, word].join(" "));
93
+ if (!hostWouldRedispatch(message, tentative)) kept.push(word);
94
+ }
95
+ if (kept.length === 0) return undefined;
96
+ const named = namedReasonMessage(kept.join(" "));
97
+ if (kept.length === words.length) return named;
98
+ const marked = `${named}${PARTLY_WITHHELD_MARKER}`;
99
+ return hostWouldRedispatch(message, marked) ? undefined : marked;
100
+ }
101
+
102
+ /**
103
+ * The public error text for a structured refusal or unknown provider stop.
104
+ *
105
+ * The pinned host re-dispatches an error terminal from its prose alone (see
106
+ * `hostWouldRedispatch`). A refusal explanation or stop reason is
107
+ * provider-authored, so text such as "overloaded", "prompt is too long", or
108
+ * "model_context_window_exceeded" would make the host send the stopped request
109
+ * again. The structured code alone selects this path; the provider text is kept
110
+ * only when neither host predicate would act on it. An unknown stop is then
111
+ * reworded so it still names its reason, and only when no reworded form is safe
112
+ * is a fixed fallback published. A refusal whose explanation is unsafe publishes
113
+ * its fixed fallback directly. A message without the structured code, or that is
114
+ * not an error terminal, yields `undefined` and must be published unchanged.
115
+ */
116
+ export function hostFinalStopMessage(
117
+ message: AssistantMessage,
118
+ ): { readonly errorMessage: string } | undefined {
119
+ const code = (message as { code?: unknown }).code;
120
+ if (message.stopReason !== "error") return undefined;
121
+ if (code !== "refusal" && code !== "unknown_stop") return undefined;
122
+ const candidate = message.errorMessage;
123
+ if (
124
+ typeof candidate === "string" &&
125
+ candidate.length > 0 &&
126
+ !hostWouldRedispatch(message, candidate)
127
+ ) {
128
+ return { errorMessage: candidate };
129
+ }
130
+ if (code === "refusal") return { errorMessage: REFUSAL_FALLBACK_MESSAGE };
131
+ return {
132
+ errorMessage:
133
+ unknownStopNamingReason(message, message.rawStopReason) ??
134
+ UNKNOWN_STOP_FALLBACK_MESSAGE,
135
+ };
136
+ }
package/src/index.ts CHANGED
@@ -80,6 +80,7 @@ import {
80
80
  import { DiagnosticStore } from "./diagnostic-store.js";
81
81
  import { DeclarationNoticeMarker } from "./declaration-notice-marker.js";
82
82
  import { DiagnosticLog } from "./diagnostics.js";
83
+ import { createRefusalAdvisor } from "./refusal-advice.js";
83
84
  import {
84
85
  classifyProviderId,
85
86
  createPublicAuthStorageAdapter,
@@ -3890,6 +3891,18 @@ export const createMultiAccountExtension =
3890
3891
  modelSupport,
3891
3892
  alreadyCooled: classified.alreadyCooled === true,
3892
3893
  });
3894
+ if (decision.status === "retained") {
3895
+ // A structured refusal or unknown stop keeps the account: no switch,
3896
+ // no park, no OpenRouter, and no continuation. The same context would
3897
+ // stop the same way on any account.
3898
+ diagnostics.record(
3899
+ "info",
3900
+ "routing.retained",
3901
+ "The provider stopped the turn with a refusal or unknown stop reason; the account was kept and no follow-up was scheduled.",
3902
+ { providerId, kind: decision.classification.kind ?? "unknown" },
3903
+ );
3904
+ return;
3905
+ }
3893
3906
  if (decision.status === "paused") {
3894
3907
  // OpenRouter is an explicitly enabled, metered FINAL rung. The helper
3895
3908
  // re-checks every managed family so family-chain policy cannot bypass
@@ -4147,6 +4160,9 @@ export const createMultiAccountExtension =
4147
4160
  const lastFailure = new Map<string, ProviderFailureSignal>();
4148
4161
  const handledFailures = new WeakSet<object>();
4149
4162
  const observedMessages = new WeakSet<object>();
4163
+ // Operator advice for a structured refusal. A delegate-owned in-process
4164
+ // session shares the foreground UI, so only the foreground advises.
4165
+ const refusalAdvisor = createRefusalAdvisor({ foreground: !delegateOwnedSession });
4150
4166
  const recordUsage = (
4151
4167
  observation: () => UsageObservation | undefined,
4152
4168
  ): void => {
@@ -4873,7 +4889,7 @@ export const createMultiAccountExtension =
4873
4889
  config,
4874
4890
  nowMs,
4875
4891
  });
4876
- if (reactive.status === "paused") return;
4892
+ if (reactive.status !== "selected") return;
4877
4893
  reactiveCandidate = reactive;
4878
4894
  destinationProviderId = reactive.destination.providerId;
4879
4895
  }
@@ -5141,6 +5157,17 @@ export const createMultiAccountExtension =
5141
5157
  acceptedLogicalAssociation,
5142
5158
  );
5143
5159
  }
5160
+ // Advice only: names the physical route that refused, never routes.
5161
+ refusalAdvisor.advise(
5162
+ originalMessage,
5163
+ messageContext,
5164
+ acceptedLogicalAssociation && logicalAssociation !== undefined
5165
+ ? {
5166
+ providerId: logicalAssociation.route.providerId,
5167
+ modelId: logicalAssociation.dispatchedModelId,
5168
+ }
5169
+ : {},
5170
+ );
5144
5171
 
5145
5172
  try {
5146
5173
  const validOriginalMessage = originalMessage as AssistantMessage;
@@ -5228,6 +5255,8 @@ export const createMultiAccountExtension =
5228
5255
  ) {
5229
5256
  return;
5230
5257
  }
5258
+ // Advice only: a managed account's structured refusal never routes.
5259
+ refusalAdvisor.advise(event.message, messageContext, identity);
5231
5260
  const subscriptionFamily = isRoutingEligibleAccountFamily(messageSlot)
5232
5261
  ? messageSlot.family
5233
5262
  : undefined;
@@ -5299,9 +5328,17 @@ export const createMultiAccountExtension =
5299
5328
  turnRouteOrigin = origin;
5300
5329
  handledFailures.add(event.message);
5301
5330
  const responseFailure = lastFailure.get(providerId) ?? {};
5302
- const messageCode = providerErrorCodeFromMessage(
5303
- event.message.errorMessage,
5304
- );
5331
+ // A structured provider stop is copied only from this two-value allowlist,
5332
+ // never spread, and it wins over any code parsed from provider-authored
5333
+ // errorMessage prose: refusal text must not read as a rate limit.
5334
+ const rawStopCode = (event.message as { code?: unknown }).code;
5335
+ const stopCode =
5336
+ rawStopCode === "refusal" || rawStopCode === "unknown_stop"
5337
+ ? rawStopCode
5338
+ : undefined;
5339
+ const messageCode =
5340
+ stopCode ??
5341
+ providerErrorCodeFromMessage(event.message.errorMessage);
5305
5342
  const failure: ProviderFailureSignal = {
5306
5343
  ...responseFailure,
5307
5344
  ...(messageCode === undefined ? {} : { code: messageCode }),
@@ -16,12 +16,14 @@
16
16
  import { logicalAccountEligible, recordFailureCooldown } from "./routing.js";
17
17
  import type { ManagedAccount } from "./routing.js";
18
18
  import {
19
+ isContextOverflow,
19
20
  isRetryableAssistantError,
20
21
  type AssistantMessage,
21
22
  type ProviderResponse,
22
23
  type SimpleStreamOptions,
23
24
  } from "@earendil-works/pi-ai";
24
25
  import { RuntimeState, type LogicalRoutePin } from "./runtime-state.js";
26
+ import { hostFinalStopMessage } from "./host-final-stop-message.js";
25
27
  import { DEFAULT_CONFIG } from "./config.js";
26
28
  import type {
27
29
  AllowedFamily,
@@ -47,6 +49,16 @@ import type { ProviderType, Vendor } from "./vendor.js";
47
49
 
48
50
  export { LOGICAL_PROVIDER_ID } from "./models-declaration.js";
49
51
  import { LOGICAL_PROVIDER_ID } from "./models-declaration.js";
52
+ import { forceCodexSseOptions } from "./codex-adapter.js";
53
+
54
+ /** Fixed diagnostic label for a caller-supplied transport; never echoes the raw value. */
55
+ function codexTransportLabel(
56
+ transport: unknown,
57
+ ): "websocket" | "websocket-cached" | "auto" | "other" {
58
+ return transport === "websocket" || transport === "websocket-cached" || transport === "auto"
59
+ ? transport
60
+ : "other";
61
+ }
50
62
 
51
63
  /** One physical account the logical provider may dispatch to. */
52
64
  export interface LogicalPhysicalAccount {
@@ -342,6 +354,36 @@ const EXHAUSTION_LENGTH_ALLOWANCE_MULTIPLIER = 8;
342
354
  const EXHAUSTION_LENGTH_MAX_CONTEXT_FRACTION = 0.8;
343
355
  const EXHAUSTION_LENGTH_ERROR_MESSAGE = "provider returned error (usage-limit)";
344
356
 
357
+ /**
358
+ * Fixed public text for a setup-shaped context overflow. The pinned host's
359
+ * `isContextOverflow` matches it (so the host compacts and retries once) and
360
+ * `isRetryableAssistantError` does not (so the host does not fail over).
361
+ */
362
+ export const SETUP_CONTEXT_OVERFLOW_MESSAGE = "context_length_exceeded (provider_error)";
363
+
364
+ type SetupFailureDisposition = "context-overflow" | "retryable" | "host-final";
365
+
366
+ /**
367
+ * How the pinned host treats the raw setup text. The host checks the two
368
+ * predicates separately: `_handlePostAgentRun` compacts and retries once on
369
+ * `isContextOverflow`, while `_isRetryableError` excludes overflow and fails
370
+ * over on `isRetryableAssistantError`. An unreadable predicate result counts
371
+ * as host-final.
372
+ */
373
+ function setupFailureDisposition(
374
+ message: AssistantMessage,
375
+ raw: unknown,
376
+ ): SetupFailureDisposition {
377
+ if (typeof raw !== "string" || raw.length === 0) return "host-final";
378
+ try {
379
+ const probe = { ...message, errorMessage: raw };
380
+ if (isContextOverflow(probe, 0)) return "context-overflow";
381
+ return isRetryableAssistantError(probe) ? "retryable" : "host-final";
382
+ } catch {
383
+ return "host-final";
384
+ }
385
+ }
386
+
345
387
  function finiteNonNegative(value: unknown): number | undefined {
346
388
  return typeof value === "number" && Number.isFinite(value) && value >= 0
347
389
  ? value
@@ -531,6 +573,54 @@ function projectFailureSignal(
531
573
  };
532
574
  }
533
575
 
576
+ /**
577
+ * Whether a physical terminal is the host's setup-error shape: the first event
578
+ * of the stream is an `error` with no content, all-zero usage, no diagnostics,
579
+ * no structured stop code, and no structured failure evidence.
580
+ *
581
+ * That is what pi-ai `lazyStream` (`createSetupErrorMessage`) publishes when a
582
+ * provider stream throws or rejects before it starts, so its `errorMessage` is
583
+ * raw exception text, not provider-authored failure prose. The production cause
584
+ * of the observed setup `TypeError` is not known (see UPSTREAM.md). The same
585
+ * shape also carries transient pre-start failures ("fetch failed", a 503 before
586
+ * `start`), so the caller decides retryability from the text, never publishes
587
+ * it. A real provider failure that carries a recognized code or status keeps
588
+ * its own text and routing.
589
+ */
590
+ function isUnclassifiedSetupFailure(
591
+ message: AssistantMessage,
592
+ failure: ProviderFailureSignal,
593
+ ): boolean {
594
+ try {
595
+ if (message.stopReason !== "error") return false;
596
+ if (!Array.isArray(message.content) || message.content.length !== 0) return false;
597
+ const diagnostics = (message as { diagnostics?: unknown }).diagnostics;
598
+ if (diagnostics !== undefined && !(Array.isArray(diagnostics) && diagnostics.length === 0)) {
599
+ return false;
600
+ }
601
+ if ((message as { code?: unknown }).code !== undefined) return false;
602
+ const usage = projectTerminalUsage(message);
603
+ if (
604
+ usage === undefined ||
605
+ usage.input !== 0 ||
606
+ usage.output !== 0 ||
607
+ usage.cacheRead !== 0 ||
608
+ usage.cacheWrite !== 0 ||
609
+ usage.totalTokens !== 0 ||
610
+ usage.cost.total !== 0
611
+ ) {
612
+ return false;
613
+ }
614
+ return (
615
+ failure.code === undefined &&
616
+ failure.httpStatus === undefined &&
617
+ failure.transportKind === undefined
618
+ );
619
+ } catch {
620
+ return false;
621
+ }
622
+ }
623
+
534
624
  function safeProjectFailureSignal(
535
625
  error: unknown,
536
626
  modelId: string,
@@ -666,6 +756,7 @@ export function createLogicalProvider(
666
756
  deps: LogicalProviderDeps,
667
757
  ): LogicalProvider {
668
758
  const coordinator = createHostRetryCoordinator(deps);
759
+ let codexTransportNoticeSent = false;
669
760
 
670
761
  const diagnose = (message: string): void => {
671
762
  deps.onDiagnostic?.(message);
@@ -936,6 +1027,7 @@ export function createLogicalProvider(
936
1027
 
937
1028
  const projectMessage = (message: AssistantMessage, modelId: string): AssistantMessage => ({
938
1029
  ...message, api: LOGICAL_PROVIDER_ID, provider: LOGICAL_PROVIDER_ID, model: modelId,
1030
+ ...hostFinalStopMessage(message),
939
1031
  });
940
1032
 
941
1033
  const projectEvent = (event: unknown, modelId: string): unknown => {
@@ -951,6 +1043,18 @@ export function createLogicalProvider(
951
1043
  return { ...candidate, [key]: projectMessage(value as AssistantMessage, modelId) };
952
1044
  };
953
1045
 
1046
+ const withPublicErrorMessage = (event: unknown, errorMessage: string): unknown => {
1047
+ if (typeof event !== "object" || event === null) return event;
1048
+ const candidate = event as Record<string, unknown>;
1049
+ if (candidate.type !== "error" || typeof candidate.error !== "object" || candidate.error === null) {
1050
+ return event;
1051
+ }
1052
+ return {
1053
+ ...candidate,
1054
+ error: { ...(candidate.error as AssistantMessage), errorMessage },
1055
+ };
1056
+ };
1057
+
954
1058
  const watchStream = (
955
1059
  stream: AsyncIterable<unknown>,
956
1060
  model: unknown,
@@ -962,6 +1066,7 @@ export function createLogicalProvider(
962
1066
  ): AsyncIterable<unknown> => ({
963
1067
  async *[Symbol.asyncIterator]() {
964
1068
  let sawTerminal = false;
1069
+ let sawEvent = false;
965
1070
  let failureReceipt: HostRetryCooldownReceipt | undefined;
966
1071
  const recordFailureOnce = (error: unknown): HostRetryCooldownReceipt => {
967
1072
  failureReceipt ??= coordinator.recordFailure({
@@ -1003,7 +1108,10 @@ export function createLogicalProvider(
1003
1108
  ? (event as { type?: unknown }).type
1004
1109
  : undefined;
1005
1110
  if (eventType === "done" || eventType === "error") sawTerminal = true;
1111
+ const firstEvent = !sawEvent;
1112
+ sawEvent = true;
1006
1113
  const terminal = terminalAttribution(event);
1114
+ let setupFailureMessage: string | undefined;
1007
1115
  if (terminal !== undefined) {
1008
1116
  const { message, outcome } = terminal;
1009
1117
  if (outcome === "finish") {
@@ -1035,10 +1143,41 @@ export function createLogicalProvider(
1035
1143
  } else {
1036
1144
  const failure = safeProjectFailureSignal(message, dispatchedModelId);
1037
1145
  attributeFailure(message, failure, recordFailureOnce(message));
1146
+ if (firstEvent && isUnclassifiedSetupFailure(message, failure)) {
1147
+ // The physical account is cooled above exactly like any other
1148
+ // failure. Only the published text changes: the raw text is
1149
+ // replaced by the bounded classified message the
1150
+ // rejected-dispatch path uses, so it is never published. The
1151
+ // setup shape is shared by transient pre-start failures ("fetch
1152
+ // failed", a 503 before `start`), pre-start context overflows
1153
+ // (a Codex 400 or Anthropic 413), and deterministic setup throws.
1154
+ // The raw text decides the form: an overflow publishes a fixed
1155
+ // overflow message so the host compacts instead of failing
1156
+ // over; a host-retryable text keeps the retryable form and
1157
+ // fails over; anything else is host-final (`provider_error`),
1158
+ // so a deterministic fault is not repeated on the next account.
1159
+ const disposition = setupFailureDisposition(message, message.errorMessage);
1160
+ setupFailureMessage =
1161
+ disposition === "context-overflow"
1162
+ ? SETUP_CONTEXT_OVERFLOW_MESSAGE
1163
+ : classifiedErrorMessage(failure, disposition === "retryable");
1164
+ try {
1165
+ deps.onDiagnostic?.(
1166
+ `logical dispatch for ${account.providerId} failed during stream setup; ` +
1167
+ "published a classified failure instead of the raw setup error",
1168
+ );
1169
+ } catch {
1170
+ // A diagnostic sink failure cannot replace a provider result.
1171
+ }
1172
+ }
1038
1173
  }
1039
1174
  await attempt.waitForTerminal();
1040
1175
  }
1041
- const publicEvent = projectEvent(event, requestedModelId);
1176
+ const projectedEvent = projectEvent(event, requestedModelId);
1177
+ const publicEvent =
1178
+ setupFailureMessage === undefined
1179
+ ? projectedEvent
1180
+ : withPublicErrorMessage(projectedEvent, setupFailureMessage);
1042
1181
  if (terminal !== undefined && publicEvent !== event) {
1043
1182
  const publicTerminal = terminalAttribution(publicEvent);
1044
1183
  if (publicTerminal !== undefined) {
@@ -1127,8 +1266,24 @@ export function createLogicalProvider(
1127
1266
  await originalOnResponse(response, responseModel);
1128
1267
  }
1129
1268
  };
1269
+ let routedOptions = options;
1270
+ if (account.family === "openai-codex") {
1271
+ // Temporary Pi 0.99 WebSocket containment; see CODEX_FORCED_TRANSPORT.
1272
+ const forced = forceCodexSseOptions(options);
1273
+ routedOptions = forced.options;
1274
+ if (forced.overridden && options?.transport !== undefined && !codexTransportNoticeSent) {
1275
+ codexTransportNoticeSent = true;
1276
+ try {
1277
+ deps.onDiagnostic?.(
1278
+ `Codex transport "${codexTransportLabel(options.transport)}" overridden to "sse" on routed calls (Pi 0.99 WebSocket containment).`,
1279
+ );
1280
+ } catch {
1281
+ // A diagnostic sink failure cannot replace a provider result.
1282
+ }
1283
+ }
1284
+ }
1130
1285
  const attributedOptions: SimpleStreamOptions = {
1131
- ...options,
1286
+ ...routedOptions,
1132
1287
  onPayload: wrappedOnPayload,
1133
1288
  onResponse: wrappedOnResponse,
1134
1289
  };