@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,239 @@
1
+ import type { AllowedFamily, ManagedFamily, MultiAccountConfig } from "./config.js";
2
+ import { isAllowedFamily } from "./config.js";
3
+ import { resolveTierModel } from "./tier-model-resolver.js";
4
+ import type { ProviderType } from "./vendor.js";
5
+ import { sameVendor, vendorForFamily } from "./vendor.js";
6
+ import { isCanonicalManagedProviderId } from "./runtime-state.js";
7
+
8
+ /** Host-observable input modalities used to reject incompatible destinations. */
9
+ export type RecoveryInputModality = "text" | "image";
10
+
11
+ /** Capability facts projected from one physical catalog row. */
12
+ export interface RecoveryModelCapability {
13
+ readonly modelId: string;
14
+ readonly input: readonly RecoveryInputModality[];
15
+ readonly supportsTools: boolean;
16
+ readonly contextWindow: number;
17
+ }
18
+
19
+ /** One validated physical account visible when a request-local plan is built. */
20
+ export interface RecoveryPlanAccount {
21
+ readonly providerId: string;
22
+ readonly family: ManagedFamily;
23
+ readonly providerType: Exclude<ProviderType, "openrouter">;
24
+ readonly eligible: boolean;
25
+ readonly models: readonly RecoveryModelCapability[];
26
+ }
27
+
28
+ export type RecoveryCandidateTier =
29
+ | "same-family-subscription"
30
+ | "owning-vendor-api"
31
+ | "cross-family-subscription";
32
+
33
+ /** One finite provider/model pair. Logical and physical model identity stay distinct. */
34
+ export interface RecoveryCandidate {
35
+ readonly providerId: string;
36
+ readonly family: ManagedFamily;
37
+ readonly providerType: Exclude<ProviderType, "openrouter">;
38
+ readonly modelId: string;
39
+ readonly selectedModelId: string;
40
+ readonly tier: RecoveryCandidateTier;
41
+ readonly substitution: "exact" | "configured";
42
+ readonly capability: RecoveryModelCapability;
43
+ }
44
+
45
+ export type RecoveryPlanConfig = Pick<
46
+ MultiAccountConfig,
47
+ | "sameFamilyFailover"
48
+ | "crossFamilyChainEnabled"
49
+ | "crossFamilyChains"
50
+ | "preferredModels"
51
+ | "tierModelMap"
52
+ >;
53
+
54
+ export interface RecoveryCandidatePlanRequest {
55
+ readonly selectedModelId: string;
56
+ readonly originFamily: AllowedFamily;
57
+ readonly accounts: readonly RecoveryPlanAccount[];
58
+ readonly config: RecoveryPlanConfig;
59
+ readonly requiredInput: readonly RecoveryInputModality[];
60
+ readonly requiresTools: boolean;
61
+ readonly failedPair?: Readonly<{ providerId: string; modelId: string }>;
62
+ }
63
+
64
+ function providerTypeMatchesFamily(account: RecoveryPlanAccount): boolean {
65
+ if (account.family === "openai-codex") return account.providerType === "subscription";
66
+ if (account.family === "openai") return account.providerType === "owning-vendor-api";
67
+ return account.providerType === "subscription" || account.providerType === "owning-vendor-api";
68
+ }
69
+
70
+ function usableCapability(
71
+ model: RecoveryModelCapability,
72
+ requiredInput: readonly RecoveryInputModality[],
73
+ requiresTools: boolean,
74
+ ): boolean {
75
+ return (
76
+ typeof model.modelId === "string" &&
77
+ model.modelId.length > 0 &&
78
+ Number.isFinite(model.contextWindow) &&
79
+ model.contextWindow > 0 &&
80
+ requiredInput.every((input) => model.input.includes(input)) &&
81
+ (!requiresTools || model.supportsTools)
82
+ );
83
+ }
84
+
85
+ function immutableCapability(model: RecoveryModelCapability): RecoveryModelCapability {
86
+ return Object.freeze({
87
+ modelId: model.modelId,
88
+ input: Object.freeze([...model.input]),
89
+ supportsTools: model.supportsTools,
90
+ contextWindow: model.contextWindow,
91
+ });
92
+ }
93
+
94
+ /** Build the immutable, deterministic candidate sweep for one logical call. */
95
+ export function buildRecoveryCandidatePlan(
96
+ request: RecoveryCandidatePlanRequest,
97
+ ): readonly RecoveryCandidate[] {
98
+ if (typeof request.selectedModelId !== "string" || request.selectedModelId.length === 0) {
99
+ return Object.freeze([]);
100
+ }
101
+
102
+ const accounts: RecoveryPlanAccount[] = [];
103
+ const seenAccounts = new Set<string>();
104
+ for (const account of request.accounts) {
105
+ if (
106
+ !account.eligible ||
107
+ !isCanonicalManagedProviderId(account.providerId, account.family) ||
108
+ !providerTypeMatchesFamily(account) ||
109
+ seenAccounts.has(account.providerId)
110
+ ) {
111
+ continue;
112
+ }
113
+ seenAccounts.add(account.providerId);
114
+ accounts.push(account);
115
+ }
116
+
117
+ const candidates: RecoveryCandidate[] = [];
118
+ const seenPairs = new Set<string>();
119
+ const add = (
120
+ account: RecoveryPlanAccount,
121
+ model: RecoveryModelCapability | undefined,
122
+ tier: RecoveryCandidateTier,
123
+ substitution: RecoveryCandidate["substitution"],
124
+ ): void => {
125
+ if (
126
+ model === undefined ||
127
+ !usableCapability(model, request.requiredInput, request.requiresTools)
128
+ ) {
129
+ return;
130
+ }
131
+ if (
132
+ request.failedPair?.providerId === account.providerId &&
133
+ request.failedPair.modelId === model.modelId
134
+ ) {
135
+ return;
136
+ }
137
+ const key = `${account.providerId}\u0000${model.modelId}`;
138
+ if (seenPairs.has(key)) return;
139
+ seenPairs.add(key);
140
+ candidates.push(
141
+ Object.freeze({
142
+ providerId: account.providerId,
143
+ family: account.family,
144
+ providerType: account.providerType,
145
+ modelId: model.modelId,
146
+ selectedModelId: request.selectedModelId,
147
+ tier,
148
+ substitution,
149
+ capability: immutableCapability(model),
150
+ }),
151
+ );
152
+ };
153
+
154
+ if (request.config.sameFamilyFailover) {
155
+ const sameFamilySubscriptions = accounts.filter(
156
+ (account) =>
157
+ account.providerType === "subscription" &&
158
+ account.family === request.originFamily,
159
+ );
160
+ for (const account of sameFamilySubscriptions) {
161
+ add(
162
+ account,
163
+ account.models.find(({ modelId }) => modelId === request.selectedModelId),
164
+ "same-family-subscription",
165
+ "exact",
166
+ );
167
+ }
168
+ }
169
+
170
+ // Google has no supported owning-vendor-API destination. Exclude it before
171
+ // this tier is built at all, so a malformed or hostile account shape can
172
+ // neither expand via an exact model match nor reach resolveTierModel below.
173
+ const owningVendorApiAccounts = accounts.filter(
174
+ (account) =>
175
+ account.providerType === "owning-vendor-api" &&
176
+ account.family !== "google-antigravity" &&
177
+ sameVendor(account.family, request.originFamily),
178
+ );
179
+ for (const account of owningVendorApiAccounts) {
180
+ add(
181
+ account,
182
+ account.models.find(({ modelId }) => modelId === request.selectedModelId),
183
+ "owning-vendor-api",
184
+ "exact",
185
+ );
186
+ }
187
+ for (const account of owningVendorApiAccounts) {
188
+ // Google has no supported metered destination; the filter above already
189
+ // excludes it, and this narrows the type for resolveTierModel below.
190
+ if (account.family === "google-antigravity") continue;
191
+ const resolvedModelId = resolveTierModel(
192
+ request.selectedModelId,
193
+ vendorForFamily(account.family),
194
+ account.models.map(({ modelId }) => modelId),
195
+ request.config.tierModelMap,
196
+ );
197
+ if (resolvedModelId === request.selectedModelId) continue;
198
+ add(
199
+ account,
200
+ account.models.find(({ modelId }) => modelId === resolvedModelId),
201
+ "owning-vendor-api",
202
+ "configured",
203
+ );
204
+ }
205
+
206
+ if (request.config.crossFamilyChainEnabled) {
207
+ const destinationFamilies = request.config.crossFamilyChains
208
+ .filter(({ from, to }) => from === request.originFamily && to !== from)
209
+ .map(({ to }) => to);
210
+ for (const destinationFamily of destinationFamilies) {
211
+ if (!isAllowedFamily(destinationFamily)) continue;
212
+ const destinations = accounts.filter(
213
+ (account) =>
214
+ account.providerType === "subscription" &&
215
+ account.family === destinationFamily,
216
+ );
217
+ for (const account of destinations) {
218
+ add(
219
+ account,
220
+ account.models.find(({ modelId }) => modelId === request.selectedModelId),
221
+ "cross-family-subscription",
222
+ "exact",
223
+ );
224
+ }
225
+ for (const modelId of request.config.preferredModels[destinationFamily] ?? []) {
226
+ for (const account of destinations) {
227
+ add(
228
+ account,
229
+ account.models.find((model) => model.modelId === modelId),
230
+ "cross-family-subscription",
231
+ "configured",
232
+ );
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ return Object.freeze(candidates);
239
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Pure custom-range parsing and granularity-satisfiability checks for the
3
+ * subscription-value report projection.
4
+ *
5
+ * Every export here is read-only and side-effect free: no file I/O, no clock
6
+ * reads (`nowMs` and retained-granularity facts are always caller-supplied),
7
+ * and no `process.exit`. The eventual standalone CLI maps
8
+ * {@link ReportRangeResult.exitCode} onto its own process exit; this module
9
+ * only ever returns that code as data.
10
+ *
11
+ * A custom bound is either an ISO date `YYYY-MM-DD` (resolved to local
12
+ * midnight in the supplied report timezone) or a full RFC 3339 timestamp with
13
+ * an explicit `Z` or numeric offset. An offsetless date-time, a bare
14
+ * year-month, or any other shape is rejected as invalid syntax. The
15
+ * resolved range is start-inclusive and end-exclusive.
16
+ */
17
+
18
+ import { getPeriodBounds, resolveZonedInstant, type PeriodType } from "./period-boundaries.js";
19
+
20
+ const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
21
+ const OFFSET_DATETIME_PATTERN =
22
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u;
23
+ const OFFSETLESS_DATETIME_PATTERN =
24
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/u;
25
+
26
+ type BoundParseResult =
27
+ | { readonly ok: true; readonly ms: number }
28
+ | { readonly ok: false; readonly reason: string };
29
+
30
+ function parseBound(raw: string, timeZone: string): BoundParseResult {
31
+ if (raw.length === 0) {
32
+ return { ok: false, reason: "a custom range bound must not be empty." };
33
+ }
34
+ if (DATE_ONLY_PATTERN.test(raw)) {
35
+ const [yearRaw, monthRaw, dayRaw] = raw.split("-");
36
+ const year = Number(yearRaw);
37
+ const month = Number(monthRaw);
38
+ const day = Number(dayRaw);
39
+ if (month < 1 || month > 12 || day < 1 || day > 31) {
40
+ return { ok: false, reason: `"${raw}" is not a valid ISO date.` };
41
+ }
42
+ try {
43
+ return {
44
+ ok: true,
45
+ ms: resolveZonedInstant({ year, month: month - 1, day }, timeZone),
46
+ };
47
+ } catch (error) {
48
+ return { ok: false, reason: (error as Error).message };
49
+ }
50
+ }
51
+ if (OFFSET_DATETIME_PATTERN.test(raw)) {
52
+ const ms = Date.parse(raw);
53
+ if (!Number.isFinite(ms)) {
54
+ return { ok: false, reason: `"${raw}" is not a valid RFC 3339 timestamp.` };
55
+ }
56
+ return { ok: true, ms };
57
+ }
58
+ if (OFFSETLESS_DATETIME_PATTERN.test(raw)) {
59
+ return {
60
+ ok: false,
61
+ reason: `"${raw}" is an offsetless date-time; supply "Z" or a numeric UTC offset.`,
62
+ };
63
+ }
64
+ return {
65
+ ok: false,
66
+ reason: `"${raw}" is not a supported ISO date (YYYY-MM-DD) or RFC 3339 timestamp.`,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * The finest granularity actually retained over one sub-interval of history.
72
+ * `"raw"` means per-observation precision (any instant is a valid boundary);
73
+ * every other value is a {@link PeriodType} whose own period boundaries are
74
+ * the finest safely-sliceable instants in that sub-interval.
75
+ */
76
+ export type RetainedGranularity = "raw" | PeriodType;
77
+
78
+ export interface GranularitySegment {
79
+ readonly startMs: number;
80
+ readonly endMs: number;
81
+ readonly granularity: RetainedGranularity;
82
+ }
83
+
84
+ export type RangeSatisfiability =
85
+ | { readonly status: "satisfiable" }
86
+ | { readonly status: "unsatisfiable"; readonly reason: string };
87
+
88
+ function alignsToGranularity(instantMs: number, granularity: RetainedGranularity): boolean {
89
+ if (granularity === "raw") return true;
90
+ return getPeriodBounds(instantMs, granularity).startMs === instantMs;
91
+ }
92
+
93
+ /**
94
+ * Checks whether `range` can be reported without slicing a coarser retained
95
+ * rollup to fabricate detail it never recorded. `segments` describes the
96
+ * caller's own retained-state facts: which sub-intervals of history are only
97
+ * available at which granularity. A range whose start or end instant falls
98
+ * inside a segment but does not land on that segment's own granularity
99
+ * boundary, or that reaches outside every supplied segment, is unsatisfiable.
100
+ * An empty range (`endMs <= startMs`) is trivially satisfiable.
101
+ */
102
+ export function assertRangeSatisfiable(
103
+ range: { readonly startMs: number; readonly endMs: number },
104
+ segments: readonly GranularitySegment[],
105
+ ): RangeSatisfiability {
106
+ if (range.endMs <= range.startMs) return { status: "satisfiable" };
107
+ const startSegment = segments.find(
108
+ (segment) => range.startMs >= segment.startMs && range.startMs < segment.endMs,
109
+ );
110
+ if (startSegment === undefined) {
111
+ return {
112
+ status: "unsatisfiable",
113
+ reason: "the range start falls outside every retained interval.",
114
+ };
115
+ }
116
+ if (!alignsToGranularity(range.startMs, startSegment.granularity)) {
117
+ return {
118
+ status: "unsatisfiable",
119
+ reason: `the range start is not aligned to the retained "${startSegment.granularity}" granularity.`,
120
+ };
121
+ }
122
+ const endSegment = segments.find(
123
+ (segment) => range.endMs > segment.startMs && range.endMs <= segment.endMs,
124
+ );
125
+ if (endSegment === undefined) {
126
+ return {
127
+ status: "unsatisfiable",
128
+ reason: "the range end falls outside every retained interval.",
129
+ };
130
+ }
131
+ if (!alignsToGranularity(range.endMs, endSegment.granularity)) {
132
+ return {
133
+ status: "unsatisfiable",
134
+ reason: `the range end is not aligned to the retained "${endSegment.granularity}" granularity.`,
135
+ };
136
+ }
137
+ return { status: "satisfiable" };
138
+ }
139
+
140
+ export interface ReportRangeInvalid {
141
+ readonly status: "invalid";
142
+ readonly exitCode: 2;
143
+ readonly reason: string;
144
+ }
145
+
146
+ export interface ReportRangeUnsatisfiable {
147
+ readonly status: "unsatisfiable";
148
+ readonly exitCode: 3;
149
+ readonly reason: string;
150
+ }
151
+
152
+ export interface ReportRangeResolved {
153
+ readonly status: "resolved";
154
+ readonly exitCode: 0;
155
+ readonly startMs: number;
156
+ readonly endMs: number;
157
+ }
158
+
159
+ /**
160
+ * A discriminated projection result, never a process exit. The standalone
161
+ * CLI node maps {@link ReportRangeResult.exitCode} onto its own process exit
162
+ * (2 for invalid syntax, 3 for unsatisfiable precision, 0 for a resolved,
163
+ * reportable range -- which may still describe a truthful empty window).
164
+ */
165
+ export type ReportRangeResult =
166
+ | ReportRangeInvalid
167
+ | ReportRangeUnsatisfiable
168
+ | ReportRangeResolved;
169
+
170
+ /**
171
+ * Resolves one custom `--from`/`--to` request. `retainedGranularity`, when
172
+ * supplied, is checked with {@link assertRangeSatisfiable}; omitting it skips
173
+ * the satisfiability check (useful when a caller only wants syntax
174
+ * validation). The range is start-inclusive and end-exclusive; `from` equal
175
+ * to `to` resolves to a valid, explicitly empty range rather than an error.
176
+ */
177
+ export function resolveCustomRange(input: {
178
+ readonly fromRaw: string;
179
+ readonly toRaw: string;
180
+ readonly timeZone?: string;
181
+ readonly retainedGranularity?: readonly GranularitySegment[];
182
+ }): ReportRangeResult {
183
+ const timeZone = input.timeZone ?? "UTC";
184
+ const from = parseBound(input.fromRaw, timeZone);
185
+ if (!from.ok) return { status: "invalid", exitCode: 2, reason: from.reason };
186
+ const to = parseBound(input.toRaw, timeZone);
187
+ if (!to.ok) return { status: "invalid", exitCode: 2, reason: to.reason };
188
+ if (to.ms < from.ms) {
189
+ return {
190
+ status: "invalid",
191
+ exitCode: 2,
192
+ reason: "the range end must not precede its start.",
193
+ };
194
+ }
195
+ const range = { startMs: from.ms, endMs: to.ms };
196
+ if (input.retainedGranularity !== undefined) {
197
+ const satisfiability = assertRangeSatisfiable(range, input.retainedGranularity);
198
+ if (satisfiability.status === "unsatisfiable") {
199
+ return { status: "unsatisfiable", exitCode: 3, reason: satisfiability.reason };
200
+ }
201
+ }
202
+ return { status: "resolved", exitCode: 0, startMs: range.startMs, endMs: range.endMs };
203
+ }