@omnicross/subscriptions 0.1.2 → 0.1.4

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/dist/index.d.cts CHANGED
@@ -27,7 +27,15 @@ export { claudeOAuth, codexOAuth, geminiOAuth } from './oauth.cjs';
27
27
  * subscriptions layer).
28
28
  */
29
29
 
30
- /** The six methods the subscription block consumes from the host credential store. */
30
+ /**
31
+ * The credential surface the subscription block consumes from the host store.
32
+ *
33
+ * The six ORIGINAL methods (active-account getters + refreshers) are REQUIRED;
34
+ * the three by-id methods (subscription-account-scheduling, design D6) are
35
+ * OPTIONAL and feature-detected so every existing lightweight test double that
36
+ * implements only the six keeps compiling and the single-account active path is
37
+ * untouched.
38
+ */
31
39
  interface SubscriptionCredentialStore {
32
40
  /** Full decrypted account-tokens config (all subscription providers). */
33
41
  getFullConfig(): Promise<AccountTokensConfig>;
@@ -41,6 +49,124 @@ interface SubscriptionCredentialStore {
41
49
  refreshGeminiToken(): Promise<boolean>;
42
50
  /** Current valid OpenCodeGo static API key; `null` if none. */
43
51
  getValidOpenCodeGoApiKey(): Promise<string | null>;
52
+ /**
53
+ * Resolve a SPECIFIC account's access token by id (refreshing a near-expiry
54
+ * OAuth token for that account, mirroring the active getter's per-provider
55
+ * refresh policy). `null` when the account is unknown/expired/tokenless.
56
+ */
57
+ getAccessTokenForAccount?(providerId: SubscriptionProviderId, accountId: string): Promise<string | null>;
58
+ /**
59
+ * Force a refresh of a SPECIFIC account's OAuth token by id; `true` on success.
60
+ * Static-key providers (opencodego) return `false` — they don't refresh.
61
+ */
62
+ refreshAccountToken?(providerId: SubscriptionProviderId, accountId: string): Promise<boolean>;
63
+ /**
64
+ * Best-effort record of a selection's time onto the account's `lastUsedAt`
65
+ * (throttled by the selector so the hot path does not rewrite the store every
66
+ * request). Durability only — never affects which credential is valid.
67
+ */
68
+ touchAccountLastUsed?(providerId: SubscriptionProviderId, accountId: string, iso: string): Promise<void>;
69
+ }
70
+
71
+ /**
72
+ * SubscriptionAccountSelector — the pure, in-memory account scheduler
73
+ * (subscription-account-scheduling, design D3).
74
+ *
75
+ * Given a provider's account list (+ an optional session key) it decides WHICH
76
+ * account of the pool serves the outbound request:
77
+ *
78
+ * filter `schedulable !== false`
79
+ * → session-affinity short-circuit (sticky `sessionKey → accountId`, TTL 1h)
80
+ * → sort by `priority` asc → effective `lastUsedAt` asc (LRU) → `createdAt` asc
81
+ * → `[0]`.
82
+ *
83
+ * A faithful port of CRS `sortAccountsByPriority` + `unifiedClaudeScheduler`
84
+ * session mapping. The class holds process-lived state — the affinity map and an
85
+ * in-memory `lastUsedAt` overlay (the authoritative live tie-break value, seeded
86
+ * from the persisted field and advanced to `now` on each selection so repeated
87
+ * selections round-robin fairly even before any durable persist). ONE instance is
88
+ * constructed at bootstrap and SHARED by all three auth strategies (each scopes
89
+ * its calls by `providerId`), mirroring the single long-lived CRS scheduler.
90
+ *
91
+ * ZERO-REGRESSION CARRIER: `select()` returns `null` when the schedulable account
92
+ * count is ≤ 1. Callers treat `null` (and an `isActive` result) as "use the
93
+ * existing active-mirror path", so a single-account provider exercises NO new
94
+ * code and its token read is byte-identical to before this change.
95
+ *
96
+ * @module scheduler/SubscriptionAccountSelector
97
+ */
98
+
99
+ /** Sticky session→account affinity TTL (CRS parity: 1 hour). */
100
+ declare const SESSION_AFFINITY_TTL_MS = 3600000;
101
+ /** Min gap between best-effort `lastUsedAt` durable persists, per account. */
102
+ declare const LAST_USED_PERSIST_THROTTLE_MS = 60000;
103
+ /** Precedence assumed for an account with no `priority` (CRS `|| 50`). */
104
+ declare const DEFAULT_ACCOUNT_PRIORITY = 50;
105
+ /** One schedulable candidate — the scheduling projection of an account entry. */
106
+ interface SchedulableAccount {
107
+ id: string;
108
+ /** Default `50` (lower = higher precedence). */
109
+ priority?: number;
110
+ /** ISO — LRU tie-break; absent ⇒ treated as least-recently-used (`0`). */
111
+ lastUsedAt?: string;
112
+ /** ISO — final tie-break; absent ⇒ oldest (`0`). */
113
+ createdAt?: string;
114
+ /** Default `true`; child #2 (account health) sets `false` for an unhealthy
115
+ * account and the selector skips it. */
116
+ schedulable?: boolean;
117
+ }
118
+ interface SelectInput {
119
+ providerId: SubscriptionProviderId;
120
+ accounts: readonly SchedulableAccount[];
121
+ /** The persistent active-account pointer (for the `isActive` discriminant). */
122
+ activeAccountId?: string;
123
+ /** Stable per-conversation key for session affinity; absent ⇒ pure priority/LRU. */
124
+ sessionKey?: string;
125
+ /** Injectable clock for tests (default `Date.now()`). */
126
+ now?: number;
127
+ }
128
+ interface SelectResult {
129
+ accountId: string;
130
+ /** `true` when the chosen account is the active one — the caller then runs the
131
+ * existing active-mirror getter verbatim (no by-id read). */
132
+ isActive: boolean;
133
+ }
134
+ declare class SubscriptionAccountSelector {
135
+ /** `providerId\0accountId → live lastUsedAt ms` (authoritative tie-break). */
136
+ private readonly lastUsedOverlay;
137
+ /** `providerId\0sessionKey → { accountId, expiresAt }`. */
138
+ private readonly affinity;
139
+ /** `providerId\0accountId → last durable-persist ms` (throttle state). */
140
+ private readonly lastPersist;
141
+ /**
142
+ * Choose the account to serve this request, or `null` when there are ≤ 1
143
+ * schedulable accounts (the zero-regression signal — caller uses the active
144
+ * account). Updates the live `lastUsedAt` overlay for the chosen account and,
145
+ * when a `sessionKey` is given, records/extends the affinity mapping.
146
+ */
147
+ select(input: SelectInput): SelectResult | null;
148
+ /**
149
+ * Drop every session-affinity mapping bound to this account (subscription-account-
150
+ * health, task 4.1). Called when a selected account's by-id token turns out
151
+ * null/invalid or an affinity-bound account becomes unhealthy, so the next
152
+ * selection for those sessions picks a fresh account instead of re-sticking to
153
+ * the bad one. O(affinity entries) — the map is tiny (one entry per live
154
+ * conversation).
155
+ */
156
+ evictAffinity(providerId: SubscriptionProviderId, accountId: string): void;
157
+ /**
158
+ * Whether a best-effort `lastUsedAt` durable persist is DUE for this account
159
+ * (≥ `LAST_USED_PERSIST_THROTTLE_MS` since the last one). Records the persist
160
+ * time when it returns `true`, so the strategy calls `touchAccountLastUsed`
161
+ * sparingly and the request hot path does not rewrite the store every request.
162
+ */
163
+ duePersist(providerId: SubscriptionProviderId, accountId: string, now?: number): boolean;
164
+ /** Sort by `priority` asc → effective `lastUsedAt` asc → `createdAt` asc → `[0]`. */
165
+ private pickOrdered;
166
+ /** The live tie-break value: the in-memory overlay when set, else the persisted
167
+ * `lastUsedAt` (0 when absent). */
168
+ private effectiveLastUsed;
169
+ private markUsed;
44
170
  }
45
171
 
46
172
  /**
@@ -57,6 +183,9 @@ interface SubscriptionCredentialStore {
57
183
 
58
184
  declare class SubscriptionAccountService {
59
185
  private readonly mutex;
186
+ /** ONE account-pool scheduler (subscription-account-scheduling) shared by all
187
+ * four strategies so they share the affinity map + the `lastUsedAt` overlay. */
188
+ private readonly selector;
60
189
  private readonly strategies;
61
190
  constructor(tokens: SubscriptionCredentialStore);
62
191
  /** Returns the strategy bound to a subscription provider, or `null` for unknown ids. */
@@ -136,7 +265,17 @@ interface DispatcherHooks {
136
265
  readonly executor: TransformerChainExecutor;
137
266
  /** Shared transformer service registry — looks up transformer-by-name. */
138
267
  readonly transformerService: TransformerService;
139
- /** Fetch + retry helper from the proxy (semaphore, 429/5xx loop). */
268
+ /**
269
+ * Fetch + retry helper from the proxy (semaphore, 429/5xx loop). On a non-ok
270
+ * upstream it throws a `ProviderApiError`-shaped error carrying `.status`.
271
+ *
272
+ * ACCOUNT-HEALTH CONTRACT (subscription-account-health): for the daemon-path
273
+ * 429-reset cooldown + 403-ban sniff to function, the thrown error SHOULD also
274
+ * carry the upstream response `headers` (a `Headers` or a plain record) and, on
275
+ * a 403, a bounded `bodyText`/`body` string. The dispatcher reads them
276
+ * STRUCTURALLY (`errHeaders`/`errBodyText`) — absent ⇒ the account-health mark
277
+ * gracefully degrades to a bare-429 (unmarked, lazy re-probe), never an error.
278
+ */
140
279
  fetchWithRetry(url: string, headers: Record<string, string>, body: unknown, model: string): Promise<Response>;
141
280
  /** Forward the upstream response to the SDK + tap usage. */
142
281
  writeProxyResponse(res: http.ServerResponse, providerResponse: Response, isStream: boolean, reqId?: number): Promise<void>;
@@ -185,6 +324,17 @@ declare class SubscriptionDispatcher {
185
324
  */
186
325
  private maybeRetryAfterError;
187
326
  private applyHeadersWithRetry;
327
+ /**
328
+ * Mark the served account's health against ONE attempt's outcome
329
+ * (subscription-account-health, task 3.4). No-op when no account was reported
330
+ * (non-pooled / single-account) or on a session-cancel (status 0). On a caught
331
+ * error `err` is passed so the 429-reset / 403-ban drivers are read STRUCTURALLY
332
+ * from the error's upstream `headers` + `bodyText` (the `fetchWithRetry`
333
+ * contract) — so daemon-path 429 cooldown + ban blocking function for
334
+ * multi-account codex/gemini/opencodego pools; absent headers ⇒ a bare-429
335
+ * (unmarked, lazy re-probe). Success (2xx) clears; 401/5xx/thrown → transient.
336
+ */
337
+ private markHealth;
188
338
  /**
189
339
  * Resolve the Code Assist project for the gemini subscription profile. Pulls
190
340
  * the Bearer the bound `AuthStrategy` would inject (so the strategy stays the
@@ -199,4 +349,4 @@ declare class SubscriptionDispatcher {
199
349
  private buildRequestSummary;
200
350
  }
201
351
 
202
- export { type DispatchRequest, type DispatcherHooks, SubscriptionAccountService, type SubscriptionCredentialStore, SubscriptionDispatcher, SubscriptionProviderRegistry, getSubscriptionAccountService, getSubscriptionProviderRegistry, setSubscriptionAccountService, setSubscriptionProviderRegistry };
352
+ export { DEFAULT_ACCOUNT_PRIORITY, type DispatchRequest, type DispatcherHooks, LAST_USED_PERSIST_THROTTLE_MS, SESSION_AFFINITY_TTL_MS, type SchedulableAccount, type SelectInput, type SelectResult, SubscriptionAccountSelector, SubscriptionAccountService, type SubscriptionCredentialStore, SubscriptionDispatcher, SubscriptionProviderRegistry, getSubscriptionAccountService, getSubscriptionProviderRegistry, setSubscriptionAccountService, setSubscriptionProviderRegistry };
package/dist/index.d.ts CHANGED
@@ -27,7 +27,15 @@ export { claudeOAuth, codexOAuth, geminiOAuth } from './oauth.js';
27
27
  * subscriptions layer).
28
28
  */
29
29
 
30
- /** The six methods the subscription block consumes from the host credential store. */
30
+ /**
31
+ * The credential surface the subscription block consumes from the host store.
32
+ *
33
+ * The six ORIGINAL methods (active-account getters + refreshers) are REQUIRED;
34
+ * the three by-id methods (subscription-account-scheduling, design D6) are
35
+ * OPTIONAL and feature-detected so every existing lightweight test double that
36
+ * implements only the six keeps compiling and the single-account active path is
37
+ * untouched.
38
+ */
31
39
  interface SubscriptionCredentialStore {
32
40
  /** Full decrypted account-tokens config (all subscription providers). */
33
41
  getFullConfig(): Promise<AccountTokensConfig>;
@@ -41,6 +49,124 @@ interface SubscriptionCredentialStore {
41
49
  refreshGeminiToken(): Promise<boolean>;
42
50
  /** Current valid OpenCodeGo static API key; `null` if none. */
43
51
  getValidOpenCodeGoApiKey(): Promise<string | null>;
52
+ /**
53
+ * Resolve a SPECIFIC account's access token by id (refreshing a near-expiry
54
+ * OAuth token for that account, mirroring the active getter's per-provider
55
+ * refresh policy). `null` when the account is unknown/expired/tokenless.
56
+ */
57
+ getAccessTokenForAccount?(providerId: SubscriptionProviderId, accountId: string): Promise<string | null>;
58
+ /**
59
+ * Force a refresh of a SPECIFIC account's OAuth token by id; `true` on success.
60
+ * Static-key providers (opencodego) return `false` — they don't refresh.
61
+ */
62
+ refreshAccountToken?(providerId: SubscriptionProviderId, accountId: string): Promise<boolean>;
63
+ /**
64
+ * Best-effort record of a selection's time onto the account's `lastUsedAt`
65
+ * (throttled by the selector so the hot path does not rewrite the store every
66
+ * request). Durability only — never affects which credential is valid.
67
+ */
68
+ touchAccountLastUsed?(providerId: SubscriptionProviderId, accountId: string, iso: string): Promise<void>;
69
+ }
70
+
71
+ /**
72
+ * SubscriptionAccountSelector — the pure, in-memory account scheduler
73
+ * (subscription-account-scheduling, design D3).
74
+ *
75
+ * Given a provider's account list (+ an optional session key) it decides WHICH
76
+ * account of the pool serves the outbound request:
77
+ *
78
+ * filter `schedulable !== false`
79
+ * → session-affinity short-circuit (sticky `sessionKey → accountId`, TTL 1h)
80
+ * → sort by `priority` asc → effective `lastUsedAt` asc (LRU) → `createdAt` asc
81
+ * → `[0]`.
82
+ *
83
+ * A faithful port of CRS `sortAccountsByPriority` + `unifiedClaudeScheduler`
84
+ * session mapping. The class holds process-lived state — the affinity map and an
85
+ * in-memory `lastUsedAt` overlay (the authoritative live tie-break value, seeded
86
+ * from the persisted field and advanced to `now` on each selection so repeated
87
+ * selections round-robin fairly even before any durable persist). ONE instance is
88
+ * constructed at bootstrap and SHARED by all three auth strategies (each scopes
89
+ * its calls by `providerId`), mirroring the single long-lived CRS scheduler.
90
+ *
91
+ * ZERO-REGRESSION CARRIER: `select()` returns `null` when the schedulable account
92
+ * count is ≤ 1. Callers treat `null` (and an `isActive` result) as "use the
93
+ * existing active-mirror path", so a single-account provider exercises NO new
94
+ * code and its token read is byte-identical to before this change.
95
+ *
96
+ * @module scheduler/SubscriptionAccountSelector
97
+ */
98
+
99
+ /** Sticky session→account affinity TTL (CRS parity: 1 hour). */
100
+ declare const SESSION_AFFINITY_TTL_MS = 3600000;
101
+ /** Min gap between best-effort `lastUsedAt` durable persists, per account. */
102
+ declare const LAST_USED_PERSIST_THROTTLE_MS = 60000;
103
+ /** Precedence assumed for an account with no `priority` (CRS `|| 50`). */
104
+ declare const DEFAULT_ACCOUNT_PRIORITY = 50;
105
+ /** One schedulable candidate — the scheduling projection of an account entry. */
106
+ interface SchedulableAccount {
107
+ id: string;
108
+ /** Default `50` (lower = higher precedence). */
109
+ priority?: number;
110
+ /** ISO — LRU tie-break; absent ⇒ treated as least-recently-used (`0`). */
111
+ lastUsedAt?: string;
112
+ /** ISO — final tie-break; absent ⇒ oldest (`0`). */
113
+ createdAt?: string;
114
+ /** Default `true`; child #2 (account health) sets `false` for an unhealthy
115
+ * account and the selector skips it. */
116
+ schedulable?: boolean;
117
+ }
118
+ interface SelectInput {
119
+ providerId: SubscriptionProviderId;
120
+ accounts: readonly SchedulableAccount[];
121
+ /** The persistent active-account pointer (for the `isActive` discriminant). */
122
+ activeAccountId?: string;
123
+ /** Stable per-conversation key for session affinity; absent ⇒ pure priority/LRU. */
124
+ sessionKey?: string;
125
+ /** Injectable clock for tests (default `Date.now()`). */
126
+ now?: number;
127
+ }
128
+ interface SelectResult {
129
+ accountId: string;
130
+ /** `true` when the chosen account is the active one — the caller then runs the
131
+ * existing active-mirror getter verbatim (no by-id read). */
132
+ isActive: boolean;
133
+ }
134
+ declare class SubscriptionAccountSelector {
135
+ /** `providerId\0accountId → live lastUsedAt ms` (authoritative tie-break). */
136
+ private readonly lastUsedOverlay;
137
+ /** `providerId\0sessionKey → { accountId, expiresAt }`. */
138
+ private readonly affinity;
139
+ /** `providerId\0accountId → last durable-persist ms` (throttle state). */
140
+ private readonly lastPersist;
141
+ /**
142
+ * Choose the account to serve this request, or `null` when there are ≤ 1
143
+ * schedulable accounts (the zero-regression signal — caller uses the active
144
+ * account). Updates the live `lastUsedAt` overlay for the chosen account and,
145
+ * when a `sessionKey` is given, records/extends the affinity mapping.
146
+ */
147
+ select(input: SelectInput): SelectResult | null;
148
+ /**
149
+ * Drop every session-affinity mapping bound to this account (subscription-account-
150
+ * health, task 4.1). Called when a selected account's by-id token turns out
151
+ * null/invalid or an affinity-bound account becomes unhealthy, so the next
152
+ * selection for those sessions picks a fresh account instead of re-sticking to
153
+ * the bad one. O(affinity entries) — the map is tiny (one entry per live
154
+ * conversation).
155
+ */
156
+ evictAffinity(providerId: SubscriptionProviderId, accountId: string): void;
157
+ /**
158
+ * Whether a best-effort `lastUsedAt` durable persist is DUE for this account
159
+ * (≥ `LAST_USED_PERSIST_THROTTLE_MS` since the last one). Records the persist
160
+ * time when it returns `true`, so the strategy calls `touchAccountLastUsed`
161
+ * sparingly and the request hot path does not rewrite the store every request.
162
+ */
163
+ duePersist(providerId: SubscriptionProviderId, accountId: string, now?: number): boolean;
164
+ /** Sort by `priority` asc → effective `lastUsedAt` asc → `createdAt` asc → `[0]`. */
165
+ private pickOrdered;
166
+ /** The live tie-break value: the in-memory overlay when set, else the persisted
167
+ * `lastUsedAt` (0 when absent). */
168
+ private effectiveLastUsed;
169
+ private markUsed;
44
170
  }
45
171
 
46
172
  /**
@@ -57,6 +183,9 @@ interface SubscriptionCredentialStore {
57
183
 
58
184
  declare class SubscriptionAccountService {
59
185
  private readonly mutex;
186
+ /** ONE account-pool scheduler (subscription-account-scheduling) shared by all
187
+ * four strategies so they share the affinity map + the `lastUsedAt` overlay. */
188
+ private readonly selector;
60
189
  private readonly strategies;
61
190
  constructor(tokens: SubscriptionCredentialStore);
62
191
  /** Returns the strategy bound to a subscription provider, or `null` for unknown ids. */
@@ -136,7 +265,17 @@ interface DispatcherHooks {
136
265
  readonly executor: TransformerChainExecutor;
137
266
  /** Shared transformer service registry — looks up transformer-by-name. */
138
267
  readonly transformerService: TransformerService;
139
- /** Fetch + retry helper from the proxy (semaphore, 429/5xx loop). */
268
+ /**
269
+ * Fetch + retry helper from the proxy (semaphore, 429/5xx loop). On a non-ok
270
+ * upstream it throws a `ProviderApiError`-shaped error carrying `.status`.
271
+ *
272
+ * ACCOUNT-HEALTH CONTRACT (subscription-account-health): for the daemon-path
273
+ * 429-reset cooldown + 403-ban sniff to function, the thrown error SHOULD also
274
+ * carry the upstream response `headers` (a `Headers` or a plain record) and, on
275
+ * a 403, a bounded `bodyText`/`body` string. The dispatcher reads them
276
+ * STRUCTURALLY (`errHeaders`/`errBodyText`) — absent ⇒ the account-health mark
277
+ * gracefully degrades to a bare-429 (unmarked, lazy re-probe), never an error.
278
+ */
140
279
  fetchWithRetry(url: string, headers: Record<string, string>, body: unknown, model: string): Promise<Response>;
141
280
  /** Forward the upstream response to the SDK + tap usage. */
142
281
  writeProxyResponse(res: http.ServerResponse, providerResponse: Response, isStream: boolean, reqId?: number): Promise<void>;
@@ -185,6 +324,17 @@ declare class SubscriptionDispatcher {
185
324
  */
186
325
  private maybeRetryAfterError;
187
326
  private applyHeadersWithRetry;
327
+ /**
328
+ * Mark the served account's health against ONE attempt's outcome
329
+ * (subscription-account-health, task 3.4). No-op when no account was reported
330
+ * (non-pooled / single-account) or on a session-cancel (status 0). On a caught
331
+ * error `err` is passed so the 429-reset / 403-ban drivers are read STRUCTURALLY
332
+ * from the error's upstream `headers` + `bodyText` (the `fetchWithRetry`
333
+ * contract) — so daemon-path 429 cooldown + ban blocking function for
334
+ * multi-account codex/gemini/opencodego pools; absent headers ⇒ a bare-429
335
+ * (unmarked, lazy re-probe). Success (2xx) clears; 401/5xx/thrown → transient.
336
+ */
337
+ private markHealth;
188
338
  /**
189
339
  * Resolve the Code Assist project for the gemini subscription profile. Pulls
190
340
  * the Bearer the bound `AuthStrategy` would inject (so the strategy stays the
@@ -199,4 +349,4 @@ declare class SubscriptionDispatcher {
199
349
  private buildRequestSummary;
200
350
  }
201
351
 
202
- export { type DispatchRequest, type DispatcherHooks, SubscriptionAccountService, type SubscriptionCredentialStore, SubscriptionDispatcher, SubscriptionProviderRegistry, getSubscriptionAccountService, getSubscriptionProviderRegistry, setSubscriptionAccountService, setSubscriptionProviderRegistry };
352
+ export { DEFAULT_ACCOUNT_PRIORITY, type DispatchRequest, type DispatcherHooks, LAST_USED_PERSIST_THROTTLE_MS, SESSION_AFFINITY_TTL_MS, type SchedulableAccount, type SelectInput, type SelectResult, SubscriptionAccountSelector, SubscriptionAccountService, type SubscriptionCredentialStore, SubscriptionDispatcher, SubscriptionProviderRegistry, getSubscriptionAccountService, getSubscriptionProviderRegistry, setSubscriptionAccountService, setSubscriptionProviderRegistry };