@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.
- package/package.json +1 -1
- package/packages/pi-anthropic-oauth/src/stream.ts +8 -0
- package/src/codex-adapter.ts +81 -7
- package/src/config.ts +151 -0
- package/src/host-final-stop-message.ts +16 -4
- package/src/index.ts +17 -0
- package/src/logical-provider.ts +127 -1
- package/src/model-fallback-policy.ts +384 -0
- package/src/recovery-engine.ts +354 -68
- package/src/recovery-plan.ts +7 -1
- package/src/recovery-send-evidence.ts +29 -0
- package/src/refusal-advice.ts +139 -0
- package/src/shared-usage.ts +21 -4
- package/src/usage-fetch.ts +42 -59
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isManagedFamily,
|
|
3
|
+
type ManagedFamily,
|
|
4
|
+
type MultiAccountConfig,
|
|
5
|
+
} from "./config.js";
|
|
6
|
+
import {
|
|
7
|
+
PROVIDER_ERROR_CODES,
|
|
8
|
+
type ProviderErrorCode,
|
|
9
|
+
} from "./error-classification.js";
|
|
10
|
+
import { vendorForFamily } from "./vendor.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Structured facts the pinned provider adapters surface, projected by the
|
|
14
|
+
* caller from exact fields only. Pinned pi-ai 0.84.4 evidence:
|
|
15
|
+
*
|
|
16
|
+
* - HTTP status: `onResponse({ status })` in `dist/api/anthropic-messages.js`,
|
|
17
|
+
* `openai-responses.js`, and the Codex SSE path of
|
|
18
|
+
* `openai-codex-responses.js`; SDK errors carry `status`
|
|
19
|
+
* (`@anthropic-ai/sdk` `APIError`, `utils/error-body.js` `extractStatus`).
|
|
20
|
+
* - Error code: the Anthropic `{type:"error",error:{type}}` envelope and the
|
|
21
|
+
* Codex `CodexApiError.code` / `response.failed` `error.code` fields, already
|
|
22
|
+
* canonicalized to {@link ProviderErrorCode} by `src/error-classification.ts`.
|
|
23
|
+
* - Stop reason: `AssistantMessage.rawStopReason`, set from Anthropic
|
|
24
|
+
* `delta.stop_reason` (`refusal`, `sensitive`) and from Responses/Codex
|
|
25
|
+
* `status[.incomplete_reason]` (`incomplete.content_filter`).
|
|
26
|
+
*
|
|
27
|
+
* Every adapter flattens failures into a bounded `errorMessage`; that prose is
|
|
28
|
+
* deliberately absent from this contract and is never parsed here.
|
|
29
|
+
*
|
|
30
|
+
* Relationship to the recovery engine's content-free `RecoveryModelFailure`
|
|
31
|
+
* projection (`stopReason`, `api`, `provider`, `model`, `hasErrorMessage`,
|
|
32
|
+
* `diagnosticTypes`, and the optional `code`): no assistant content, thinking,
|
|
33
|
+
* tool call, or error text is needed, and the first six fields are never
|
|
34
|
+
* consulted. The projection's optional `code` carries only the structured stop
|
|
35
|
+
* codes `refusal` and `unknown_stop`; an integration may pass it through as the
|
|
36
|
+
* signal `code`, where both values stop model substitution (`refusal` and
|
|
37
|
+
* `unknown`). The other signal fields lie outside that projection and must be
|
|
38
|
+
* supplied as structured error facts by the integration:
|
|
39
|
+
*
|
|
40
|
+
* - `httpStatus`: the response or SDK error status.
|
|
41
|
+
* - `code`: the canonical code projected from the structured provider error
|
|
42
|
+
* envelope or Codex error-code field before the hook runs, or the
|
|
43
|
+
* projection's structured stop code. The projection carries only
|
|
44
|
+
* `hasErrorMessage`, never the envelope.
|
|
45
|
+
* - `providerStopReason`: `AssistantMessage.rawStopReason`, which the
|
|
46
|
+
* projection deliberately omits.
|
|
47
|
+
* - `verifiedCapability`: computed locally from the outgoing request and the
|
|
48
|
+
* catalog entry, never from the response.
|
|
49
|
+
*
|
|
50
|
+
* Given only the projection's fields, no signal field can trigger a fallback:
|
|
51
|
+
* every field is absent or is a stop code that classifies as `refusal` or
|
|
52
|
+
* `unknown`, so no model substitution happens (fail closed).
|
|
53
|
+
* {@link SelectedFallbackModel} likewise needs only request metadata (required
|
|
54
|
+
* input modalities, whether tools are present, a token estimate), never
|
|
55
|
+
* assistant output.
|
|
56
|
+
*/
|
|
57
|
+
export interface ModelFallbackFailureSignal {
|
|
58
|
+
readonly httpStatus?: number;
|
|
59
|
+
/** Canonical code projected from an exact upstream type/code field. */
|
|
60
|
+
readonly code?: ProviderErrorCode;
|
|
61
|
+
/** Exact `AssistantMessage.rawStopReason`. */
|
|
62
|
+
readonly providerStopReason?: string;
|
|
63
|
+
/** Capability mismatch established from request and catalog metadata, not prose. */
|
|
64
|
+
readonly verifiedCapability?: "input-modality" | "tools";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type ModelFallbackFailureKind =
|
|
68
|
+
| "model_not_found"
|
|
69
|
+
| "capability_incompatibility"
|
|
70
|
+
| "invalid_request"
|
|
71
|
+
| "quota"
|
|
72
|
+
| "auth"
|
|
73
|
+
| "rate_limit"
|
|
74
|
+
| "refusal"
|
|
75
|
+
| "unknown";
|
|
76
|
+
|
|
77
|
+
export interface ModelFallbackFailureClassification {
|
|
78
|
+
readonly kind: ModelFallbackFailureKind;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const PROVIDER_ERROR_CODE_SET: ReadonlySet<string> = new Set(
|
|
82
|
+
PROVIDER_ERROR_CODES,
|
|
83
|
+
);
|
|
84
|
+
const AUTH_CODES: ReadonlySet<ProviderErrorCode> = new Set([
|
|
85
|
+
"invalid_api_key",
|
|
86
|
+
"invalid_token",
|
|
87
|
+
"token_expired",
|
|
88
|
+
"token_revoked",
|
|
89
|
+
"oauth_refresh_rejected",
|
|
90
|
+
"oauth_service_unavailable",
|
|
91
|
+
"insufficient_permissions",
|
|
92
|
+
]);
|
|
93
|
+
const REFUSAL_STOP_REASONS: ReadonlySet<string> = new Set([
|
|
94
|
+
"refusal",
|
|
95
|
+
"sensitive",
|
|
96
|
+
"incomplete.content_filter",
|
|
97
|
+
"content_filter",
|
|
98
|
+
]);
|
|
99
|
+
const VERIFIED_CAPABILITIES: ReadonlySet<string> = new Set([
|
|
100
|
+
"input-modality",
|
|
101
|
+
"tools",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Classifies only exact status/code/stop-reason fields, each revalidated at
|
|
106
|
+
* runtime so a cast or spread caller cannot smuggle prose through. Account
|
|
107
|
+
* facts (quota, rate limit, auth, permission) and refusals always dominate:
|
|
108
|
+
* they route to another account or stop, never to a different model. A
|
|
109
|
+
* structured `refusal` code or refusal stop reason, and a structured
|
|
110
|
+
* `unknown_stop` code, outrank every model-fallback fact (`model_not_found`,
|
|
111
|
+
* verified capability mismatch, malformed request).
|
|
112
|
+
*/
|
|
113
|
+
export function classifyModelFallbackFailure(
|
|
114
|
+
signal: ModelFallbackFailureSignal,
|
|
115
|
+
): ModelFallbackFailureClassification {
|
|
116
|
+
const httpStatus = Number.isInteger(signal.httpStatus)
|
|
117
|
+
? signal.httpStatus
|
|
118
|
+
: undefined;
|
|
119
|
+
const code =
|
|
120
|
+
typeof signal.code === "string" && PROVIDER_ERROR_CODE_SET.has(signal.code)
|
|
121
|
+
? signal.code
|
|
122
|
+
: undefined;
|
|
123
|
+
if (code === "quota_exhausted") return { kind: "quota" };
|
|
124
|
+
if (httpStatus === 429 || code === "rate_limit") return { kind: "rate_limit" };
|
|
125
|
+
if (
|
|
126
|
+
httpStatus === 401 ||
|
|
127
|
+
httpStatus === 403 ||
|
|
128
|
+
(code !== undefined && AUTH_CODES.has(code))
|
|
129
|
+
) {
|
|
130
|
+
return { kind: "auth" };
|
|
131
|
+
}
|
|
132
|
+
if (
|
|
133
|
+
code === "refusal" ||
|
|
134
|
+
(typeof signal.providerStopReason === "string" &&
|
|
135
|
+
REFUSAL_STOP_REASONS.has(signal.providerStopReason))
|
|
136
|
+
) {
|
|
137
|
+
return { kind: "refusal" };
|
|
138
|
+
}
|
|
139
|
+
// An unknown stop proves nothing about the model; it is never a fallback.
|
|
140
|
+
if (code === "unknown_stop") return { kind: "unknown" };
|
|
141
|
+
// A server failure proves nothing about the model; it is never a fallback.
|
|
142
|
+
if (httpStatus !== undefined && httpStatus >= 500) return { kind: "unknown" };
|
|
143
|
+
if (code === "model_not_found") return { kind: "model_not_found" };
|
|
144
|
+
const malformed =
|
|
145
|
+
code === "invalid_request" || code === "unsupported_api_version";
|
|
146
|
+
if (
|
|
147
|
+
typeof signal.verifiedCapability === "string" &&
|
|
148
|
+
VERIFIED_CAPABILITIES.has(signal.verifiedCapability)
|
|
149
|
+
) {
|
|
150
|
+
// A provider rejection of the sent request cannot be attributed to the
|
|
151
|
+
// locally verified mismatch rather than to a malformed request.
|
|
152
|
+
return malformed ? { kind: "unknown" } : { kind: "capability_incompatibility" };
|
|
153
|
+
}
|
|
154
|
+
if (malformed) return { kind: "invalid_request" };
|
|
155
|
+
return { kind: "unknown" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The physical family that serves a model. `openrouter` is the metered router:
|
|
160
|
+
* it has no single model vendor, so it is never "same-vendor" with anything.
|
|
161
|
+
*/
|
|
162
|
+
export type ModelFallbackFamily = ManagedFamily | "openrouter";
|
|
163
|
+
|
|
164
|
+
export interface SelectedFallbackModel {
|
|
165
|
+
readonly modelId: string;
|
|
166
|
+
/** Missing or unknown at runtime fails closed. */
|
|
167
|
+
readonly family?: ModelFallbackFamily;
|
|
168
|
+
readonly requiredInput: readonly string[];
|
|
169
|
+
readonly requiresTools: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* The caller's estimate of the whole request (context plus reserved output)
|
|
172
|
+
* in tokens. Required: an unknown size cannot prove fit and refuses.
|
|
173
|
+
*/
|
|
174
|
+
readonly requestContextTokens: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type ModelFallbackIneligibilityReason =
|
|
178
|
+
| "unavailable"
|
|
179
|
+
| "disabled"
|
|
180
|
+
| "credential-unavailable"
|
|
181
|
+
| "group-excluded"
|
|
182
|
+
| "pricing-unknown"
|
|
183
|
+
| "egress-consent-missing"
|
|
184
|
+
| "delegate-excluded";
|
|
185
|
+
|
|
186
|
+
export type ModelFallbackEligibility =
|
|
187
|
+
| { readonly status: "eligible" }
|
|
188
|
+
| {
|
|
189
|
+
readonly status: "ineligible";
|
|
190
|
+
readonly reason: ModelFallbackIneligibilityReason;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** One exact catalog identity with all live eligibility already projected. */
|
|
194
|
+
export interface ModelFallbackCandidate {
|
|
195
|
+
readonly modelId: string;
|
|
196
|
+
/** Missing or unknown at runtime fails closed. */
|
|
197
|
+
readonly family?: ModelFallbackFamily;
|
|
198
|
+
readonly input: readonly string[];
|
|
199
|
+
readonly supportsTools: boolean;
|
|
200
|
+
readonly contextWindow: number;
|
|
201
|
+
readonly eligibility: ModelFallbackEligibility;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface SelectFallbackModelInput {
|
|
205
|
+
readonly selectedModel: SelectedFallbackModel;
|
|
206
|
+
readonly failure: ModelFallbackFailureClassification;
|
|
207
|
+
readonly config: Pick<
|
|
208
|
+
MultiAccountConfig,
|
|
209
|
+
"modelFallbacks" | "modelFallbackEgress"
|
|
210
|
+
>;
|
|
211
|
+
readonly candidates: readonly ModelFallbackCandidate[];
|
|
212
|
+
readonly cancelled: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type ModelFallbackRefusalReason =
|
|
216
|
+
| "not-configured"
|
|
217
|
+
| "failure-not-eligible"
|
|
218
|
+
| "cancelled"
|
|
219
|
+
| "model-family-ambiguous"
|
|
220
|
+
| "request-size-unknown"
|
|
221
|
+
| "cross-vendor-egress-unauthorized"
|
|
222
|
+
| "no-eligible-destination";
|
|
223
|
+
|
|
224
|
+
export type ModelFallbackEgress = "same-vendor" | "authorized-cross-vendor";
|
|
225
|
+
|
|
226
|
+
export type SelectFallbackModelResult =
|
|
227
|
+
| {
|
|
228
|
+
readonly status: "fallback";
|
|
229
|
+
readonly modelId: string;
|
|
230
|
+
readonly egress: ModelFallbackEgress;
|
|
231
|
+
}
|
|
232
|
+
| { readonly status: "none"; readonly reason: ModelFallbackRefusalReason };
|
|
233
|
+
|
|
234
|
+
/** Injected-hook shape for the recovery engine's `model` action. */
|
|
235
|
+
export type SelectFallbackModel = (
|
|
236
|
+
input: SelectFallbackModelInput,
|
|
237
|
+
) => SelectFallbackModelResult;
|
|
238
|
+
|
|
239
|
+
/** Managed vendor, the metered router, or `null` when the family is unknown. */
|
|
240
|
+
type EgressIdentity =
|
|
241
|
+
| { readonly kind: "vendor"; readonly vendor: string }
|
|
242
|
+
| { readonly kind: "openrouter" };
|
|
243
|
+
|
|
244
|
+
function egressIdentity(family: unknown): EgressIdentity | null {
|
|
245
|
+
if (typeof family !== "string") return null;
|
|
246
|
+
if (family === "openrouter") return { kind: "openrouter" };
|
|
247
|
+
if (!isManagedFamily(family)) return null;
|
|
248
|
+
return { kind: "vendor", vendor: vendorForFamily(family) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function sameEgressIdentity(left: EgressIdentity, right: EgressIdentity): boolean {
|
|
252
|
+
return (
|
|
253
|
+
left.kind === right.kind &&
|
|
254
|
+
(left.kind === "openrouter" ||
|
|
255
|
+
(right.kind === "vendor" && left.vendor === right.vendor))
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Only a managed destination authored by the source's vendor is same-vendor. */
|
|
260
|
+
function isSameVendor(source: EgressIdentity, destination: EgressIdentity): boolean {
|
|
261
|
+
return (
|
|
262
|
+
source.kind === "vendor" &&
|
|
263
|
+
destination.kind === "vendor" &&
|
|
264
|
+
source.vendor === destination.vendor
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function configuredDestinations(
|
|
269
|
+
config: SelectFallbackModelInput["config"],
|
|
270
|
+
modelId: string,
|
|
271
|
+
): readonly string[] {
|
|
272
|
+
const map = config.modelFallbacks;
|
|
273
|
+
// Own keys only: an inherited name such as `constructor` is never policy.
|
|
274
|
+
if (map === undefined || !Object.hasOwn(map, modelId)) return [];
|
|
275
|
+
return map[modelId] ?? [];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function isTokenCount(value: unknown): value is number {
|
|
279
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function fitsRequest(
|
|
283
|
+
candidate: ModelFallbackCandidate,
|
|
284
|
+
selected: SelectedFallbackModel,
|
|
285
|
+
): boolean {
|
|
286
|
+
const supported = new Set(candidate.input);
|
|
287
|
+
return (
|
|
288
|
+
selected.requiredInput.every((modality) => supported.has(modality)) &&
|
|
289
|
+
(!selected.requiresTools || candidate.supportsTools === true) &&
|
|
290
|
+
isTokenCount(candidate.contextWindow) &&
|
|
291
|
+
selected.requestContextTokens <= candidate.contextWindow
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function hasDirectionalEgressAuthorization(
|
|
296
|
+
input: SelectFallbackModelInput,
|
|
297
|
+
destinationModelId: string,
|
|
298
|
+
): boolean {
|
|
299
|
+
return (input.config.modelFallbackEgress ?? []).some(
|
|
300
|
+
(edge) =>
|
|
301
|
+
edge.sourceModelId === input.selectedModel.modelId &&
|
|
302
|
+
edge.destinationModelId === destinationModelId,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Selects at most one configured model and performs no I/O or dispatch.
|
|
308
|
+
*
|
|
309
|
+
* Only the first configured destination that is present, unambiguous,
|
|
310
|
+
* currently eligible, and able to carry the request unchanged is returned. A
|
|
311
|
+
* smaller context window is not a rejection on its own; a window the request
|
|
312
|
+
* does not fit is, because nothing downstream may truncate or summarize.
|
|
313
|
+
* Metered (OpenRouter) and other-vendor destinations always require the exact
|
|
314
|
+
* directional `modelFallbackEgress` edge.
|
|
315
|
+
*/
|
|
316
|
+
export const selectFallbackModel: SelectFallbackModel = (input) => {
|
|
317
|
+
if (input.cancelled) return { status: "none", reason: "cancelled" };
|
|
318
|
+
if (
|
|
319
|
+
input.failure.kind !== "model_not_found" &&
|
|
320
|
+
input.failure.kind !== "capability_incompatibility"
|
|
321
|
+
) {
|
|
322
|
+
return { status: "none", reason: "failure-not-eligible" };
|
|
323
|
+
}
|
|
324
|
+
const source = input.selectedModel;
|
|
325
|
+
const destinations = configuredDestinations(input.config, source.modelId);
|
|
326
|
+
if (destinations.length === 0) {
|
|
327
|
+
return { status: "none", reason: "not-configured" };
|
|
328
|
+
}
|
|
329
|
+
const sourceIdentity = egressIdentity(source.family);
|
|
330
|
+
if (sourceIdentity === null) {
|
|
331
|
+
return { status: "none", reason: "model-family-ambiguous" };
|
|
332
|
+
}
|
|
333
|
+
if (!isTokenCount(source.requestContextTokens)) {
|
|
334
|
+
return { status: "none", reason: "request-size-unknown" };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let sawUnauthorizedCrossVendor = false;
|
|
338
|
+
for (const destinationModelId of destinations) {
|
|
339
|
+
if (destinationModelId === source.modelId) continue;
|
|
340
|
+
const matches = input.candidates.filter(
|
|
341
|
+
(candidate) => candidate.modelId === destinationModelId,
|
|
342
|
+
);
|
|
343
|
+
if (matches.length === 0) continue;
|
|
344
|
+
const identities = matches.map((candidate) =>
|
|
345
|
+
egressIdentity(candidate.family),
|
|
346
|
+
);
|
|
347
|
+
const destinationIdentity = identities[0];
|
|
348
|
+
if (
|
|
349
|
+
destinationIdentity === undefined ||
|
|
350
|
+
destinationIdentity === null ||
|
|
351
|
+
identities.some(
|
|
352
|
+
(identity) =>
|
|
353
|
+
identity === null || !sameEgressIdentity(identity, destinationIdentity),
|
|
354
|
+
)
|
|
355
|
+
) {
|
|
356
|
+
return { status: "none", reason: "model-family-ambiguous" };
|
|
357
|
+
}
|
|
358
|
+
const usable = matches.some(
|
|
359
|
+
(candidate) =>
|
|
360
|
+
candidate.eligibility.status === "eligible" &&
|
|
361
|
+
fitsRequest(candidate, source),
|
|
362
|
+
);
|
|
363
|
+
if (!usable) continue;
|
|
364
|
+
|
|
365
|
+
if (isSameVendor(sourceIdentity, destinationIdentity)) {
|
|
366
|
+
return { status: "fallback", modelId: destinationModelId, egress: "same-vendor" };
|
|
367
|
+
}
|
|
368
|
+
if (hasDirectionalEgressAuthorization(input, destinationModelId)) {
|
|
369
|
+
return {
|
|
370
|
+
status: "fallback",
|
|
371
|
+
modelId: destinationModelId,
|
|
372
|
+
egress: "authorized-cross-vendor",
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
sawUnauthorizedCrossVendor = true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
status: "none",
|
|
380
|
+
reason: sawUnauthorizedCrossVendor
|
|
381
|
+
? "cross-vendor-egress-unauthorized"
|
|
382
|
+
: "no-eligible-destination",
|
|
383
|
+
};
|
|
384
|
+
};
|