@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.
- package/LICENSE +21 -0
- package/NOTICE +29 -0
- package/README.md +999 -0
- package/config/models/pi-multi-account.v1.json +32 -0
- package/config/subscription-plans.v1.json +122 -0
- package/package.json +76 -0
- package/packages/pi-anthropic-oauth/LICENSE +21 -0
- package/packages/pi-anthropic-oauth/package.json +54 -0
- package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
- package/packages/pi-anthropic-oauth/src/context.ts +116 -0
- package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
- package/packages/pi-anthropic-oauth/src/index.ts +37 -0
- package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
- package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
- package/packages/pi-antigravity/LICENSE +21 -0
- package/packages/pi-antigravity/package.json +77 -0
- package/packages/pi-antigravity/src/auth/index.ts +14 -0
- package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
- package/packages/pi-antigravity/src/client/client.ts +561 -0
- package/packages/pi-antigravity/src/client/index.ts +1 -0
- package/packages/pi-antigravity/src/context.ts +110 -0
- package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
- package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
- package/packages/pi-antigravity/src/image/image.ts +336 -0
- package/packages/pi-antigravity/src/image/index.ts +1 -0
- package/packages/pi-antigravity/src/index.ts +280 -0
- package/packages/pi-antigravity/src/models/discovery.ts +154 -0
- package/packages/pi-antigravity/src/models/grouping.ts +424 -0
- package/packages/pi-antigravity/src/models/index.ts +3 -0
- package/packages/pi-antigravity/src/models/models.ts +500 -0
- package/packages/pi-antigravity/src/stream/index.ts +1 -0
- package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
- package/packages/pi-antigravity/src/types/enums.ts +42 -0
- package/packages/pi-antigravity/src/types/index.ts +2 -0
- package/packages/pi-antigravity/src/types/types.ts +292 -0
- package/packages/pi-antigravity/src/usage/index.ts +1 -0
- package/packages/pi-antigravity/src/usage/usage.ts +416 -0
- package/packages/pi-antigravity/src/utils/http.ts +91 -0
- package/packages/pi-antigravity/src/utils/index.ts +3 -0
- package/packages/pi-antigravity/src/utils/security.ts +73 -0
- package/packages/pi-antigravity/src/utils/util.ts +132 -0
- package/scripts/multi-account.mjs +44 -0
- package/src/account-labels.ts +223 -0
- package/src/account-plan-assignment.ts +340 -0
- package/src/account-rate-history.ts +372 -0
- package/src/anthropic-adaptive-stream.ts +531 -0
- package/src/anthropic-alias-stream.ts +140 -0
- package/src/anthropic-context-compat.ts +80 -0
- package/src/api-pricing.ts +579 -0
- package/src/bounded-file-lines.ts +97 -0
- package/src/catalog-rebinding.ts +177 -0
- package/src/catalog-registration-probe.ts +111 -0
- package/src/codex-adapter.ts +345 -0
- package/src/codex-model-defaults.ts +785 -0
- package/src/command-completions.ts +404 -0
- package/src/commands.ts +2000 -0
- package/src/compaction.ts +14 -0
- package/src/config.ts +1317 -0
- package/src/continuation.ts +569 -0
- package/src/cooldowns.ts +110 -0
- package/src/cost-digest-store.ts +332 -0
- package/src/cost-digest.ts +1044 -0
- package/src/cost-history.ts +251 -0
- package/src/cost-period-closer.ts +160 -0
- package/src/cost-report-json.ts +318 -0
- package/src/cost-report-reader.ts +368 -0
- package/src/cost-report-render.ts +207 -0
- package/src/cost-report.ts +1104 -0
- package/src/coverage-attestation.ts +397 -0
- package/src/credential-lifecycle.ts +169 -0
- package/src/credential-refresh.ts +248 -0
- package/src/declaration-notice-marker.ts +238 -0
- package/src/diagnostic-store.ts +276 -0
- package/src/diagnostics.ts +309 -0
- package/src/discovery.ts +471 -0
- package/src/duration.ts +13 -0
- package/src/error-classification.ts +256 -0
- package/src/fuzzy.ts +15 -0
- package/src/group-policy.ts +81 -0
- package/src/history-store.ts +897 -0
- package/src/index.ts +5572 -0
- package/src/lifecycle.ts +378 -0
- package/src/logical-dispatch.ts +279 -0
- package/src/logical-model-selector.ts +254 -0
- package/src/logical-model-switcher.ts +430 -0
- package/src/logical-provider-attribution.ts +544 -0
- package/src/logical-provider.ts +1237 -0
- package/src/logical-route-indicator.ts +215 -0
- package/src/machine-lease.ts +445 -0
- package/src/model-support.ts +66 -0
- package/src/models-declaration.ts +1091 -0
- package/src/openai-adapter.ts +117 -0
- package/src/openrouter-budget.ts +304 -0
- package/src/openrouter-fallback.ts +146 -0
- package/src/period-boundaries.ts +376 -0
- package/src/pi-anthropic-oauth.d.ts +6 -0
- package/src/preflight.ts +253 -0
- package/src/pricing-cache.ts +235 -0
- package/src/project-identity.ts +100 -0
- package/src/provider-registration.ts +942 -0
- package/src/rate-formula.ts +163 -0
- package/src/recovery-engine.ts +853 -0
- package/src/recovery-output.ts +837 -0
- package/src/recovery-plan.ts +239 -0
- package/src/report-range.ts +203 -0
- package/src/route-resolver.ts +789 -0
- package/src/routing-config-transaction.ts +232 -0
- package/src/routing.ts +1163 -0
- package/src/runtime-state.ts +630 -0
- package/src/session-account-groups.ts +284 -0
- package/src/session-restore.ts +287 -0
- package/src/shared-usage.ts +1392 -0
- package/src/standalone-cli.ts +720 -0
- package/src/status-view.ts +578 -0
- package/src/subscription-plan-catalog.ts +346 -0
- package/src/tier-model-resolver.ts +46 -0
- package/src/upstream-anthropic.ts +315 -0
- package/src/upstream-antigravity.ts +327 -0
- package/src/usage-fetch.ts +1634 -0
- package/src/usage.ts +1026 -0
- package/src/vendor.ts +87 -0
- package/src/warmer.ts +231 -0
- package/src/watchdog.ts +219 -0
- 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, "&")
|
|
23
|
+
.replace(/</g, "<")
|
|
24
|
+
.replace(/>/g, ">")
|
|
25
|
+
.replace(/"/g, """)
|
|
26
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|