@centerforagenticai/pi-multi-account 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
@@ -0,0 +1,132 @@
1
+ import { createHash } from "node:crypto";
2
+ import { getModelEnum } from "../models/models.js";
3
+
4
+ export function antigravityEnv(name: string): string | undefined {
5
+ return process.env[`ANTIGRAVITY_${name}`] || process.env[`NOAGY_${name}`];
6
+ }
7
+
8
+ export function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+
12
+ export function asString(value: unknown): string | undefined {
13
+ return typeof value === "string" && value ? value : undefined;
14
+ }
15
+
16
+ export function sanitizeText(text: unknown): string {
17
+ return String(text ?? "").replace(/[\uD800-\uDFFF]/g, "\uFFFD");
18
+ }
19
+
20
+ export function escapeHtml(text: string): string {
21
+ return text
22
+ .replace(/&/g, "&amp;")
23
+ .replace(/</g, "&lt;")
24
+ .replace(/>/g, "&gt;")
25
+ .replace(/"/g, "&quot;")
26
+ .replace(/'/g, "&#39;");
27
+ }
28
+
29
+ export function escapeRegExp(text: string): string {
30
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
+ }
32
+
33
+ export function nowRequestId(): string {
34
+ return antigravityRequestEnvelope("unknown", false).requestId;
35
+ }
36
+
37
+ /** Deterministic RFC 4122 v5 UUID from seed (survives restarts for the same session seed). */
38
+ export function stableUuid(seed: string): string {
39
+ const bytes = createHash("sha1").update(seed).digest().subarray(0, 16);
40
+ bytes[6] = (bytes[6] & 0x0f) | 0x50;
41
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
42
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
43
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
44
+ }
45
+
46
+ export type AntigravityEnvelopeOptions = {
47
+ isClaude?: boolean;
48
+ isNonGemini?: boolean;
49
+ step?: number;
50
+ lastStepIndex?: string;
51
+ requestIndex?: number;
52
+ userTurnIndex?: number;
53
+ trajectoryId?: string;
54
+ conversationId?: string;
55
+ };
56
+
57
+ const sessionTrajectoryMap = new Map<string, { conversationId: string; trajectoryId: string }>();
58
+
59
+ /** Stable conversationId and trajectoryId within a multi-turn conversation session. */
60
+ export function resolveSessionTrajectory(context?: {
61
+ messages?: Array<{ role?: string; timestamp?: number; content?: unknown }>;
62
+ }): { conversationId: string; trajectoryId: string } {
63
+ const firstMsg = context?.messages?.[0];
64
+ if (!firstMsg) {
65
+ return { conversationId: crypto.randomUUID(), trajectoryId: crypto.randomUUID() };
66
+ }
67
+ const contentSeed =
68
+ typeof firstMsg.content === "string"
69
+ ? firstMsg.content.slice(0, 64)
70
+ : Array.isArray(firstMsg.content)
71
+ ? JSON.stringify(firstMsg.content[0] ?? "").slice(0, 64)
72
+ : "";
73
+ const seed = `${firstMsg.role || "user"}:${firstMsg.timestamp || ""}:${contentSeed}`;
74
+ let entry = sessionTrajectoryMap.get(seed);
75
+ if (!entry) {
76
+ entry = {
77
+ conversationId: stableUuid(`antigravity:conv:${seed}`),
78
+ trajectoryId: stableUuid(`antigravity:traj:${seed}`),
79
+ };
80
+ sessionTrajectoryMap.set(seed, entry);
81
+ if (sessionTrajectoryMap.size > 64) {
82
+ const oldestKey = sessionTrajectoryMap.keys().next().value;
83
+ if (oldestKey !== undefined) sessionTrajectoryMap.delete(oldestKey);
84
+ }
85
+ }
86
+ return entry;
87
+ }
88
+
89
+ export function clearSessionTrajectoryMap(): void {
90
+ sessionTrajectoryMap.clear();
91
+ }
92
+
93
+ export function antigravityRequestEnvelope(
94
+ wireModelId: string,
95
+ optionsOrIsClaude: boolean | AntigravityEnvelopeOptions = false,
96
+ ): { requestId: string; sessionId: string; labels: Record<string, string> } {
97
+ const options: AntigravityEnvelopeOptions =
98
+ typeof optionsOrIsClaude === "boolean" ? { isClaude: optionsOrIsClaude } : optionsOrIsClaude;
99
+
100
+ const isClaude = Boolean(options.isClaude);
101
+ const isNonGemini = Boolean(options.isNonGemini || isClaude);
102
+ const step = Math.max(1, options.step ?? 1);
103
+ const lastStepIndex = options.lastStepIndex ?? String(Math.max(0, step - 1));
104
+ const requestIndex = options.requestIndex ?? options.userTurnIndex ?? Math.max(0, step - 1);
105
+ const agentId = options.conversationId || crypto.randomUUID();
106
+ const trajectoryId = options.trajectoryId || crypto.randomUUID();
107
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
108
+ const sessionId = String(new DataView(bytes.buffer, bytes.byteOffset, 8).getBigInt64(0, true));
109
+
110
+ const claudeLabel = isClaude ? "true" : "false";
111
+ const nonGeminiLabel = isNonGemini ? "true" : "false";
112
+
113
+ const labels: Record<string, string> = {
114
+ last_step_index: lastStepIndex,
115
+ request_id: `${trajectoryId}-${requestIndex}`,
116
+ trajectory_id: trajectoryId,
117
+ used_claude: claudeLabel,
118
+ used_claude_conservative: claudeLabel,
119
+ used_non_gemini_model: nonGeminiLabel,
120
+ };
121
+
122
+ const modelEnum = getModelEnum(wireModelId);
123
+ if (modelEnum) {
124
+ labels.model_enum = modelEnum;
125
+ }
126
+
127
+ return {
128
+ requestId: `agent/${agentId}/${Date.now()}/${trajectoryId}/${step}`,
129
+ sessionId,
130
+ labels,
131
+ };
132
+ }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ // The published `multi-account` bin entry.
3
+ //
4
+ // This package ships raw TypeScript (see `package.json`'s `"exports":
5
+ // "./src/index.ts"`); it is never compiled to `dist/*.js`. Every module under
6
+ // `src/` uses NodeNext-style relative specifiers -- `import "./config.js"` --
7
+ // that point at a ".ts" sibling, exactly as `tsc -p tsconfig.json` requires.
8
+ // The Pi coding-agent host and Vitest both already know how to resolve that;
9
+ // a bare `node` invocation of this standalone launcher does not, and this
10
+ // package intentionally carries no `tsx`/`ts-node`-class dependency.
11
+ //
12
+ // So this file installs a small, synchronous Node module-customization hook
13
+ // (see https://nodejs.org/api/module.html#module-registerhooksoptions) that
14
+ // redirects a relative ".js" specifier to its ".ts" sibling when one exists,
15
+ // then hands off to `runStandaloneCli`. `module.registerHooks()` installs
16
+ // in-thread and takes effect immediately for every module resolution issued
17
+ // afterward in this same realm -- including this file's own dynamic import
18
+ // below -- so, unlike the async, loader-thread `module.register()` API this
19
+ // replaced, no `--import` preload and no re-exec are needed to avoid a race
20
+ // against that import. (`module.register()` also emits Node's DEP0205
21
+ // deprecation warning to stderr as of Node 26, which broke this CLI's
22
+ // documented empty-stderr contract on a clean run; `registerHooks()` has no
23
+ // such warning on any currently supported Node major.)
24
+ import { registerHooks } from "node:module";
25
+
26
+ registerHooks({
27
+ resolve(specifier, context, nextResolve) {
28
+ if (specifier.startsWith(".") && specifier.endsWith(".js")) {
29
+ try {
30
+ return nextResolve(specifier.replace(/\.js$/, ".ts"), context);
31
+ } catch {
32
+ // No ".ts" sibling: fall through to normal resolution below, which
33
+ // will raise Node's own not-found error for a genuinely missing
34
+ // specifier.
35
+ }
36
+ }
37
+ return nextResolve(specifier, context);
38
+ },
39
+ });
40
+
41
+ const { runStandaloneCli } = await import(
42
+ new URL("../src/standalone-cli.ts", import.meta.url)
43
+ );
44
+ process.exitCode = await runStandaloneCli(process.argv.slice(2));
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Human-meaningful identity for managed accounts.
3
+ *
4
+ * Pi renders a provider's `name` in the login list, falling back to the OAuth
5
+ * method name. Because the Anthropic alias inherits the upstream OAuth config
6
+ * verbatim, every managed Anthropic account previously rendered as the same
7
+ * "Claude Pro/Max" string, leaving an operator unable to tell which account to
8
+ * log in to or refresh.
9
+ *
10
+ * This module resolves a bounded, display-safe label per provider id. It is
11
+ * presentation-only: it performs no I/O, mutates no routing state, and never
12
+ * retains credential material.
13
+ */
14
+
15
+ import {
16
+ classifyProviderId,
17
+ isProviderSlotWithinAccountLimit,
18
+ } from "./discovery.js";
19
+
20
+ /** Hard cap on a rendered label; derived values are truncated to fit. */
21
+ const MAX_LABEL_LENGTH = 64;
22
+
23
+ /**
24
+ * Printable ASCII excluding control characters. Labels reach a terminal UI, so
25
+ * anything that could smuggle escape sequences is rejected outright rather
26
+ * than escaped.
27
+ */
28
+ const DISPLAY_SAFE = /^[\x20-\x7E]+$/;
29
+
30
+ /** Claim names that may carry a human-identifying value, in priority order. */
31
+ const IDENTITY_CLAIMS = ["email", "preferred_username", "name"] as const;
32
+
33
+ /** Bounded per-account label overrides, keyed by canonical provider id. */
34
+ export type AccountLabelConfig = Readonly<Record<string, string>>;
35
+
36
+ /**
37
+ * Normalizes an operator-supplied or derived label. Returns undefined when the
38
+ * value is absent, empty, unsafe to display, or not a string — callers then
39
+ * fall back rather than surfacing a partial or hostile value.
40
+ */
41
+ export function normalizeLabel(value: unknown): string | undefined {
42
+ if (typeof value !== "string") return undefined;
43
+ const trimmed = value.trim();
44
+ if (trimmed.length === 0) return undefined;
45
+ if (!DISPLAY_SAFE.test(trimmed)) return undefined;
46
+ return trimmed.length > MAX_LABEL_LENGTH
47
+ ? trimmed.slice(0, MAX_LABEL_LENGTH)
48
+ : trimmed;
49
+ }
50
+
51
+ /**
52
+ * Extracts a human-identifying claim from a JWT-shaped access token.
53
+ *
54
+ * Only the payload segment is decoded, and only an allow-listed identity claim
55
+ * is read; the signature, header, and every other claim are discarded. A
56
+ * malformed, oversized, non-JWT, or unparseable token yields undefined rather
57
+ * than throwing, so a hostile credential can never break registration or leak
58
+ * token content into a label.
59
+ *
60
+ * Anthropic credentials are opaque and will always return undefined here; they
61
+ * rely on configured labels instead.
62
+ */
63
+ export function deriveLabelFromToken(token: unknown): string | undefined {
64
+ if (typeof token !== "string") return undefined;
65
+ // Bound the work: real access tokens are small, and this refuses to parse
66
+ // arbitrarily large strings.
67
+ if (token.length === 0 || token.length > 8_192) return undefined;
68
+ const segments = token.split(".");
69
+ if (segments.length !== 3) return undefined;
70
+ const payloadSegment = segments[1];
71
+ if (payloadSegment === undefined || payloadSegment.length === 0) {
72
+ return undefined;
73
+ }
74
+ let payload: unknown;
75
+ try {
76
+ const decoded = Buffer.from(payloadSegment, "base64url").toString("utf8");
77
+ if (decoded.length === 0 || decoded.length > 8_192) return undefined;
78
+ payload = JSON.parse(decoded);
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ if (typeof payload !== "object" || payload === null) return undefined;
83
+ const record = payload as Record<string, unknown>;
84
+ for (const claim of IDENTITY_CLAIMS) {
85
+ const label = normalizeLabel(record[claim]);
86
+ if (label !== undefined) return label;
87
+ }
88
+ // Some providers nest identity under a namespaced profile object.
89
+ for (const value of Object.values(record)) {
90
+ if (typeof value !== "object" || value === null) continue;
91
+ const nested = value as Record<string, unknown>;
92
+ for (const claim of IDENTITY_CLAIMS) {
93
+ const label = normalizeLabel(nested[claim]);
94
+ if (label !== undefined) return label;
95
+ }
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /**
101
+ * A stable fingerprint identifying the real account behind a credential.
102
+ *
103
+ * Detects one account occupying two slots, where failover between them is
104
+ * decorative: they share a single quota and rate limit. Codex tokens are JWTs
105
+ * carrying a stable subject claim, so the fingerprint survives token refresh.
106
+ * Anthropic tokens are opaque and yield undefined, which callers must treat as
107
+ * "unknown" rather than "different account".
108
+ */
109
+ export function accountFingerprint(token: unknown): string | undefined {
110
+ if (typeof token !== "string" || token.length === 0) return undefined;
111
+ if (token.length > 8_192) return undefined;
112
+ const segments = token.split(".");
113
+ if (segments.length !== 3) return undefined;
114
+ const payloadSegment = segments[1];
115
+ if (payloadSegment === undefined || payloadSegment.length === 0) {
116
+ return undefined;
117
+ }
118
+ let payload: unknown;
119
+ try {
120
+ const decoded = Buffer.from(payloadSegment, "base64url").toString("utf8");
121
+ if (decoded.length === 0 || decoded.length > 8_192) return undefined;
122
+ payload = JSON.parse(decoded);
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ if (typeof payload !== "object" || payload === null) return undefined;
127
+ const subject = (payload as Record<string, unknown>)["sub"];
128
+ if (typeof subject === "string" && subject.length > 0) return subject;
129
+ return deriveLabelFromToken(token);
130
+ }
131
+
132
+ /**
133
+ * Groups managed slots that resolve to the SAME underlying account.
134
+ *
135
+ * Returns one entry per duplicated account listing the provider ids sharing it.
136
+ * Slots whose identity cannot be determined are never grouped, so an
137
+ * opaque-token family produces no false positives.
138
+ */
139
+ export function duplicateAccountGroups(
140
+ slots: ReadonlyArray<{
141
+ readonly providerId: string;
142
+ readonly token?: unknown;
143
+ }>,
144
+ ): ReadonlyArray<readonly string[]> {
145
+ const byFingerprint = new Map<string, string[]>();
146
+ for (const { providerId, token } of slots) {
147
+ const fingerprint = accountFingerprint(token);
148
+ if (fingerprint === undefined) continue;
149
+ const group = byFingerprint.get(fingerprint);
150
+ if (group) group.push(providerId);
151
+ else byFingerprint.set(fingerprint, [providerId]);
152
+ }
153
+ return [...byFingerprint.values()].filter((group) => group.length > 1);
154
+ }
155
+
156
+ /**
157
+ * Resolves the label for one managed account.
158
+ *
159
+ * Precedence is deliberate: an explicitly configured label always wins, because
160
+ * the operator's own naming convention is more meaningful than any value the
161
+ * provider happens to embed. A derived label is the fallback, and the canonical
162
+ * provider id is the final backstop so registration never fails for want of a
163
+ * name.
164
+ */
165
+ export function resolveAccountLabel(input: {
166
+ readonly providerId: string;
167
+ readonly configured?: AccountLabelConfig | undefined;
168
+ readonly token?: unknown;
169
+ }): string {
170
+ const configured = normalizeLabel(input.configured?.[input.providerId]);
171
+ if (configured !== undefined) return configured;
172
+ const derived = deriveLabelFromToken(input.token);
173
+ if (derived !== undefined) return derived;
174
+ return input.providerId;
175
+ }
176
+
177
+ /**
178
+ * Resolves a managed account's label only when its provider id is within the
179
+ * current validated account limit.
180
+ *
181
+ * This is the final label sink's own guard. It classifies `input.providerId`,
182
+ * applies `isProviderSlotWithinAccountLimit(slot, input.accountLimit)`, and
183
+ * returns the original provider id before indexing `configured` or invoking the
184
+ * lazy `readToken` callback when the id is malformed or out of the current
185
+ * limit. Only an in-range managed id reaches {@link resolveAccountLabel}. It
186
+ * closes the label boundary independently rather than trusting every future
187
+ * caller to remember an upstream projection.
188
+ */
189
+ export function resolveAccountLabelWithinLimit(input: Readonly<{
190
+ providerId: string;
191
+ accountLimit: number;
192
+ configured: AccountLabelConfig;
193
+ readToken: (providerId: string) => unknown;
194
+ }>): string {
195
+ const slot = classifyProviderId({
196
+ providerId: input.providerId,
197
+ credentialType: "unknown",
198
+ });
199
+ if (
200
+ slot === null ||
201
+ !isProviderSlotWithinAccountLimit(slot, input.accountLimit)
202
+ ) return input.providerId;
203
+ return resolveAccountLabel({
204
+ providerId: input.providerId,
205
+ configured: input.configured,
206
+ token: input.readToken(input.providerId),
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Composes the string Pi shows in its login list and provider UI, pairing the
212
+ * family's product name with the account's own label. When the label carries no
213
+ * information beyond the provider id, the base name is returned unadorned to
214
+ * avoid a redundant "Claude Pro/Max — anthropic-account-2".
215
+ */
216
+ export function composeDisplayName(
217
+ baseName: string,
218
+ label: string,
219
+ providerId: string,
220
+ ): string {
221
+ if (label === providerId) return baseName;
222
+ return `${baseName} — ${label}`;
223
+ }