@opengeni/subscriptions 2.0.1 → 2.0.2-canary.38789554071001

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/src/quota.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ModelId, SubscriptionConnection, SubscriptionQuota } from "./types";
1
+ import type { ModelId, QuotaBlockClaim, SubscriptionConnection, SubscriptionQuota } from "./types";
2
2
 
3
3
  /**
4
4
  * What the shared quota model says about a connection's capacity now
@@ -73,6 +73,89 @@ export function modelCooldownUntil(
73
73
  return until !== undefined && until > now ? until : null;
74
74
  }
75
75
 
76
+ /** One stored block a usage reading may end: a model cooldown, or (model null) the exhaustion deadline. */
77
+ export type SubscriptionQuotaBlock = {
78
+ modelId: ModelId | null;
79
+ until: number;
80
+ /** Its claim, only while it still explains this exact deadline. */
81
+ claim: QuotaBlockClaim | null;
82
+ };
83
+
84
+ /**
85
+ * The claim of a stored block (a model's cooldown, or with `modelId` null
86
+ * the exhaustion deadline), only while it is tied to the block's current
87
+ * deadline; null otherwise (fail closed).
88
+ */
89
+ export function quotaBlockClaim(
90
+ quota: Pick<
91
+ SubscriptionQuota,
92
+ "modelCooldowns" | "modelCooldownClaims" | "exhaustedUntil" | "exhaustedClaim"
93
+ >,
94
+ modelId: ModelId | null,
95
+ ): QuotaBlockClaim | null {
96
+ const until = modelId === null ? quota.exhaustedUntil : quota.modelCooldowns[modelId];
97
+ const claim = modelId === null ? quota.exhaustedClaim : quota.modelCooldownClaims?.[modelId];
98
+ return until !== null &&
99
+ until !== undefined &&
100
+ claim !== null &&
101
+ claim !== undefined &&
102
+ claim.until === until &&
103
+ claim.windowIds.length > 0
104
+ ? claim
105
+ : null;
106
+ }
107
+
108
+ /** The quota document a store writes: claims only while they explain a block. */
109
+ export type StoredQuotaDocument = Pick<
110
+ SubscriptionQuota,
111
+ | "windows"
112
+ | "modelCooldowns"
113
+ | "modelCooldownClaims"
114
+ | "exhaustedUntil"
115
+ | "exhaustedKind"
116
+ | "exhaustedClaim"
117
+ | "source"
118
+ >;
119
+
120
+ /** The stored document of one quota (a claim that no longer explains its block is dropped). */
121
+ export function storedQuotaDocument(quota: SubscriptionQuota): StoredQuotaDocument {
122
+ const modelCooldownClaims: Record<string, QuotaBlockClaim> = {};
123
+ for (const modelId of Object.keys(quota.modelCooldowns)) {
124
+ const claim = quotaBlockClaim(quota, modelId);
125
+ if (claim) modelCooldownClaims[modelId] = claim;
126
+ }
127
+ const exhaustedClaim = quotaBlockClaim(quota, null);
128
+ return {
129
+ windows: quota.windows,
130
+ modelCooldowns: quota.modelCooldowns,
131
+ ...(Object.keys(modelCooldownClaims).length > 0 ? { modelCooldownClaims } : {}),
132
+ exhaustedUntil: quota.exhaustedUntil,
133
+ exhaustedKind: quota.exhaustedKind,
134
+ ...(exhaustedClaim ? { exhaustedClaim } : {}),
135
+ source: quota.source,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * The claim of a block merged from several observations at `until`: every
141
+ * source whose own block has that exact deadline must carry a claim tied to
142
+ * it (their window ids are joined); otherwise none (an unexplained limit
143
+ * with the same deadline keeps the block).
144
+ */
145
+ function mergedClaim(
146
+ until: number,
147
+ sources: readonly { until: number | null | undefined; claim: QuotaBlockClaim | null }[],
148
+ ): QuotaBlockClaim | null {
149
+ const deciding = sources.filter((source) => source.until === until);
150
+ if (deciding.length === 0 || deciding.some((source) => source.claim === null)) return null;
151
+ const claims = deciding.map((source) => source.claim!);
152
+ return {
153
+ until,
154
+ observedAt: Math.max(...claims.map((claim) => claim.observedAt)),
155
+ windowIds: [...new Set(claims.flatMap((claim) => claim.windowIds))],
156
+ };
157
+ }
158
+
76
159
  /**
77
160
  * A quota observation applies only to the credential generation it was made
78
161
  * with, so a refusal seen before a refresh cannot quarantine the renewed
@@ -140,6 +223,31 @@ export function applyQuotaObservation(
140
223
  current.exhaustedUntil !== null &&
141
224
  current.exhaustedUntil > observedAt &&
142
225
  (observation.exhaustedUntil === null || observation.exhaustedUntil < current.exhaustedUntil);
226
+ const exhaustedUntil = keepCurrentDeadline ? current.exhaustedUntil : observation.exhaustedUntil;
227
+ // A block keeps its evidence only where every observation that set its
228
+ // merged deadline explained it (`QuotaBlockClaim`).
229
+ const sources = [current, observation];
230
+ const modelCooldownClaims: Record<string, QuotaBlockClaim> = {};
231
+ for (const [modelId, until] of Object.entries(modelCooldowns)) {
232
+ const claim = mergedClaim(
233
+ until,
234
+ sources.map((source) => ({
235
+ until: source.modelCooldowns[modelId],
236
+ claim: quotaBlockClaim(source, modelId),
237
+ })),
238
+ );
239
+ if (claim) modelCooldownClaims[modelId] = claim;
240
+ }
241
+ const exhaustedClaim =
242
+ exhaustedUntil === null
243
+ ? null
244
+ : mergedClaim(
245
+ exhaustedUntil,
246
+ sources.map((source) => ({
247
+ until: source.exhaustedUntil,
248
+ claim: quotaBlockClaim(source, null),
249
+ })),
250
+ );
143
251
  const reported = new Set(observation.windows.map((window) => window.id));
144
252
  const omittedExhausted =
145
253
  observation.source === "usage_endpoint" && options.usageReadingsComplete === true
@@ -151,13 +259,18 @@ export function applyQuotaObservation(
151
259
  window.resetsAt !== null &&
152
260
  window.resetsAt > observedAt,
153
261
  );
154
- return {
262
+ const merged: SubscriptionQuota = {
155
263
  ...observation,
156
264
  windows: omittedExhausted.length
157
265
  ? [...observation.windows, ...omittedExhausted]
158
266
  : observation.windows,
159
267
  modelCooldowns,
160
- exhaustedUntil: keepCurrentDeadline ? current.exhaustedUntil : observation.exhaustedUntil,
268
+ exhaustedUntil,
161
269
  exhaustedKind: keepCurrentDeadline ? current.exhaustedKind : observation.exhaustedKind,
162
270
  };
271
+ delete merged.modelCooldownClaims;
272
+ delete merged.exhaustedClaim;
273
+ if (Object.keys(modelCooldownClaims).length > 0) merged.modelCooldownClaims = modelCooldownClaims;
274
+ if (exhaustedClaim) merged.exhaustedClaim = exhaustedClaim;
275
+ return merged;
163
276
  }
@@ -143,6 +143,7 @@ export type SwitchKind =
143
143
 
144
144
  export type WaitReason =
145
145
  | "pinned_account_unavailable"
146
+ | "pinned_account_disabled"
146
147
  /** The explicit choice can never serve this work until a person changes something (D-24). */
147
148
  | "pinned_account_ineligible"
148
149
  | "no_eligible_capacity"
@@ -566,6 +567,21 @@ export function pinnedNeverServes(
566
567
  return !isAuthorized(world, session, { ...pinned, healthy: true, allocatorEnabled: true });
567
568
  }
568
569
 
570
+ function pinnedAllocationDisabled(world: World, session: Session, pinned: Connection): boolean {
571
+ if (!pinned.allocatorEnabled) return true;
572
+ if (pinned.assignmentPolicies === undefined) return false;
573
+ const source =
574
+ effectiveSettings(world.settings, session.workspaceId).values.inferenceSource?.[
575
+ pinned.provider
576
+ ] ?? "automatic";
577
+ return !pinned.assignmentPolicies.some(
578
+ (policy) =>
579
+ policy.workspaceId === session.workspaceId &&
580
+ (source === "automatic" || policy.inferencePool === source) &&
581
+ policy.allocatorEnabled,
582
+ );
583
+ }
584
+
569
585
  /** Future times at which some quota or model cooldown in the world resets, ascending. */
570
586
  function futureResetTimes(world: World, now: number): number[] {
571
587
  const times = new Set<number>();
@@ -652,6 +668,9 @@ export function decide(world: World, sessionId: string, now: number): Decision {
652
668
  if (pinnedNeverServes(world, session, pinned)) {
653
669
  return { kind: "wait", reason: "pinned_account_ineligible", earliestResetAt: null };
654
670
  }
671
+ if (pinned && pinnedAllocationDisabled(world, session, pinned)) {
672
+ return { kind: "wait", reason: "pinned_account_disabled", earliestResetAt: null };
673
+ }
655
674
  // Otherwise the earliest future time the chosen account can serve (D-22).
656
675
  return {
657
676
  kind: "wait",
@@ -906,7 +925,9 @@ export function checkDecision(
906
925
  : session.pinnedConnectionId
907
926
  ? never
908
927
  ? "pinned_account_ineligible"
909
- : "pinned_account_unavailable"
928
+ : pinned && pinnedAllocationDisabled(world, session, pinned)
929
+ ? "pinned_account_disabled"
930
+ : "pinned_account_unavailable"
910
931
  : "no_eligible_capacity";
911
932
  if (decision.reason !== expectedReason) {
912
933
  fail(
package/src/types.ts CHANGED
@@ -101,12 +101,34 @@ export type QuotaWindow = {
101
101
 
102
102
  export type QuotaSource = "usage_endpoint" | "response_headers" | "refusal";
103
103
 
104
+ /**
105
+ * Which reported limits one stored block (a model cooldown, or the quota
106
+ * exhaustion deadline) came from: the provider's own window ids, as the
107
+ * observation that set the block named them, tied to that block's exact
108
+ * deadline. A block whose deadline no longer equals `until` (a later write
109
+ * extended it without evidence) has no claim. A block without a claim (an
110
+ * unrecognized or missing limit, or a refusal without usage evidence) never
111
+ * ends before its own deadline.
112
+ */
113
+ export type QuotaBlockClaim = {
114
+ /** The deadline of the block this claim explains. */
115
+ until: number;
116
+ /** When the observation that set the block was made. */
117
+ observedAt: number;
118
+ /** The provider window ids whose limit set the block (never empty). */
119
+ windowIds: readonly string[];
120
+ };
121
+
104
122
  export type SubscriptionQuota = {
105
123
  windows: readonly QuotaWindow[];
106
124
  /** Per-model cooldowns (model-specific limits some providers report). */
107
125
  modelCooldowns: Readonly<Record<ModelId, number>>;
126
+ /** Optional evidence per model cooldown (see `QuotaBlockClaim`); absent: none. */
127
+ modelCooldownClaims?: Readonly<Record<ModelId, QuotaBlockClaim>>;
108
128
  exhaustedUntil: number | null;
109
129
  exhaustedKind: "quota" | "rate_limit" | null;
130
+ /** Optional evidence for `exhaustedUntil` (see `QuotaBlockClaim`); absent: none. */
131
+ exhaustedClaim?: QuotaBlockClaim | null;
110
132
  revision: number;
111
133
  observedAt: number | null;
112
134
  observedRefreshGeneration: number | null;
@@ -315,6 +337,8 @@ export type AccountSwitchReason =
315
337
 
316
338
  export type WaitReason =
317
339
  | "pinned_account_unavailable"
340
+ /** Allocation was explicitly switched off; a person must enable it or change the pin. */
341
+ | "pinned_account_disabled"
318
342
  /** The explicit choice can never serve this work until a person changes something (D-24). */
319
343
  | "pinned_account_ineligible"
320
344
  | "no_eligible_capacity"
@@ -0,0 +1,27 @@
1
+ import type { WaitReason } from "./types";
2
+
3
+ /** Current wait guidance shared by initial parking and later reclassification.
4
+ * These are new audit events only; never rewrite an earlier model request. */
5
+ export function subscriptionCapacityWaitReasonText(reason: string, name: string): string | null {
6
+ const messages: Record<WaitReason, string> = {
7
+ pinned_account_unavailable: `The ${name} account chosen for this session is unavailable right now. This turn continues automatically once it is available again.`,
8
+ pinned_account_disabled: `The ${name} account pinned to this chat is turned off. Turn it back on, choose another account, or switch the chat to automatic. Waiting alone will not fix this.`,
9
+ pinned_account_ineligible: `The ${name} account chosen for this session can no longer serve this work. This turn continues once another account is chosen or the session is switched back to automatic.`,
10
+ no_eligible_capacity: `No ${name} subscription has capacity for this turn right now. It continues automatically when an account is available.`,
11
+ model_not_allowed: `This model is not allowed for the ${name} subscriptions available here. This turn continues if that changes.`,
12
+ compaction_provider_locked:
13
+ "This session is tied to a provider that has no capacity right now. It continues automatically when an account is available.",
14
+ accepted_authority_unavailable:
15
+ "This work was accepted before the account move and its account access could not be carried. Send it again to continue.",
16
+ };
17
+ return Object.hasOwn(messages, reason) ? messages[reason as WaitReason] : null;
18
+ }
19
+
20
+ export function subscriptionCapacityWaitNeedsAction(reason: string): boolean {
21
+ return [
22
+ "pinned_account_disabled",
23
+ "pinned_account_ineligible",
24
+ "model_not_allowed",
25
+ "accepted_authority_unavailable",
26
+ ].includes(reason);
27
+ }