@centerforagenticai/pi-multi-account 0.1.4 → 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.
@@ -30,6 +30,9 @@ export type RecoveryCandidateTier =
30
30
  | "owning-vendor-api"
31
31
  | "cross-family-subscription";
32
32
 
33
+ /** The one recovery dimension a candidate may change for this logical call. */
34
+ export type RecoveryActionKind = "account" | "model";
35
+
33
36
  /** One finite provider/model pair. Logical and physical model identity stay distinct. */
34
37
  export interface RecoveryCandidate {
35
38
  readonly providerId: string;
@@ -39,6 +42,7 @@ export interface RecoveryCandidate {
39
42
  readonly selectedModelId: string;
40
43
  readonly tier: RecoveryCandidateTier;
41
44
  readonly substitution: "exact" | "configured";
45
+ readonly recoveryAction: RecoveryActionKind;
42
46
  readonly capability: RecoveryModelCapability;
43
47
  }
44
48
 
@@ -91,7 +95,7 @@ function immutableCapability(model: RecoveryModelCapability): RecoveryModelCapab
91
95
  });
92
96
  }
93
97
 
94
- /** Build the immutable, deterministic candidate sweep for one logical call. */
98
+ /** Build the immutable, deterministic candidate order for one logical call. */
95
99
  export function buildRecoveryCandidatePlan(
96
100
  request: RecoveryCandidatePlanRequest,
97
101
  ): readonly RecoveryCandidate[] {
@@ -146,6 +150,8 @@ export function buildRecoveryCandidatePlan(
146
150
  selectedModelId: request.selectedModelId,
147
151
  tier,
148
152
  substitution,
153
+ recoveryAction:
154
+ model.modelId === request.selectedModelId ? "account" : "model",
149
155
  capability: immutableCapability(model),
150
156
  }),
151
157
  );
@@ -0,0 +1,29 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+
3
+ export type CodexRecoverySendEvidence = "pre-execution-rejected" | "uncertain";
4
+
5
+ /**
6
+ * Classify whether a pinned Codex WebSocket terminal proves that one request was
7
+ * rejected before execution without an internal reconnect or SSE fallback.
8
+ *
9
+ * Pinned pi-ai 0.84.4 sends `response.create` before it observes stream events
10
+ * (`dist/api/openai-codex-responses.js:1182`). Its WebSocket loop can reconnect
11
+ * for two special errors or fall back to SSE (`:218-245`). The
12
+ * `provider_transport_failure` diagnostic is appended only on that transport
13
+ * branch (`:229-237`). Structured `CodexApiError` fields are instead normalized
14
+ * to terminal `errorMessage` (`:483-539`, `:344-346`), which retains neither the
15
+ * code/payload nor proof that no prior reconnect occurred.
16
+ *
17
+ * The per-session `getOpenAICodexWebSocketDebugStats` counters (`:632-654`)
18
+ * are process-global, shared by concurrent requests, and absent from the
19
+ * terminal, so they cannot attribute sends to one invocation either.
20
+ *
21
+ * Consequently no terminal `AssistantMessage` exposed by the pinned package is
22
+ * trustworthy proof of the complete condition. Do not infer safety from prose,
23
+ * status-like text, or the absence of a transport diagnostic.
24
+ */
25
+ export function classifyCodexRecoverySendEvidence(
26
+ _terminal: AssistantMessage,
27
+ ): CodexRecoverySendEvidence {
28
+ return "uncertain";
29
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Operator-only advice for a structured provider refusal.
3
+ *
4
+ * A refusal is an outcome, not an account failure: routing keeps the account
5
+ * and nothing is resent (see `src/routing.ts`, status `retained`). This module
6
+ * only tells the operator what happened and which choices remain. It never
7
+ * resends, rephrases, starts a turn, changes the model, rotates an account, or
8
+ * writes state.
9
+ *
10
+ * Only the structured `code` field selects advice. The adapter sets it from the
11
+ * provider's own stop reason (`src/anthropic-adaptive-stream.ts`). Assistant
12
+ * prose, the error message, the raw stop reason, and content are never read,
13
+ * so a refusal is never guessed and no provider text can reach the advice.
14
+ */
15
+
16
+ /** Identical advice inside this window is shown once. */
17
+ export const REFUSAL_ADVICE_REPEAT_WINDOW_MS = 60_000;
18
+
19
+ /** Provider and model IDs are named only when they are short, plain tokens. */
20
+ const SAFE_ROUTE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
21
+
22
+ const CHOICES =
23
+ "Multi-account kept the account and did not resend, reroute, or retry it. " +
24
+ "You can revise the request, or pick another model explicitly with /model.";
25
+
26
+ export type RefusalAdviceRoute = Readonly<{
27
+ providerId?: unknown;
28
+ modelId?: unknown;
29
+ }>;
30
+
31
+ /** Reads one own data property; accessors and proxies count as absent. */
32
+ function ownDataValue(value: object, key: string): unknown {
33
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
34
+ return descriptor !== undefined && "value" in descriptor
35
+ ? descriptor.value
36
+ : undefined;
37
+ }
38
+
39
+ /**
40
+ * The allowlisted projection: whether `message` is an assistant error terminal
41
+ * carrying the exact structured `refusal` code. Nothing else is read.
42
+ */
43
+ export function isStructuredRefusal(message: unknown): boolean {
44
+ try {
45
+ if (typeof message !== "object" || message === null) return false;
46
+ return (
47
+ ownDataValue(message, "role") === "assistant" &&
48
+ ownDataValue(message, "stopReason") === "error" &&
49
+ ownDataValue(message, "code") === "refusal"
50
+ );
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function safeRouteId(value: unknown): string | undefined {
57
+ return typeof value === "string" && SAFE_ROUTE_ID.test(value)
58
+ ? value
59
+ : undefined;
60
+ }
61
+
62
+ function safeRouteLabel(route: RefusalAdviceRoute): string | undefined {
63
+ try {
64
+ const providerId = safeRouteId(route.providerId);
65
+ const modelId = safeRouteId(route.modelId);
66
+ return providerId === undefined || modelId === undefined
67
+ ? undefined
68
+ : `${providerId}/${modelId}`;
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Bounded advice text for a structured refusal, or `undefined` when the message
76
+ * carries no structured refusal code. The text names only the route identity
77
+ * the caller resolved, and drops it when it is not a short plain token.
78
+ */
79
+ export function refusalAdvice(
80
+ message: unknown,
81
+ route: RefusalAdviceRoute,
82
+ ): string | undefined {
83
+ if (!isStructuredRefusal(message)) return undefined;
84
+ const label = safeRouteLabel(route);
85
+ const subject =
86
+ label === undefined ? "The model refused this request." : `${label} refused this request.`;
87
+ return `${subject} ${CHOICES}`;
88
+ }
89
+
90
+ type NotifyContext = {
91
+ readonly hasUI?: unknown;
92
+ readonly ui?: { notify?: unknown };
93
+ };
94
+
95
+ export type RefusalAdvisorOptions = Readonly<{
96
+ /** False for delegate-owned sessions that share the foreground UI. */
97
+ foreground: boolean;
98
+ now?: () => number;
99
+ }>;
100
+
101
+ /**
102
+ * Shows refusal advice in the foreground UI at most once per terminal and once
103
+ * per route inside {@link REFUSAL_ADVICE_REPEAT_WINDOW_MS}. Fail-soft: a UI
104
+ * failure never reaches the caller. Returns whether advice was shown.
105
+ */
106
+ export function createRefusalAdvisor(options: RefusalAdvisorOptions) {
107
+ const now = options.now ?? Date.now;
108
+ const advised = new WeakSet<object>();
109
+ let lastText: string | undefined;
110
+ let lastAtMs = Number.NEGATIVE_INFINITY;
111
+ return {
112
+ advise(
113
+ message: unknown,
114
+ context: NotifyContext | undefined,
115
+ route: RefusalAdviceRoute,
116
+ ): boolean {
117
+ try {
118
+ if (!options.foreground) return false;
119
+ const text = refusalAdvice(message, route);
120
+ if (text === undefined) return false;
121
+ if (advised.has(message as object)) return false;
122
+ advised.add(message as object);
123
+ if (context?.hasUI !== true) return false;
124
+ const notify = context.ui?.notify;
125
+ if (typeof notify !== "function") return false;
126
+ const atMs = now();
127
+ if (text === lastText && atMs - lastAtMs < REFUSAL_ADVICE_REPEAT_WINDOW_MS) {
128
+ return false;
129
+ }
130
+ lastText = text;
131
+ lastAtMs = atMs;
132
+ notify.call(context.ui, text, "warning");
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ },
138
+ };
139
+ }
@@ -905,10 +905,14 @@ export class SharedUsageStore {
905
905
 
906
906
  append(record: SharedUsageLogRecord): boolean {
907
907
  try {
908
- // Judge the caller's own value, not the projection: projecting slices
909
- // the observer id, and a value this store would have to rewrite is
910
- // not one the producer emitted.
911
- if (!validObserverId((record as { observerId?: unknown }).observerId))
908
+ // The store is the ONLY producer of a persisted observer id
909
+ // (internal issue #133). The grammar check alone cannot keep a
910
+ // credential out: `sk-ant-api03-...` is a valid hostname-shaped id, and
911
+ // no character rule separates a hostname from a key. So a caller may
912
+ // only restate this store's own id, which the constructor validated;
913
+ // any other value, a peer's included, is refused rather than written.
914
+ // Judged on the caller's own value, before projection slices it.
915
+ if ((record as { observerId?: unknown }).observerId !== this.#observerId)
912
916
  return false;
913
917
  const projected = validExhaustionHoldRecord(record)
914
918
  ? projectExhaustionHold(record)
@@ -1063,15 +1067,28 @@ export class SharedUsageStore {
1063
1067
  return latest;
1064
1068
  }
1065
1069
 
1070
+ /**
1071
+ * The newest attempt for an account that was observed at or before `nowMs`.
1072
+ *
1073
+ * An attempt observed in the future is ignored here, at the single source,
1074
+ * rather than by each reader (internal issue #133). Such a record always
1075
+ * sorts as the newest, so a reader that merely declined to be suppressed by
1076
+ * it still took its `failureCount` as the prior rung: every real failure
1077
+ * rewrote the same count, the next read returned the future record again,
1078
+ * and the backoff ladder froze. `nowMs` is required so each reader judges
1079
+ * against its own (possibly injected) clock.
1080
+ */
1066
1081
  latestAttempt(
1067
1082
  providerId: string,
1068
1083
  family: AllowedFamily,
1084
+ nowMs: number,
1069
1085
  ): SharedUsageAttemptRecord | undefined {
1070
1086
  let latest: SharedUsageAttemptRecord | undefined;
1071
1087
  for (const record of this.readAttempts()) {
1072
1088
  if (
1073
1089
  record.providerId === providerId &&
1074
1090
  record.family === family &&
1091
+ record.observedAtMs <= nowMs &&
1075
1092
  (latest === undefined || record.observedAtMs >= latest.observedAtMs)
1076
1093
  ) {
1077
1094
  latest = record;
@@ -641,36 +641,27 @@ async function queryEndpoint(
641
641
  : normalizeAnthropicUsagePayload(payload);
642
642
  }
643
643
 
644
- /**
645
- * The attempt's deadline, or undefined when no honest ladder could have set it.
646
- *
647
- * The store already bounds `nextAttemptAtMs` against the record's own
648
- * `observedAtMs` on append and read (internal issue #108). That span
649
- * check alone is not enough for a reader: a record whose ORIGIN sits centuries
650
- * ahead carries a legitimate-looking span and would still suppress polling for
651
- * that account forever. No honest deadline lies further than one capped rung
652
- * from now, so anything beyond that is ignored rather than trusted.
653
- */
654
- function plausibleAttemptDeadline(
655
- attempt: SharedUsageAttemptRecord | undefined,
656
- nowMs: number,
657
- ): number | undefined {
658
- if (attempt === undefined) return undefined;
659
- if (attempt.nextAttemptAtMs > nowMs + MAX_USAGE_ATTEMPT_DELAY_MS) {
660
- return undefined;
661
- }
662
- return attempt.nextAttemptAtMs;
663
- }
644
+ // Every attempt a reader here sees comes from `SharedUsageStore.latestAttempt`
645
+ // called with that reader's own clock, and that is what keeps a far-future
646
+ // deadline (internal issue #108) from suppressing polling:
647
+ //
648
+ // - the store refuses, on append and read, any attempt whose
649
+ // `nextAttemptAtMs - observedAtMs` exceeds MAX_USAGE_ATTEMPT_DELAY_MS; and
650
+ // - `latestAttempt` skips any attempt observed after `nowMs` (#133).
651
+ //
652
+ // Together these give `nextAttemptAtMs <= nowMs + MAX_USAGE_ATTEMPT_DELAY_MS`
653
+ // for every record a reader receives. A per-reader "plausible deadline" check
654
+ // used to restate that bound; once the origin filter moved into the store it
655
+ // could no longer fail on any reachable input and was removed (#133,
656
+ // CR-REFRESH-READER-BOUND-UNREACHABLE). Read attempts only through
657
+ // `latestAttempt(providerId, family, nowMs)` with the clock the decision uses.
664
658
 
665
659
  function statusFromAttempt(
666
660
  attempt: SharedUsageAttemptRecord | undefined,
667
661
  enabled: boolean,
668
- nowMs: number,
669
662
  ): UsageFetchStatus {
670
663
  const nextAttemptAtMs =
671
- attempt?.failureCount === 0
672
- ? undefined
673
- : plausibleAttemptDeadline(attempt, nowMs);
664
+ attempt?.failureCount === 0 ? undefined : attempt?.nextAttemptAtMs;
674
665
  const disabledReason = attempt?.failureReason;
675
666
  return {
676
667
  enabled,
@@ -853,9 +844,8 @@ export class UsageFetcher {
853
844
  ): UsageFetchStatus {
854
845
  const enabled = config.usageFetchEnabled?.[family] ?? true;
855
846
  return statusFromAttempt(
856
- this.#sharedStore.latestAttempt(providerId, family),
847
+ this.#sharedStore.latestAttempt(providerId, family, this.#now()),
857
848
  enabled,
858
- this.#now(),
859
849
  );
860
850
  }
861
851
 
@@ -1017,25 +1007,20 @@ export class UsageFetcher {
1017
1007
  // the debounce clause, so `already-answered` and `backoff` returned
1018
1008
  // before it ran: an attempt with `observedAtMs` centuries ahead is
1019
1009
  // trivially `>= failedAtMs`, and suppressed every failure refresh
1020
- // forever. The bound belongs here, on the record, before any clause
1021
- // reads it.
1010
+ // forever. `latestAttempt(..., nowMs)` now refuses such a record at the
1011
+ // source (#133), which also bounds `nextAttemptAtMs` to one capped rung
1012
+ // past `nowMs` (see the note above `statusFromAttempt`).
1022
1013
  //
1023
- // `nextAttemptAtMs` is bounded relative to `observedAtMs` by the store and
1024
- // checked again here before it may suppress a send. An honest record cannot
1025
- // have been OBSERVED in the future either.
1014
+ // The failure trigger is not tied to `observedAtMs` by the store, so a
1015
+ // record observed in the past can still name a failure in the future;
1016
+ // such a marker cannot be honest and must not arm the debounce.
1026
1017
  if (
1027
- attempt.observedAtMs > nowMs ||
1028
- (attempt.failureTriggeredAtMs !== undefined &&
1029
- attempt.failureTriggeredAtMs > nowMs)
1018
+ attempt.failureTriggeredAtMs !== undefined &&
1019
+ attempt.failureTriggeredAtMs > nowMs
1030
1020
  ) {
1031
1021
  return undefined;
1032
1022
  }
1033
- const nextAttemptAtMs = plausibleAttemptDeadline(attempt, nowMs);
1034
- if (
1035
- attempt.failureCount > 0 &&
1036
- nextAttemptAtMs !== undefined &&
1037
- nextAttemptAtMs > nowMs
1038
- ) {
1023
+ if (attempt.failureCount > 0 && attempt.nextAttemptAtMs > nowMs) {
1039
1024
  return "backoff";
1040
1025
  }
1041
1026
  if (attempt.observedAtMs >= failedAtMs) return "already-answered";
@@ -1070,15 +1055,17 @@ export class UsageFetcher {
1070
1055
  }
1071
1056
 
1072
1057
  // Step 4/5: durable checks before the lease.
1058
+ const beforeLeaseNowMs = this.#now();
1073
1059
  const beforeLease = this.#sharedStore.latestAttempt(
1074
1060
  account.providerId,
1075
1061
  account.family,
1062
+ beforeLeaseNowMs,
1076
1063
  );
1077
1064
  if (
1078
1065
  this.#failureRefreshSuppressedBy(
1079
1066
  beforeLease,
1080
1067
  failedAtMs,
1081
- this.#now(),
1068
+ beforeLeaseNowMs,
1082
1069
  ) !== undefined
1083
1070
  ) {
1084
1071
  return { providerId: account.providerId, status: "not-due" };
@@ -1098,15 +1085,17 @@ export class UsageFetcher {
1098
1085
  const leaseGuard: AntigravityLeaseGuard = { lease, handedOff: false };
1099
1086
  try {
1100
1087
  // Step 7: the same checks again, now that nobody else can write.
1088
+ const underLeaseNowMs = this.#now();
1101
1089
  const underLease = this.#sharedStore.latestAttempt(
1102
1090
  account.providerId,
1103
1091
  account.family,
1092
+ underLeaseNowMs,
1104
1093
  );
1105
1094
  if (
1106
1095
  this.#failureRefreshSuppressedBy(
1107
1096
  underLease,
1108
1097
  failedAtMs,
1109
- this.#now(),
1098
+ underLeaseNowMs,
1110
1099
  ) !== undefined
1111
1100
  ) {
1112
1101
  return { providerId: account.providerId, status: "not-due" };
@@ -1195,6 +1184,7 @@ export class UsageFetcher {
1195
1184
  const prior = this.#sharedStore.latestAttempt(
1196
1185
  account.providerId,
1197
1186
  account.family,
1187
+ nowMs,
1198
1188
  );
1199
1189
  // Backoff is checked BEFORE `disabled`, deliberately.
1200
1190
  //
@@ -1217,12 +1207,7 @@ export class UsageFetcher {
1217
1207
  // not it was disabled. A retry that fails again simply re-arms the ladder at
1218
1208
  // its capped rung, so a genuinely dead endpoint is polled at most once per
1219
1209
  // MAX_USAGE_ATTEMPT_DELAY_MS rather than hammered.
1220
- const priorDeadline = plausibleAttemptDeadline(prior, nowMs);
1221
- if (
1222
- prior !== undefined &&
1223
- priorDeadline !== undefined &&
1224
- priorDeadline > nowMs
1225
- ) {
1210
+ if (prior !== undefined && prior.nextAttemptAtMs > nowMs) {
1226
1211
  await this.#persistWindowGap(
1227
1212
  account,
1228
1213
  nowMs,
@@ -1246,21 +1231,17 @@ export class UsageFetcher {
1246
1231
  }
1247
1232
  const leaseGuard: AntigravityLeaseGuard = { lease, handedOff: false };
1248
1233
  try {
1249
- const current = this.#sharedStore.latestAttempt(
1250
- account.providerId,
1251
- account.family,
1252
- );
1253
1234
  // Same ordering as the pre-lease check above: an elapsed backoff wins
1254
1235
  // over a stale `disabled` flag, so a recovered account can be observed
1255
1236
  // again. Re-read under the lease because a peer may have attempted in
1256
1237
  // between.
1257
1238
  const currentNowMs = this.#now();
1258
- const currentDeadline = plausibleAttemptDeadline(current, currentNowMs);
1259
- if (
1260
- current !== undefined &&
1261
- currentDeadline !== undefined &&
1262
- currentDeadline > currentNowMs
1263
- ) {
1239
+ const current = this.#sharedStore.latestAttempt(
1240
+ account.providerId,
1241
+ account.family,
1242
+ currentNowMs,
1243
+ );
1244
+ if (current !== undefined && current.nextAttemptAtMs > currentNowMs) {
1264
1245
  await this.#persistWindowGap(
1265
1246
  account,
1266
1247
  this.#now(),
@@ -1654,13 +1635,15 @@ export class UsageFetcher {
1654
1635
  }
1655
1636
  // REQ-WINDOW-CADENCE: enforce cross-process 15-minute floor.
1656
1637
  // Check durable attempt record to prevent peer-process fetch within the window.
1638
+ const cadenceNowMs = this.#now();
1657
1639
  const prior = this.#sharedStore.latestAttempt(
1658
1640
  account.providerId,
1659
1641
  account.family,
1642
+ cadenceNowMs,
1660
1643
  );
1661
1644
  if (
1662
1645
  prior !== undefined &&
1663
- this.#now() - prior.observedAtMs < WINDOW_SAMPLE_INTERVAL_MS
1646
+ cadenceNowMs - prior.observedAtMs < WINDOW_SAMPLE_INTERVAL_MS
1664
1647
  ) {
1665
1648
  continue;
1666
1649
  }