@centerforagenticai/pi-multi-account 0.1.6 → 0.1.8
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/README.md +5 -0
- package/package.json +12 -6
- package/src/account-labels.ts +27 -0
- package/src/commands.ts +140 -62
- package/src/index.ts +84 -1
- package/src/logical-provider.ts +37 -6
- package/src/public-status-projection.ts +187 -0
- package/src/public-status.d.ts +116 -0
- package/src/public-status.js +90 -0
package/README.md
CHANGED
|
@@ -43,9 +43,14 @@ The package bundles reviewed Anthropic OAuth and Google Antigravity source under
|
|
|
43
43
|
| Provider | `unified` | Optional exact-model logical provider generated from managed catalogs. |
|
|
44
44
|
| CLI | `multi-account` | Offline cost reports, pricing refresh, period closure, and account plan history. |
|
|
45
45
|
| Export | exact-model route resolver v1 | Read-only, credential-free route policy for code consumers. |
|
|
46
|
+
| Export | `./public-status` v1 | Read-only live account status for other extensions over `pi.events`. |
|
|
46
47
|
|
|
47
48
|
Every subcommand and argument is in [Commands, tools, and autocomplete](docs/commands.md). The resolver contract is in [Routing and recovery](docs/routing.md).
|
|
48
49
|
|
|
50
|
+
### Public account status subpath
|
|
51
|
+
|
|
52
|
+
Import `@centerforagenticai/pi-multi-account/public-status` and call `discoverPublicAccountStatusReader(pi.events)`. Use the package name your installation resolves: the package is installed under its published name. The subpath is a plain JavaScript module with no imports, so plain Node can load it and it never loads the extension or Pi. The v1 contract uses the query channel `pi-multi-account:public-status-service-query:v1` and returns a `public-status-v1` snapshot, or `owner-unavailable` until `session_start` finishes and after `session_shutdown`, and `source-error` when the owner fails. Version 1 covers only Anthropic and OpenAI Codex accounts; Google Antigravity accounts are omitted. Each account's `label` is the label you configured in `accountLabels`, or else the provider id. It is never read from a token. See [Commands, tools, and autocomplete](docs/commands.md#public-live-account-status-public-status).
|
|
53
|
+
|
|
49
54
|
## Configuration
|
|
50
55
|
|
|
51
56
|
The extension reads one machine-global file and no project-local config:
|
package/package.json
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.8",
|
|
3
3
|
"description": "Global Anthropic and OpenAI Codex multi-account OAuth routing for Pi.",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"multi-account": "scripts/multi-account.mjs"
|
|
7
7
|
},
|
|
8
|
-
"exports":
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./public-status": {
|
|
11
|
+
"types": "./src/public-status.d.ts",
|
|
12
|
+
"default": "./src/public-status.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
9
15
|
"files": [
|
|
10
16
|
"src",
|
|
11
17
|
"scripts/multi-account.mjs",
|
|
@@ -59,11 +65,11 @@
|
|
|
59
65
|
"undici": "^8.10.0"
|
|
60
66
|
},
|
|
61
67
|
"devDependencies": {
|
|
62
|
-
"@earendil-works/pi-ai": "^1.0.
|
|
63
|
-
"@earendil-works/pi-coding-agent": "^1.0.
|
|
64
|
-
"@earendil-works/pi-tui": "^1.0.
|
|
68
|
+
"@earendil-works/pi-ai": "^1.0.4",
|
|
69
|
+
"@earendil-works/pi-coding-agent": "^1.0.4",
|
|
70
|
+
"@earendil-works/pi-tui": "^1.0.4",
|
|
65
71
|
"@types/node": "^24.0.0",
|
|
66
|
-
"typebox": "1.3.
|
|
72
|
+
"typebox": "1.3.35",
|
|
67
73
|
"typescript": "^5.9.0",
|
|
68
74
|
"vitest": "^4.1.11"
|
|
69
75
|
},
|
package/src/account-labels.ts
CHANGED
|
@@ -207,6 +207,33 @@ export function resolveAccountLabelWithinLimit(input: Readonly<{
|
|
|
207
207
|
});
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Resolves ONLY an operator-configured label for a managed account, with the
|
|
212
|
+
* same current-limit guard as {@link resolveAccountLabelWithinLimit}.
|
|
213
|
+
*
|
|
214
|
+
* This is the public status surface's label source. It takes no token reader
|
|
215
|
+
* at all, so a token-derived identity (for example a Codex JWT `email`,
|
|
216
|
+
* `preferred_username`, or `name` claim) cannot reach it. Returns undefined when
|
|
217
|
+
* the id is malformed or above the limit, or when no valid label is configured;
|
|
218
|
+
* the caller then falls back to the provider id.
|
|
219
|
+
*/
|
|
220
|
+
export function resolveConfiguredAccountLabelWithinLimit(input: Readonly<{
|
|
221
|
+
providerId: string;
|
|
222
|
+
accountLimit: number;
|
|
223
|
+
configured: AccountLabelConfig;
|
|
224
|
+
}>): string | undefined {
|
|
225
|
+
const slot = classifyProviderId({
|
|
226
|
+
providerId: input.providerId,
|
|
227
|
+
credentialType: "unknown",
|
|
228
|
+
});
|
|
229
|
+
if (
|
|
230
|
+
slot === null ||
|
|
231
|
+
!isProviderSlotWithinAccountLimit(slot, input.accountLimit)
|
|
232
|
+
) return undefined;
|
|
233
|
+
if (!Object.hasOwn(input.configured, input.providerId)) return undefined;
|
|
234
|
+
return normalizeLabel(input.configured[input.providerId]);
|
|
235
|
+
}
|
|
236
|
+
|
|
210
237
|
/**
|
|
211
238
|
* Composes the string Pi shows in its login list and provider UI, pairing the
|
|
212
239
|
* family's product name with the account's own label. When the label carries no
|
package/src/commands.ts
CHANGED
|
@@ -65,8 +65,11 @@ import { type DiagnosticLog, sanitizedJson } from "./diagnostics.js";
|
|
|
65
65
|
import {
|
|
66
66
|
accountHealth,
|
|
67
67
|
renderStatus,
|
|
68
|
+
type AccountStatusView,
|
|
68
69
|
type StatusViewInput,
|
|
69
70
|
} from "./status-view.js";
|
|
71
|
+
import type { PublicAccountStatusSnapshot } from "./public-status.js";
|
|
72
|
+
import { projectPublicAccountStatus } from "./public-status-projection.js";
|
|
70
73
|
import type { CredentialType } from "./discovery.js";
|
|
71
74
|
import { providerTypeFor, type ProviderType } from "./vendor.js";
|
|
72
75
|
import type { UnsupportedModelPair } from "./model-support.js";
|
|
@@ -331,6 +334,11 @@ export interface CommandDependencies {
|
|
|
331
334
|
readonly activeModelId?: () => string | undefined;
|
|
332
335
|
/** Operator-facing label for a managed account, when one resolves. */
|
|
333
336
|
readonly accountLabel?: (providerId: string) => string | undefined;
|
|
337
|
+
/**
|
|
338
|
+
* Operator-configured label only, for the public status service. Must never
|
|
339
|
+
* derive a label from a credential.
|
|
340
|
+
*/
|
|
341
|
+
readonly publicAccountLabel?: (providerId: string) => string | undefined;
|
|
334
342
|
/** Bounded credential expiry for a managed account, when known. */
|
|
335
343
|
readonly credentialExpiry?: (providerId: string) => number | undefined;
|
|
336
344
|
/** Session-local provider/model divergence observations for operator status. */
|
|
@@ -341,6 +349,8 @@ export interface CommandDependencies {
|
|
|
341
349
|
) => UsageFetchStatus | undefined;
|
|
342
350
|
/** Read-only project/account/model cost intelligence for one calendar series. */
|
|
343
351
|
readonly costReport?: (periodType: PeriodType) => Promise<CostReport>;
|
|
352
|
+
/** Month API-equivalent cost report for the public status service. */
|
|
353
|
+
readonly publicCostReport?: () => Promise<CostReport>;
|
|
344
354
|
/** Explicit metered last-resort policy; never includes credential material. */
|
|
345
355
|
readonly meteredFallbackStatus?: () => MeteredFallbackStatus;
|
|
346
356
|
readonly setModel: (model: Model<Api>) => Promise<boolean>;
|
|
@@ -811,71 +821,15 @@ export class MultiAccountCommandController {
|
|
|
811
821
|
});
|
|
812
822
|
}
|
|
813
823
|
const currentProviderId = this.#dependencies.currentProviderId();
|
|
814
|
-
const activeAccountProviderId =
|
|
815
|
-
this.#dependencies.activeAccountProviderId?.() ?? currentProviderId;
|
|
816
824
|
// Label and expiry are resolved HERE, not per-branch, so the JSON and
|
|
817
825
|
// human-readable views cannot drift: a scripted consumer sees the same
|
|
818
826
|
// credential freshness the operator does.
|
|
819
|
-
const projected =
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
const unavailable =
|
|
826
|
-
this.#dependencies.state.getInvalidation(account.providerId) !==
|
|
827
|
-
undefined;
|
|
828
|
-
const usageUntrusted =
|
|
829
|
-
this.#dependencies.state.isUsageSnapshotUntrusted(
|
|
830
|
-
account.providerId,
|
|
831
|
-
now,
|
|
832
|
-
);
|
|
833
|
-
const usage = this.#dependencies.usage.get(account.providerId);
|
|
834
|
-
const view = {
|
|
835
|
-
providerId: account.providerId,
|
|
836
|
-
family: account.family,
|
|
837
|
-
// Derived live from the discovered credential type; additive field,
|
|
838
|
-
// no existing field changes. An absent credentialType defaults to
|
|
839
|
-
// the subscription type.
|
|
840
|
-
providerType: providerTypeFor(
|
|
841
|
-
account.family,
|
|
842
|
-
account.credentialType ?? "unknown",
|
|
843
|
-
),
|
|
844
|
-
active: account.providerId === activeAccountProviderId,
|
|
845
|
-
disabled,
|
|
846
|
-
unavailable,
|
|
847
|
-
...(coolingUntilMs === undefined ? {} : { coolingUntilMs }),
|
|
848
|
-
...(usageUntrusted ? { usageUntrusted: true } : {}),
|
|
849
|
-
...(usage === undefined ? {} : { usage }),
|
|
850
|
-
};
|
|
851
|
-
const health = accountHealth(view, now);
|
|
852
|
-
return {
|
|
853
|
-
...view,
|
|
854
|
-
api: account.model.api,
|
|
855
|
-
healthy: health === "ready" || health === "low-headroom",
|
|
856
|
-
...(account.accountFingerprint === undefined
|
|
857
|
-
? {}
|
|
858
|
-
: { accountFingerprint: account.accountFingerprint }),
|
|
859
|
-
usageFetch: this.#dependencies.usageFetchStatus?.(account.providerId),
|
|
860
|
-
allModelsUnsupported:
|
|
861
|
-
account.modelIds.length > 0 &&
|
|
862
|
-
account.modelIds.every((modelId) =>
|
|
863
|
-
unsupportedModels.some(
|
|
864
|
-
(pair) =>
|
|
865
|
-
pair.providerId === account.providerId &&
|
|
866
|
-
pair.modelId === modelId,
|
|
867
|
-
),
|
|
868
|
-
),
|
|
869
|
-
...this.#optionalField(
|
|
870
|
-
"label",
|
|
871
|
-
this.#dependencies.accountLabel?.(account.providerId),
|
|
872
|
-
),
|
|
873
|
-
...this.#optionalField(
|
|
874
|
-
"expiresAtMs",
|
|
875
|
-
this.#dependencies.credentialExpiry?.(account.providerId),
|
|
876
|
-
),
|
|
877
|
-
};
|
|
878
|
-
});
|
|
827
|
+
const projected = this.#projectAccountViews(
|
|
828
|
+
accounts,
|
|
829
|
+
now,
|
|
830
|
+
unsupportedModels,
|
|
831
|
+
(providerId) => this.#dependencies.accountLabel?.(providerId),
|
|
832
|
+
);
|
|
879
833
|
const meteredFallback = this.#dependencies.meteredFallbackStatus?.();
|
|
880
834
|
if (asJson) {
|
|
881
835
|
return sanitizedJson({
|
|
@@ -1459,6 +1413,130 @@ export class MultiAccountCommandController {
|
|
|
1459
1413
|
return modelId === undefined ? {} : { activeModelId: modelId };
|
|
1460
1414
|
}
|
|
1461
1415
|
|
|
1416
|
+
/**
|
|
1417
|
+
* The one live per-account projection shared by `status`, `status --json`,
|
|
1418
|
+
* and the public status service. `labelFor` is a parameter because the
|
|
1419
|
+
* public service must publish only operator-configured labels, never the
|
|
1420
|
+
* token-derived label the operator status shows.
|
|
1421
|
+
*/
|
|
1422
|
+
#projectAccountViews(
|
|
1423
|
+
accounts: readonly OperatorAccount[],
|
|
1424
|
+
now: number,
|
|
1425
|
+
unsupportedModels: readonly UnsupportedModelPair[],
|
|
1426
|
+
labelFor: (providerId: string) => string | undefined,
|
|
1427
|
+
) {
|
|
1428
|
+
const activeAccountProviderId =
|
|
1429
|
+
this.#dependencies.activeAccountProviderId?.() ??
|
|
1430
|
+
this.#dependencies.currentProviderId();
|
|
1431
|
+
return accounts.map((account) => {
|
|
1432
|
+
const disabled = this.#disabledProviders.has(account.providerId);
|
|
1433
|
+
const coolingUntilMs = this.#dependencies.state.getCooldown(
|
|
1434
|
+
account.providerId,
|
|
1435
|
+
now,
|
|
1436
|
+
)?.untilMs;
|
|
1437
|
+
const unavailable =
|
|
1438
|
+
this.#dependencies.state.getInvalidation(account.providerId) !==
|
|
1439
|
+
undefined;
|
|
1440
|
+
const usageUntrusted =
|
|
1441
|
+
this.#dependencies.state.isUsageSnapshotUntrusted(
|
|
1442
|
+
account.providerId,
|
|
1443
|
+
now,
|
|
1444
|
+
);
|
|
1445
|
+
const usage = this.#dependencies.usage.get(account.providerId);
|
|
1446
|
+
const view = {
|
|
1447
|
+
providerId: account.providerId,
|
|
1448
|
+
family: account.family,
|
|
1449
|
+
// Derived live from the discovered credential type; additive field,
|
|
1450
|
+
// no existing field changes. An absent credentialType defaults to
|
|
1451
|
+
// the subscription type.
|
|
1452
|
+
providerType: providerTypeFor(
|
|
1453
|
+
account.family,
|
|
1454
|
+
account.credentialType ?? "unknown",
|
|
1455
|
+
),
|
|
1456
|
+
active: account.providerId === activeAccountProviderId,
|
|
1457
|
+
disabled,
|
|
1458
|
+
unavailable,
|
|
1459
|
+
...(coolingUntilMs === undefined ? {} : { coolingUntilMs }),
|
|
1460
|
+
...(usageUntrusted ? { usageUntrusted: true } : {}),
|
|
1461
|
+
...(usage === undefined ? {} : { usage }),
|
|
1462
|
+
};
|
|
1463
|
+
const health = accountHealth(view, now);
|
|
1464
|
+
return {
|
|
1465
|
+
...view,
|
|
1466
|
+
api: account.model.api,
|
|
1467
|
+
healthy: health === "ready" || health === "low-headroom",
|
|
1468
|
+
...(account.accountFingerprint === undefined
|
|
1469
|
+
? {}
|
|
1470
|
+
: { accountFingerprint: account.accountFingerprint }),
|
|
1471
|
+
usageFetch: this.#dependencies.usageFetchStatus?.(account.providerId),
|
|
1472
|
+
allModelsUnsupported:
|
|
1473
|
+
account.modelIds.length > 0 &&
|
|
1474
|
+
account.modelIds.every((modelId) =>
|
|
1475
|
+
unsupportedModels.some(
|
|
1476
|
+
(pair) =>
|
|
1477
|
+
pair.providerId === account.providerId &&
|
|
1478
|
+
pair.modelId === modelId,
|
|
1479
|
+
),
|
|
1480
|
+
),
|
|
1481
|
+
...this.#optionalField("label", labelFor(account.providerId)),
|
|
1482
|
+
...this.#optionalField(
|
|
1483
|
+
"expiresAtMs",
|
|
1484
|
+
this.#dependencies.credentialExpiry?.(account.providerId),
|
|
1485
|
+
),
|
|
1486
|
+
};
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Read-only snapshot for the `./public-status` event-bus service.
|
|
1492
|
+
*
|
|
1493
|
+
* Reuses the status projection, but labels come only from
|
|
1494
|
+
* `publicAccountLabel` (operator configuration). A token-derived label, such
|
|
1495
|
+
* as a Codex JWT email claim, is a human account identifier and must never
|
|
1496
|
+
* reach this surface, so the status `accountLabel` resolver is not used.
|
|
1497
|
+
*/
|
|
1498
|
+
async publicStatus(): Promise<PublicAccountStatusSnapshot> {
|
|
1499
|
+
const nowMs = this.#now();
|
|
1500
|
+
const projected = this.#projectAccountViews(
|
|
1501
|
+
this.#accounts(),
|
|
1502
|
+
nowMs,
|
|
1503
|
+
this.#dependencies.unsupportedModels?.() ?? [],
|
|
1504
|
+
(providerId) => this.#dependencies.publicAccountLabel?.(providerId),
|
|
1505
|
+
);
|
|
1506
|
+
const accounts = projected.map(
|
|
1507
|
+
(account): AccountStatusView => ({
|
|
1508
|
+
providerId: account.providerId,
|
|
1509
|
+
family: account.family,
|
|
1510
|
+
active: account.active,
|
|
1511
|
+
disabled: account.disabled,
|
|
1512
|
+
unavailable: account.unavailable,
|
|
1513
|
+
...(account.coolingUntilMs === undefined
|
|
1514
|
+
? {}
|
|
1515
|
+
: { coolingUntilMs: account.coolingUntilMs }),
|
|
1516
|
+
...(account.usageUntrusted ? { usageUntrusted: true } : {}),
|
|
1517
|
+
...(account.usage === undefined ? {} : { usage: account.usage }),
|
|
1518
|
+
...(account.usageFetch === undefined
|
|
1519
|
+
? {}
|
|
1520
|
+
: { usageFetch: account.usageFetch }),
|
|
1521
|
+
...("label" in account ? { label: account.label } : {}),
|
|
1522
|
+
...("expiresAtMs" in account
|
|
1523
|
+
? { expiresAtMs: account.expiresAtMs }
|
|
1524
|
+
: {}),
|
|
1525
|
+
}),
|
|
1526
|
+
);
|
|
1527
|
+
let costReport: CostReport | undefined;
|
|
1528
|
+
try {
|
|
1529
|
+
costReport = await this.#dependencies.publicCostReport?.();
|
|
1530
|
+
} catch {
|
|
1531
|
+
costReport = undefined;
|
|
1532
|
+
}
|
|
1533
|
+
return projectPublicAccountStatus({
|
|
1534
|
+
nowMs,
|
|
1535
|
+
accounts,
|
|
1536
|
+
...(costReport === undefined ? {} : { costReport }),
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1462
1540
|
/**
|
|
1463
1541
|
* Machine-readable account status for the agent-invocable tool.
|
|
1464
1542
|
*
|
package/src/index.ts
CHANGED
|
@@ -75,7 +75,7 @@ import {
|
|
|
75
75
|
import { parsePiCatalogSnapshot, type PiCatalogSnapshot } from "./api-pricing.js";
|
|
76
76
|
import type { CostReport } from "./cost-report.js";
|
|
77
77
|
import { createDefaultCostReportReader } from "./cost-report-reader.js";
|
|
78
|
-
import type
|
|
78
|
+
import { getPeriodBounds, type PeriodType } from "./period-boundaries.js";
|
|
79
79
|
import {
|
|
80
80
|
mergeRefreshedCredentials,
|
|
81
81
|
type CredentialUsability,
|
|
@@ -223,7 +223,12 @@ import { restoreManagedAliasModel } from "./session-restore.js";
|
|
|
223
223
|
import {
|
|
224
224
|
accountFingerprint,
|
|
225
225
|
resolveAccountLabelWithinLimit,
|
|
226
|
+
resolveConfiguredAccountLabelWithinLimit,
|
|
226
227
|
} from "./account-labels.js";
|
|
228
|
+
import {
|
|
229
|
+
registerPublicAccountStatusService,
|
|
230
|
+
type PublicAccountStatusEventBus,
|
|
231
|
+
} from "./public-status.js";
|
|
227
232
|
import {
|
|
228
233
|
createAnthropicAliasProviderConfig,
|
|
229
234
|
reassertAnthropicBaseRegistration,
|
|
@@ -2277,6 +2282,14 @@ export const createMultiAccountExtension =
|
|
|
2277
2282
|
const registerLogicalApi = createLogicalApiRegistrarForFactoryGeneration();
|
|
2278
2283
|
|
|
2279
2284
|
let context: ExtensionContext | undefined;
|
|
2285
|
+
// The public status service answers owner-unavailable until session_start
|
|
2286
|
+
// has loaded config and discovered accounts, and again after shutdown, so
|
|
2287
|
+
// a consumer never sees a default-config snapshot as authoritative.
|
|
2288
|
+
let publicStatusStarted = false;
|
|
2289
|
+
let publicStatusDisposed = false;
|
|
2290
|
+
// The UTC day (the cost period closer's day) for which a public read last
|
|
2291
|
+
// scheduled the period closer.
|
|
2292
|
+
let publicStatusCostCloseDayStartMs: number | undefined;
|
|
2280
2293
|
let attributionStore: AttributionStore | undefined;
|
|
2281
2294
|
let latestLogicalPhysicalProviderId: string | undefined;
|
|
2282
2295
|
const logicalTerminalAssociations = createLogicalTerminalAssociationStore();
|
|
@@ -3304,6 +3317,20 @@ export const createMultiAccountExtension =
|
|
|
3304
3317
|
const label = resolveLabelFor(providerId);
|
|
3305
3318
|
return label === providerId ? undefined : label;
|
|
3306
3319
|
},
|
|
3320
|
+
// The public status service publishes configured labels only. It must
|
|
3321
|
+
// not use resolveLabelFor, which derives a label from the stored token
|
|
3322
|
+
// (a Codex JWT email claim is a human account identifier).
|
|
3323
|
+
publicAccountLabel: (providerId) => {
|
|
3324
|
+
try {
|
|
3325
|
+
return resolveConfiguredAccountLabelWithinLimit({
|
|
3326
|
+
providerId,
|
|
3327
|
+
accountLimit: config.accountLimit,
|
|
3328
|
+
configured: config.accountLabels,
|
|
3329
|
+
});
|
|
3330
|
+
} catch {
|
|
3331
|
+
return undefined;
|
|
3332
|
+
}
|
|
3333
|
+
},
|
|
3307
3334
|
// Re-read live rather than using the discovery snapshot: a long-lived
|
|
3308
3335
|
// session's snapshot ages out and would report healthy accounts as
|
|
3309
3336
|
// expired, prompting an unnecessary sign-in.
|
|
@@ -3317,6 +3344,12 @@ export const createMultiAccountExtension =
|
|
|
3317
3344
|
: usageFetcher.status(providerId, family, config);
|
|
3318
3345
|
},
|
|
3319
3346
|
costReport,
|
|
3347
|
+
// The month API-equivalent report, read without the period closer:
|
|
3348
|
+
// a polled public read must not drive retained-state writes.
|
|
3349
|
+
publicCostReport: async () =>
|
|
3350
|
+
options.costReport === undefined
|
|
3351
|
+
? defaultCostReportReader("month")
|
|
3352
|
+
: options.costReport("month"),
|
|
3320
3353
|
meteredFallbackStatus: () => {
|
|
3321
3354
|
const policy = openRouterPolicy();
|
|
3322
3355
|
const configuredPolicy = resolveOpenRouterEnvironmentPolicy({
|
|
@@ -3398,6 +3431,32 @@ export const createMultiAccountExtension =
|
|
|
3398
3431
|
}),
|
|
3399
3432
|
},
|
|
3400
3433
|
});
|
|
3434
|
+
// Read-only public status service on Pi's shared event bus. Consumers
|
|
3435
|
+
// discover it through the dependency-free `./public-status` subpath.
|
|
3436
|
+
// Hosts without an event bus (minimal embedders and test doubles) simply
|
|
3437
|
+
// do not offer the service; consumers then discover "unsupported".
|
|
3438
|
+
const publicStatusEvents = (pi as { readonly events?: PublicAccountStatusEventBus })
|
|
3439
|
+
.events;
|
|
3440
|
+
const unregisterPublicStatusService =
|
|
3441
|
+
publicStatusEvents === undefined
|
|
3442
|
+
? () => {}
|
|
3443
|
+
: registerPublicAccountStatusService(publicStatusEvents, async () => {
|
|
3444
|
+
if (!publicStatusStarted || publicStatusDisposed) {
|
|
3445
|
+
return { status: "unavailable", reason: "owner-unavailable" };
|
|
3446
|
+
}
|
|
3447
|
+
schedulePublicStatusCostClose(Date.now());
|
|
3448
|
+
return {
|
|
3449
|
+
status: "available",
|
|
3450
|
+
snapshot: await commands.publicStatus(),
|
|
3451
|
+
};
|
|
3452
|
+
});
|
|
3453
|
+
const disposePublicStatusService = (): void => {
|
|
3454
|
+
// A handler a bus failed to unsubscribe must not keep publishing a
|
|
3455
|
+
// frozen snapshot after shutdown; it reads owner-unavailable instead.
|
|
3456
|
+
publicStatusDisposed = true;
|
|
3457
|
+
publicStatusStarted = false;
|
|
3458
|
+
unregisterPublicStatusService();
|
|
3459
|
+
};
|
|
3401
3460
|
/**
|
|
3402
3461
|
* The latest failure classified during the current agent run.
|
|
3403
3462
|
*
|
|
@@ -4362,6 +4421,22 @@ export const createMultiAccountExtension =
|
|
|
4362
4421
|
warnCostCloseFailed();
|
|
4363
4422
|
}
|
|
4364
4423
|
};
|
|
4424
|
+
// The public read itself stays write-free, but the month estimate reads
|
|
4425
|
+
// closed day digests plus today's raw rows only. On the first public read
|
|
4426
|
+
// of a new day, schedule the same deduplicated, leased closer a provider
|
|
4427
|
+
// response uses (without awaiting it), so yesterday's spend reaches the
|
|
4428
|
+
// month digest even when no provider response has arrived yet today.
|
|
4429
|
+
const schedulePublicStatusCostClose = (nowMs: number): void => {
|
|
4430
|
+
let dayStartMs: number;
|
|
4431
|
+
try {
|
|
4432
|
+
dayStartMs = getPeriodBounds(nowMs, "day").startMs;
|
|
4433
|
+
} catch {
|
|
4434
|
+
return;
|
|
4435
|
+
}
|
|
4436
|
+
if (publicStatusCostCloseDayStartMs === dayStartMs) return;
|
|
4437
|
+
publicStatusCostCloseDayStartMs = dayStartMs;
|
|
4438
|
+
closeCostPeriodsAfterObservation(nowMs);
|
|
4439
|
+
};
|
|
4365
4440
|
const recordManagedAssistant = async (
|
|
4366
4441
|
message: AssistantMessage,
|
|
4367
4442
|
providerId: string,
|
|
@@ -4949,6 +5024,9 @@ export const createMultiAccountExtension =
|
|
|
4949
5024
|
} catch {
|
|
4950
5025
|
// The footer cannot fail session restore.
|
|
4951
5026
|
}
|
|
5027
|
+
// Last: config is loaded and accounts are discovered, so the public
|
|
5028
|
+
// status service may now publish an authoritative snapshot.
|
|
5029
|
+
publicStatusStarted = !publicStatusDisposed;
|
|
4952
5030
|
}),
|
|
4953
5031
|
);
|
|
4954
5032
|
pi.on(
|
|
@@ -5894,6 +5972,11 @@ export const createMultiAccountExtension =
|
|
|
5894
5972
|
} catch {
|
|
5895
5973
|
// Footer cleanup cannot block session teardown.
|
|
5896
5974
|
}
|
|
5975
|
+
try {
|
|
5976
|
+
disposePublicStatusService();
|
|
5977
|
+
} catch {
|
|
5978
|
+
// Unsubscribing is best-effort and cannot block teardown.
|
|
5979
|
+
}
|
|
5897
5980
|
resolverOwner.dispose();
|
|
5898
5981
|
});
|
|
5899
5982
|
lifecycle.register(pi);
|
package/src/logical-provider.ts
CHANGED
|
@@ -456,6 +456,14 @@ const VISIBLE_FAILURE_CAUSES: Readonly<Record<LogicalFailureEvidence["category"]
|
|
|
456
456
|
export const HOST_STALE_INSTALL_MESSAGE =
|
|
457
457
|
"pi's installed files changed while this session was running; restart the pi session to load the current install.";
|
|
458
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Public text when the missing module is a bare package rather than one of
|
|
461
|
+
* pi's own bundle files: the dependency is absent from the install, so a
|
|
462
|
+
* restart loads the same broken tree and only a reinstall helps.
|
|
463
|
+
*/
|
|
464
|
+
export const HOST_MISSING_DEPENDENCY_MESSAGE =
|
|
465
|
+
"pi could not load a package it depends on; reinstall pi (or the extension that needs the package), then restart the pi session.";
|
|
466
|
+
|
|
459
467
|
/**
|
|
460
468
|
* Node's own phrasing, anchored at the start: pi-ai `lazyStream` keeps
|
|
461
469
|
* `error.message` unprefixed. An unanchored match would misread a provider or
|
|
@@ -491,7 +499,7 @@ function hasStructuredFailureEvidence(failure: ProviderFailureSignal | undefined
|
|
|
491
499
|
* Bounded diagnostic cause for a stale install: the Node error code and the
|
|
492
500
|
* missing file's base name. Directories are dropped so no local path is kept.
|
|
493
501
|
*/
|
|
494
|
-
function
|
|
502
|
+
export function hostLoadFault(error: unknown): { cause: string; missingDependency: boolean } {
|
|
495
503
|
let text = "";
|
|
496
504
|
let code: unknown;
|
|
497
505
|
try {
|
|
@@ -504,9 +512,25 @@ function hostStaleInstallCause(error: unknown): string {
|
|
|
504
512
|
} catch {
|
|
505
513
|
// Fall through with whatever was read.
|
|
506
514
|
}
|
|
507
|
-
const file = /['"]?([^'"\s]*\.(?:m?js|cjs|json|node))['"]?/.exec(text)?.[1]?.split(/[\\/]/).at(-1);
|
|
508
515
|
const kind = code === "ERR_MODULE_NOT_FOUND" || /ERR_MODULE_NOT_FOUND|Cannot find/.test(text) ? "ERR_MODULE_NOT_FOUND" : "dynamic-import-failed";
|
|
509
|
-
|
|
516
|
+
// Node names the missing specifier first ("Cannot find package 'x' imported
|
|
517
|
+
// from /dir/importer.js"); the importer is not what is missing.
|
|
518
|
+
// Node does not escape quotes, so a path may contain one: read up to the
|
|
519
|
+
// quote that ends the specifier (before " imported from", a CJS
|
|
520
|
+
// ". Please verify…"/"Require stack" line, or the end of the text).
|
|
521
|
+
const specifier = /Cannot find (?:module|package) '(.+?)'(?=$| imported from |\.\s|\r?\n)/.exec(text)?.[1];
|
|
522
|
+
if (specifier !== undefined && specifier.length > 0) {
|
|
523
|
+
if (!/^(?:\.{1,2}[\\/]|[\\/]|file:|[A-Za-z]:[\\/])/.test(specifier)) {
|
|
524
|
+
// A bare specifier names a package (keep "@scope/name" or "name").
|
|
525
|
+
const parts = specifier.split("/");
|
|
526
|
+
const name = (specifier.startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1)).join("/");
|
|
527
|
+
return { cause: `${kind} package ${name.slice(0, 120)}`, missingDependency: true };
|
|
528
|
+
}
|
|
529
|
+
const base = specifier.split(/[\\/]/).at(-1) ?? "";
|
|
530
|
+
return { cause: base.length === 0 ? kind : `${kind} ${base.slice(0, 120)}`, missingDependency: false };
|
|
531
|
+
}
|
|
532
|
+
const file = /['"]?([^'"\s]*\.(?:m?js|cjs|json|node))['"]?/.exec(text)?.[1]?.split(/[\\/]/).at(-1);
|
|
533
|
+
return { cause: file === undefined || file.length === 0 ? kind : `${kind} ${file.slice(0, 120)}`, missingDependency: false };
|
|
510
534
|
}
|
|
511
535
|
|
|
512
536
|
type SetupFailureDisposition = "context-overflow" | "retryable" | "host-final";
|
|
@@ -1167,13 +1191,15 @@ export function createLogicalProvider(
|
|
|
1167
1191
|
account: LogicalPhysicalAccount,
|
|
1168
1192
|
cause: unknown,
|
|
1169
1193
|
): void => {
|
|
1194
|
+
const fault = hostLoadFault(cause);
|
|
1170
1195
|
box.hostStaleInstall = true;
|
|
1196
|
+
box.hostMissingDependency = fault.missingDependency;
|
|
1171
1197
|
box.overflow = false;
|
|
1172
1198
|
box.preStartRetryable = false;
|
|
1173
1199
|
try {
|
|
1174
1200
|
deps.onDiagnostic?.(
|
|
1175
1201
|
`logical dispatch for ${account.providerId} failed loading host code (host_stale_install: ` +
|
|
1176
|
-
`${
|
|
1202
|
+
`${fault.cause}); ${fault.missingDependency ? "reinstall the missing package" : "restart the pi session"}`,
|
|
1177
1203
|
);
|
|
1178
1204
|
} catch {
|
|
1179
1205
|
// A diagnostic sink failure cannot replace a provider result.
|
|
@@ -1247,6 +1273,8 @@ export function createLogicalProvider(
|
|
|
1247
1273
|
preStartRetryable: boolean;
|
|
1248
1274
|
/** Setup failed because this pi process could not load its own code. */
|
|
1249
1275
|
hostStaleInstall?: boolean;
|
|
1276
|
+
/** The missing module is a bare package, so a reinstall, not a restart, helps. */
|
|
1277
|
+
hostMissingDependency?: boolean;
|
|
1250
1278
|
/** Whether the caller or provider shutdown aborted this physical request. */
|
|
1251
1279
|
cancelled?: () => boolean;
|
|
1252
1280
|
/**
|
|
@@ -1283,7 +1311,10 @@ export function createLogicalProvider(
|
|
|
1283
1311
|
let sawTerminal = false;
|
|
1284
1312
|
// Whether any event other than a leading `start` arrived. A `start`
|
|
1285
1313
|
// only reports that response headers arrived, so a failure after it is
|
|
1286
|
-
// still the stream's first real event.
|
|
1314
|
+
// still the stream's first real event. Every other non-terminal event
|
|
1315
|
+
// is output (it also sets `box.sawOutput`), and a terminal ends the
|
|
1316
|
+
// loop, so on the thrown path `!sawEvent` means "no content yet": this
|
|
1317
|
+
// is the signal the setup-only classifications key on.
|
|
1287
1318
|
let sawEvent = false;
|
|
1288
1319
|
const recordFailureOnce = (error: unknown): HostRetryCooldownReceipt => {
|
|
1289
1320
|
if (box.hostStaleInstall === true) box.receipt ??= hostFaultReceipt();
|
|
@@ -1779,7 +1810,7 @@ export function createLogicalProvider(
|
|
|
1779
1810
|
return {
|
|
1780
1811
|
...syntheticErrorMessage(
|
|
1781
1812
|
modelId,
|
|
1782
|
-
`${stale ? HOST_STALE_INSTALL_MESSAGE : errorMessage} ${evidence}`,
|
|
1813
|
+
`${stale ? (box?.hostMissingDependency === true ? HOST_MISSING_DEPENDENCY_MESSAGE : HOST_STALE_INSTALL_MESSAGE) : errorMessage} ${evidence}`,
|
|
1783
1814
|
physical === undefined ? undefined : projectTerminalUsage(physical),
|
|
1784
1815
|
physical === undefined ? undefined : finiteNonNegative(physical.timestamp),
|
|
1785
1816
|
),
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { CostReport } from "./cost-report.js";
|
|
2
|
+
import {
|
|
3
|
+
PUBLIC_ACCOUNT_STATUS_VERSION,
|
|
4
|
+
type PublicAccountHealth,
|
|
5
|
+
type PublicAccountStatusRecord,
|
|
6
|
+
type PublicAccountStatusSnapshot,
|
|
7
|
+
type PublicCostEstimate,
|
|
8
|
+
} from "./public-status.js";
|
|
9
|
+
import { accountHealth, type AccountStatusView } from "./status-view.js";
|
|
10
|
+
import type { UsageSnapshot } from "./usage.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Owner-side projection for the `./public-status` contract.
|
|
14
|
+
*
|
|
15
|
+
* Only the extension loads this module. Consumers import the dependency-free
|
|
16
|
+
* contract in `public-status.js` (typed by `public-status.d.ts`) instead.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface PublicAccountStatusInput {
|
|
20
|
+
readonly nowMs: number;
|
|
21
|
+
/**
|
|
22
|
+
* Live account views. The caller must put only an operator-configured label
|
|
23
|
+
* in `label`; this projection publishes whatever it is given.
|
|
24
|
+
*/
|
|
25
|
+
readonly accounts: readonly AccountStatusView[];
|
|
26
|
+
readonly costReport?: CostReport;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function publicText(value: string, maximumBytes: number): string {
|
|
30
|
+
if (
|
|
31
|
+
value.length === 0 ||
|
|
32
|
+
Buffer.byteLength(value, "utf8") > maximumBytes ||
|
|
33
|
+
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
|
|
34
|
+
) {
|
|
35
|
+
throw new TypeError("Invalid public account status text.");
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function publicTimestamp(value: number | undefined): number | null {
|
|
41
|
+
return value !== undefined && Number.isSafeInteger(value) && value >= 0
|
|
42
|
+
? value
|
|
43
|
+
: null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function publicCount(value: number | undefined): number | null {
|
|
47
|
+
return value !== undefined && Number.isSafeInteger(value) && value >= 0
|
|
48
|
+
? value
|
|
49
|
+
: null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function usageHeadroomPercent(usage: UsageSnapshot | undefined): number | null {
|
|
53
|
+
if (usage === undefined) return null;
|
|
54
|
+
const fractions: number[] = [];
|
|
55
|
+
if (usage.utilization !== undefined && Number.isFinite(usage.utilization)) {
|
|
56
|
+
fractions.push(1 - Math.min(1, Math.max(0, usage.utilization)));
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
usage.remainingRequests !== undefined &&
|
|
60
|
+
usage.observedPeakRequests !== undefined &&
|
|
61
|
+
usage.observedPeakRequests > 0
|
|
62
|
+
) {
|
|
63
|
+
fractions.push(usage.remainingRequests / usage.observedPeakRequests);
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
usage.remainingTokens !== undefined &&
|
|
67
|
+
usage.observedPeakTokens !== undefined &&
|
|
68
|
+
usage.observedPeakTokens > 0
|
|
69
|
+
) {
|
|
70
|
+
fractions.push(usage.remainingTokens / usage.observedPeakTokens);
|
|
71
|
+
}
|
|
72
|
+
if (fractions.length === 0) return null;
|
|
73
|
+
const headroom = Math.min(...fractions);
|
|
74
|
+
return Math.round(Math.min(1, Math.max(0, headroom)) * 100_000) / 1_000;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function recoveryAt(account: AccountStatusView): number | null {
|
|
78
|
+
const candidates = [
|
|
79
|
+
publicTimestamp(account.coolingUntilMs),
|
|
80
|
+
publicTimestamp(account.usage?.recoveryAtMs),
|
|
81
|
+
publicTimestamp(account.usageFetch?.nextAttemptAtMs),
|
|
82
|
+
].filter((value): value is number => value !== null);
|
|
83
|
+
return candidates.length === 0 ? null : Math.max(...candidates);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The month's API-equivalent estimate, or nothing. An unpriced report has no
|
|
88
|
+
* estimate, and an invalid one (a non-finite or negative amount, or bad or
|
|
89
|
+
* inverted period bounds) is dropped rather than thrown: a bad cost figure
|
|
90
|
+
* must not turn the whole snapshot, and its account health, into a
|
|
91
|
+
* `source-error`.
|
|
92
|
+
*/
|
|
93
|
+
function publicCostEstimate(
|
|
94
|
+
report: CostReport | undefined,
|
|
95
|
+
): PublicCostEstimate | undefined {
|
|
96
|
+
const current = report?.current;
|
|
97
|
+
if (current?.apiEquivalent.status !== "priced") return undefined;
|
|
98
|
+
const amount = current.apiEquivalent.estimatedUsd;
|
|
99
|
+
const periodStart = publicTimestamp(current.periodStartMs);
|
|
100
|
+
const periodEnd = publicTimestamp(current.periodEndMs);
|
|
101
|
+
const observedAt = publicTimestamp(report?.generatedAtMs);
|
|
102
|
+
if (
|
|
103
|
+
!Number.isFinite(amount) ||
|
|
104
|
+
amount < 0 ||
|
|
105
|
+
periodStart === null ||
|
|
106
|
+
periodEnd === null ||
|
|
107
|
+
periodEnd < periodStart ||
|
|
108
|
+
observedAt === null
|
|
109
|
+
) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
estimateId: `api-equivalent:${periodStart}:${periodEnd}`,
|
|
114
|
+
accountId: null,
|
|
115
|
+
classification: "estimate-not-billing",
|
|
116
|
+
source: "pi-multi-account:api-equivalent-public-rates",
|
|
117
|
+
currency: "USD",
|
|
118
|
+
amount,
|
|
119
|
+
periodStart,
|
|
120
|
+
periodEnd,
|
|
121
|
+
observedAt,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Copies the status command's live state into the narrow, renderer-independent
|
|
127
|
+
* public read projection. Credential material, fingerprints, diagnostics, and
|
|
128
|
+
* model-routing details have no fields in this result and cannot survive.
|
|
129
|
+
*
|
|
130
|
+
* The v1 consumer rejects a whole snapshot that names any family outside
|
|
131
|
+
* anthropic and openai-codex, so accounts of every other family (for example
|
|
132
|
+
* google-antigravity) are omitted rather than published or thrown on.
|
|
133
|
+
*/
|
|
134
|
+
export function projectPublicAccountStatus(
|
|
135
|
+
input: PublicAccountStatusInput,
|
|
136
|
+
): PublicAccountStatusSnapshot {
|
|
137
|
+
if (!Number.isSafeInteger(input.nowMs) || input.nowMs < 0) {
|
|
138
|
+
throw new TypeError(
|
|
139
|
+
"Public account status observation time must be a timestamp.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const accounts: PublicAccountStatusRecord[] = [];
|
|
143
|
+
for (const account of input.accounts) {
|
|
144
|
+
const family = account.family;
|
|
145
|
+
if (family !== "anthropic" && family !== "openai-codex") continue;
|
|
146
|
+
const accountId = publicText(account.providerId, 256);
|
|
147
|
+
const usage = account.usage;
|
|
148
|
+
const usageFetch = account.usageFetch;
|
|
149
|
+
const health: PublicAccountHealth = accountHealth(account, input.nowMs);
|
|
150
|
+
accounts.push({
|
|
151
|
+
accountId,
|
|
152
|
+
label: publicText(account.label ?? accountId, 256),
|
|
153
|
+
family,
|
|
154
|
+
active: account.active,
|
|
155
|
+
health,
|
|
156
|
+
usageHeadroomPercent: usageHeadroomPercent(usage),
|
|
157
|
+
remainingRequests: publicCount(usage?.remainingRequests),
|
|
158
|
+
remainingTokens: publicCount(usage?.remainingTokens),
|
|
159
|
+
recoveryAt: recoveryAt(account),
|
|
160
|
+
credentialExpiresAt: publicTimestamp(account.expiresAtMs),
|
|
161
|
+
fetchObservedAt: publicTimestamp(usage?.snapshotAtMs),
|
|
162
|
+
fetchStale:
|
|
163
|
+
usage === undefined ||
|
|
164
|
+
usage.stale === true ||
|
|
165
|
+
usageFetch?.disabled === true ||
|
|
166
|
+
(usageFetch?.failureCount ?? 0) > 0,
|
|
167
|
+
costEstimateIds: [],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
new Set(accounts.map((account) => account.accountId)).size !==
|
|
172
|
+
accounts.length
|
|
173
|
+
) {
|
|
174
|
+
throw new TypeError("Duplicate public account id.");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const costEstimates: PublicCostEstimate[] = [];
|
|
178
|
+
const costEstimate = publicCostEstimate(input.costReport);
|
|
179
|
+
if (costEstimate !== undefined) costEstimates.push(costEstimate);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
sourceVersion: PUBLIC_ACCOUNT_STATUS_VERSION,
|
|
183
|
+
observedAtMs: input.nowMs,
|
|
184
|
+
accounts,
|
|
185
|
+
costEstimates,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the public, read-only live account status contract
|
|
3
|
+
* (`./public-status` subpath). The runtime is `public-status.js`, plain
|
|
4
|
+
* JavaScript with no imports; this hand-written declaration file must export
|
|
5
|
+
* exactly the same names (test/public-status.test.ts checks this) and, like
|
|
6
|
+
* the runtime, has no imports.
|
|
7
|
+
*
|
|
8
|
+
* The wire shape is version 1 and is frozen: the query channel, the
|
|
9
|
+
* `sourceVersion`, and the record and cost-estimate shapes must not change in
|
|
10
|
+
* place. A new shape needs a new version.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export declare const PUBLIC_ACCOUNT_STATUS_VERSION: "public-status-v1";
|
|
14
|
+
|
|
15
|
+
export declare const PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION: 1;
|
|
16
|
+
export declare const PUBLIC_ACCOUNT_STATUS_SERVICE_QUERY: "pi-multi-account:public-status-service-query:v1";
|
|
17
|
+
|
|
18
|
+
/** The families the v1 consumer accepts. Other families are omitted. */
|
|
19
|
+
export type PublicAccountStatusFamily = "anthropic" | "openai-codex";
|
|
20
|
+
|
|
21
|
+
/** Coarse health, ordered most-severe first. */
|
|
22
|
+
export type PublicAccountHealth =
|
|
23
|
+
| "unavailable"
|
|
24
|
+
| "disabled"
|
|
25
|
+
| "cooling"
|
|
26
|
+
| "rate-limited"
|
|
27
|
+
| "exhausted"
|
|
28
|
+
| "low-headroom"
|
|
29
|
+
| "ready";
|
|
30
|
+
|
|
31
|
+
export interface PublicAccountStatusRecord {
|
|
32
|
+
readonly accountId: string;
|
|
33
|
+
/** Operator-configured label, else the provider id. Never token-derived. */
|
|
34
|
+
readonly label: string;
|
|
35
|
+
readonly family: PublicAccountStatusFamily;
|
|
36
|
+
readonly active: boolean;
|
|
37
|
+
readonly health: PublicAccountHealth;
|
|
38
|
+
readonly usageHeadroomPercent: number | null;
|
|
39
|
+
readonly remainingRequests: number | null;
|
|
40
|
+
readonly remainingTokens: number | null;
|
|
41
|
+
readonly recoveryAt: number | null;
|
|
42
|
+
readonly credentialExpiresAt: number | null;
|
|
43
|
+
readonly fetchObservedAt: number | null;
|
|
44
|
+
readonly fetchStale: boolean;
|
|
45
|
+
readonly costEstimateIds: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PublicCostEstimate {
|
|
49
|
+
readonly estimateId: string;
|
|
50
|
+
readonly accountId: string | null;
|
|
51
|
+
readonly classification: "estimate-not-billing";
|
|
52
|
+
readonly source: "pi-multi-account:api-equivalent-public-rates";
|
|
53
|
+
readonly currency: "USD";
|
|
54
|
+
readonly amount: number;
|
|
55
|
+
readonly periodStart: number;
|
|
56
|
+
readonly periodEnd: number;
|
|
57
|
+
readonly observedAt: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PublicAccountStatusSnapshot {
|
|
61
|
+
readonly sourceVersion: typeof PUBLIC_ACCOUNT_STATUS_VERSION;
|
|
62
|
+
readonly observedAtMs: number;
|
|
63
|
+
readonly accounts: readonly PublicAccountStatusRecord[];
|
|
64
|
+
readonly costEstimates: readonly PublicCostEstimate[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type PublicAccountStatusUnavailableReason =
|
|
68
|
+
| "owner-unavailable"
|
|
69
|
+
| "source-error";
|
|
70
|
+
|
|
71
|
+
export type PublicAccountStatusReadResult =
|
|
72
|
+
| {
|
|
73
|
+
readonly status: "available";
|
|
74
|
+
readonly snapshot: PublicAccountStatusSnapshot;
|
|
75
|
+
}
|
|
76
|
+
| {
|
|
77
|
+
readonly status: "unavailable";
|
|
78
|
+
readonly reason: PublicAccountStatusUnavailableReason;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface PublicAccountStatusReader {
|
|
82
|
+
read(): Promise<PublicAccountStatusReadResult>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type PublicAccountStatusReaderDiscovery =
|
|
86
|
+
| {
|
|
87
|
+
readonly status: "available";
|
|
88
|
+
readonly reader: PublicAccountStatusReader;
|
|
89
|
+
}
|
|
90
|
+
| {
|
|
91
|
+
readonly status: "unsupported";
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Structurally compatible with Pi's `pi.events`. */
|
|
95
|
+
export interface PublicAccountStatusEventBus {
|
|
96
|
+
emit(channel: string, payload: unknown): void;
|
|
97
|
+
on(channel: string, handler: (payload: unknown) => void): () => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Register the owner extension's read-only status service on Pi's shared event
|
|
102
|
+
* transport. The callback owns the live state; this module neither stores
|
|
103
|
+
* credentials nor creates a second account manager. A throwing callback reads
|
|
104
|
+
* as `source-error` and its message never reaches the consumer.
|
|
105
|
+
*/
|
|
106
|
+
export declare function registerPublicAccountStatusService(
|
|
107
|
+
events: Pick<PublicAccountStatusEventBus, "on">,
|
|
108
|
+
read: () =>
|
|
109
|
+
| PublicAccountStatusReadResult
|
|
110
|
+
| Promise<PublicAccountStatusReadResult>,
|
|
111
|
+
): () => void;
|
|
112
|
+
|
|
113
|
+
/** Discover the optional live owner without treating absence as owner failure. */
|
|
114
|
+
export declare function discoverPublicAccountStatusReader(
|
|
115
|
+
events: Pick<PublicAccountStatusEventBus, "emit">,
|
|
116
|
+
): PublicAccountStatusReaderDiscovery;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Public, read-only live account status contract (`./public-status` subpath).
|
|
2
|
+
//
|
|
3
|
+
// This module is the whole runtime a consumer imports. It is plain JavaScript
|
|
4
|
+
// so plain Node can load it from node_modules without a TypeScript loader, and
|
|
5
|
+
// it deliberately has NO imports: loading the subpath must never load the
|
|
6
|
+
// extension, Pi, or any credential-adjacent module. Its types live in the
|
|
7
|
+
// hand-written `public-status.d.ts` beside it; the two must export the same
|
|
8
|
+
// names (test/public-status.test.ts checks this). The owner-side projection
|
|
9
|
+
// lives in `public-status-projection.ts`, which only the extension loads.
|
|
10
|
+
//
|
|
11
|
+
// The wire shape is version 1 and is frozen: the query channel, the
|
|
12
|
+
// `sourceVersion`, and the record and cost-estimate shapes must not change in
|
|
13
|
+
// place. A new shape needs a new version.
|
|
14
|
+
|
|
15
|
+
export const PUBLIC_ACCOUNT_STATUS_VERSION = "public-status-v1";
|
|
16
|
+
|
|
17
|
+
export const PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION = 1;
|
|
18
|
+
export const PUBLIC_ACCOUNT_STATUS_SERVICE_QUERY =
|
|
19
|
+
"pi-multi-account:public-status-service-query:v1";
|
|
20
|
+
|
|
21
|
+
function record(value) {
|
|
22
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isQuery(value) {
|
|
26
|
+
return (
|
|
27
|
+
record(value) &&
|
|
28
|
+
value["version"] === PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION &&
|
|
29
|
+
Object.keys(value).every((key) => key === "version" || key === "response")
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isResponse(value) {
|
|
34
|
+
return (
|
|
35
|
+
record(value) &&
|
|
36
|
+
value["version"] === PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION &&
|
|
37
|
+
typeof value["createReader"] === "function" &&
|
|
38
|
+
Object.keys(value).every(
|
|
39
|
+
(key) => key === "version" || key === "createReader",
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Register the owner extension's read-only status service on Pi's shared event
|
|
46
|
+
* transport. The callback owns the live state; this module neither stores
|
|
47
|
+
* credentials nor creates a second account manager. A throwing callback reads
|
|
48
|
+
* as `source-error` and its message never reaches the consumer.
|
|
49
|
+
*/
|
|
50
|
+
export function registerPublicAccountStatusService(events, read) {
|
|
51
|
+
return events.on(PUBLIC_ACCOUNT_STATUS_SERVICE_QUERY, (value) => {
|
|
52
|
+
if (!isQuery(value) || value.response !== undefined) return;
|
|
53
|
+
value.response = {
|
|
54
|
+
version: PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION,
|
|
55
|
+
createReader: () =>
|
|
56
|
+
Object.freeze({
|
|
57
|
+
async read() {
|
|
58
|
+
try {
|
|
59
|
+
return await read();
|
|
60
|
+
} catch {
|
|
61
|
+
return { status: "unavailable", reason: "source-error" };
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Discover the optional live owner without treating absence as owner failure. */
|
|
70
|
+
export function discoverPublicAccountStatusReader(events) {
|
|
71
|
+
const query = { version: PUBLIC_ACCOUNT_STATUS_SERVICE_VERSION };
|
|
72
|
+
try {
|
|
73
|
+
events.emit(PUBLIC_ACCOUNT_STATUS_SERVICE_QUERY, query);
|
|
74
|
+
} catch {
|
|
75
|
+
return { status: "unsupported" };
|
|
76
|
+
}
|
|
77
|
+
if (!isResponse(query.response)) return { status: "unsupported" };
|
|
78
|
+
try {
|
|
79
|
+
const reader = query.response.createReader();
|
|
80
|
+
if (!record(reader) || typeof reader["read"] !== "function") {
|
|
81
|
+
return { status: "unsupported" };
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
status: "available",
|
|
85
|
+
reader: { read: reader.read.bind(reader) },
|
|
86
|
+
};
|
|
87
|
+
} catch {
|
|
88
|
+
return { status: "unsupported" };
|
|
89
|
+
}
|
|
90
|
+
}
|