@omnicross/subscriptions 0.1.2 → 0.1.3

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.js CHANGED
@@ -4,26 +4,278 @@ import {
4
4
  gemini_exports
5
5
  } from "./chunk-IXGHVMZB.js";
6
6
 
7
+ // src/scheduler/SubscriptionAccountSelector.ts
8
+ var SESSION_AFFINITY_TTL_MS = 36e5;
9
+ var LAST_USED_PERSIST_THROTTLE_MS = 6e4;
10
+ var DEFAULT_ACCOUNT_PRIORITY = 50;
11
+ function scopedKey(providerId, tail) {
12
+ return `${providerId}\0${tail}`;
13
+ }
14
+ function parseIso(iso) {
15
+ if (!iso) return 0;
16
+ const ms = Date.parse(iso);
17
+ return Number.isNaN(ms) ? 0 : ms;
18
+ }
19
+ var SubscriptionAccountSelector = class {
20
+ /** `providerId\0accountId → live lastUsedAt ms` (authoritative tie-break). */
21
+ lastUsedOverlay = /* @__PURE__ */ new Map();
22
+ /** `providerId\0sessionKey → { accountId, expiresAt }`. */
23
+ affinity = /* @__PURE__ */ new Map();
24
+ /** `providerId\0accountId → last durable-persist ms` (throttle state). */
25
+ lastPersist = /* @__PURE__ */ new Map();
26
+ /**
27
+ * Choose the account to serve this request, or `null` when there are ≤ 1
28
+ * schedulable accounts (the zero-regression signal — caller uses the active
29
+ * account). Updates the live `lastUsedAt` overlay for the chosen account and,
30
+ * when a `sessionKey` is given, records/extends the affinity mapping.
31
+ */
32
+ select(input) {
33
+ const now = input.now ?? Date.now();
34
+ const schedulable = input.accounts.filter((a) => a.schedulable !== false);
35
+ if (schedulable.length <= 1) return null;
36
+ if (input.sessionKey) {
37
+ const aKey = scopedKey(input.providerId, input.sessionKey);
38
+ const hit = this.affinity.get(aKey);
39
+ if (hit && hit.expiresAt > now && schedulable.some((a) => a.id === hit.accountId)) {
40
+ hit.expiresAt = now + SESSION_AFFINITY_TTL_MS;
41
+ this.markUsed(input.providerId, hit.accountId, now);
42
+ return { accountId: hit.accountId, isActive: hit.accountId === input.activeAccountId };
43
+ }
44
+ }
45
+ const chosen = this.pickOrdered(input.providerId, schedulable);
46
+ this.markUsed(input.providerId, chosen.id, now);
47
+ if (input.sessionKey) {
48
+ this.affinity.set(scopedKey(input.providerId, input.sessionKey), {
49
+ accountId: chosen.id,
50
+ expiresAt: now + SESSION_AFFINITY_TTL_MS
51
+ });
52
+ }
53
+ return { accountId: chosen.id, isActive: chosen.id === input.activeAccountId };
54
+ }
55
+ /**
56
+ * Drop every session-affinity mapping bound to this account (subscription-account-
57
+ * health, task 4.1). Called when a selected account's by-id token turns out
58
+ * null/invalid or an affinity-bound account becomes unhealthy, so the next
59
+ * selection for those sessions picks a fresh account instead of re-sticking to
60
+ * the bad one. O(affinity entries) — the map is tiny (one entry per live
61
+ * conversation).
62
+ */
63
+ evictAffinity(providerId, accountId) {
64
+ const prefix = `${providerId} `;
65
+ for (const [key, entry] of this.affinity) {
66
+ if (entry.accountId === accountId && key.startsWith(prefix)) {
67
+ this.affinity.delete(key);
68
+ }
69
+ }
70
+ }
71
+ /**
72
+ * Whether a best-effort `lastUsedAt` durable persist is DUE for this account
73
+ * (≥ `LAST_USED_PERSIST_THROTTLE_MS` since the last one). Records the persist
74
+ * time when it returns `true`, so the strategy calls `touchAccountLastUsed`
75
+ * sparingly and the request hot path does not rewrite the store every request.
76
+ */
77
+ duePersist(providerId, accountId, now = Date.now()) {
78
+ const key = scopedKey(providerId, accountId);
79
+ const last = this.lastPersist.get(key) ?? 0;
80
+ if (now - last < LAST_USED_PERSIST_THROTTLE_MS) return false;
81
+ this.lastPersist.set(key, now);
82
+ return true;
83
+ }
84
+ /** Sort by `priority` asc → effective `lastUsedAt` asc → `createdAt` asc → `[0]`. */
85
+ pickOrdered(providerId, accounts) {
86
+ const ranked = accounts.map((account) => ({
87
+ account,
88
+ priority: account.priority ?? DEFAULT_ACCOUNT_PRIORITY,
89
+ lastUsed: this.effectiveLastUsed(providerId, account),
90
+ created: parseIso(account.createdAt)
91
+ }));
92
+ ranked.sort(
93
+ (x, y) => x.priority - y.priority || x.lastUsed - y.lastUsed || x.created - y.created
94
+ );
95
+ return ranked[0].account;
96
+ }
97
+ /** The live tie-break value: the in-memory overlay when set, else the persisted
98
+ * `lastUsedAt` (0 when absent). */
99
+ effectiveLastUsed(providerId, account) {
100
+ const overlay = this.lastUsedOverlay.get(scopedKey(providerId, account.id));
101
+ return overlay ?? parseIso(account.lastUsedAt);
102
+ }
103
+ markUsed(providerId, accountId, now) {
104
+ this.lastUsedOverlay.set(scopedKey(providerId, accountId), now);
105
+ }
106
+ };
107
+
108
+ // src/SubscriptionAccountService.ts
109
+ import { getSharedAccountHealth } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
110
+
111
+ // src/scheduler/accountModelMap.ts
112
+ function canonicalModelId(value) {
113
+ const idx = value.indexOf(",");
114
+ const bare = idx >= 0 ? value.slice(idx + 1) : value;
115
+ return bare.trim().toLowerCase();
116
+ }
117
+ function accountSupportsModel(supportedModels, model) {
118
+ if (!supportedModels) return true;
119
+ const target = canonicalModelId(model);
120
+ if (Array.isArray(supportedModels)) {
121
+ return supportedModels.some((m) => canonicalModelId(m) === target);
122
+ }
123
+ return Object.keys(supportedModels).some((k) => canonicalModelId(k) === target);
124
+ }
125
+ function remapForAccount(supportedModels, model) {
126
+ if (!supportedModels || Array.isArray(supportedModels)) return model;
127
+ const target = canonicalModelId(model);
128
+ for (const [key, actual] of Object.entries(supportedModels)) {
129
+ if (canonicalModelId(key) === target) return actual;
130
+ }
131
+ return model;
132
+ }
133
+ function remapReportForAccount(supportedModels, model) {
134
+ if (!model) return void 0;
135
+ const remapped = remapForAccount(supportedModels, model);
136
+ return remapped === model ? void 0 : remapped;
137
+ }
138
+
139
+ // src/scheduler/accountSelection.ts
140
+ var ACCOUNTS_KEY = {
141
+ claude: "claudeAccounts",
142
+ codex: "codexAccounts",
143
+ gemini: "geminiAccounts",
144
+ opencodego: "opencodegoAccounts"
145
+ };
146
+ var ACTIVE_KEY = {
147
+ claude: "activeClaudeAccountId",
148
+ codex: "activeCodexAccountId",
149
+ gemini: "activeGeminiAccountId",
150
+ opencodego: "activeOpencodegoAccountId"
151
+ };
152
+ function gateSchedulable(accounts, providerId, health, now, resolvedModel, supportedModelsById) {
153
+ if (!health && !resolvedModel || accounts.length < 2) return accounts;
154
+ return accounts.map((a) => {
155
+ const healthOk = health ? health.isSchedulable(providerId, a.id, now) : true;
156
+ const modelOk = resolvedModel ? accountSupportsModel(supportedModelsById.get(a.id), resolvedModel) : true;
157
+ return { ...a, schedulable: healthOk && modelOk };
158
+ });
159
+ }
160
+ function isPoolGated(accounts, health, resolvedModel) {
161
+ return (health !== void 0 || resolvedModel !== void 0) && accounts.length >= 2;
162
+ }
163
+ function readSchedulableAccounts(config, providerId) {
164
+ const raw = config[ACCOUNTS_KEY[providerId]] ?? [];
165
+ const accounts = raw.map((a) => ({
166
+ id: a.id,
167
+ priority: a.priority,
168
+ lastUsedAt: a.lastUsedAt,
169
+ createdAt: a.createdAt
170
+ }));
171
+ const supportedModelsById = new Map(
172
+ raw.map((a) => [a.id, a.supportedModels])
173
+ );
174
+ const activeAccountId = config[ACTIVE_KEY[providerId]];
175
+ return { accounts, activeAccountId, supportedModelsById };
176
+ }
177
+ function pickByIdTarget(selector, gated, providerId, activeAccountId, sessionKey, now, healthGated) {
178
+ const selection = selector.select({ providerId, accounts: gated, activeAccountId, sessionKey, now });
179
+ if (selection && !selection.isActive) return selection.accountId;
180
+ if (selection === null && healthGated) {
181
+ const schedulable = gated.filter((a) => a.schedulable !== false);
182
+ if (schedulable.length === 1 && schedulable[0].id !== activeAccountId) return schedulable[0].id;
183
+ }
184
+ return void 0;
185
+ }
186
+ async function resolveSelectedToken(selector, tokens, providerId, sessionKey, activeGetter, ctx) {
187
+ const health = ctx?.health;
188
+ const report = ctx?.reportSelection;
189
+ const now = ctx?.now;
190
+ const resolvedModel = ctx?.resolvedModel;
191
+ if (selector && tokens.getAccessTokenForAccount) {
192
+ const config = await tokens.getFullConfig();
193
+ const { accounts, activeAccountId, supportedModelsById } = readSchedulableAccounts(config, providerId);
194
+ const gated = gateSchedulable(accounts, providerId, health, now, resolvedModel, supportedModelsById);
195
+ const poolGated = isPoolGated(accounts, health, resolvedModel);
196
+ const remapFor = (id) => remapReportForAccount(supportedModelsById.get(id), resolvedModel);
197
+ const targetId = pickByIdTarget(selector, gated, providerId, activeAccountId, sessionKey, now, poolGated);
198
+ if (targetId !== void 0) {
199
+ const byId = await tokens.getAccessTokenForAccount(providerId, targetId);
200
+ if (byId) {
201
+ maybeTouchLastUsed(selector, tokens, providerId, targetId);
202
+ report?.(targetId, false, remapFor(targetId));
203
+ return byId;
204
+ }
205
+ selector.evictAffinity(providerId, targetId);
206
+ health?.recordUpstreamOutcome(providerId, targetId, { status: 401, now });
207
+ const remaining = gated.filter((a) => a.id !== targetId);
208
+ const retryId = pickByIdTarget(selector, remaining, providerId, activeAccountId, sessionKey, now, poolGated);
209
+ if (retryId !== void 0) {
210
+ const retryToken = await tokens.getAccessTokenForAccount(providerId, retryId);
211
+ if (retryToken) {
212
+ maybeTouchLastUsed(selector, tokens, providerId, retryId);
213
+ report?.(retryId, false, remapFor(retryId));
214
+ return retryToken;
215
+ }
216
+ }
217
+ }
218
+ if (activeAccountId) report?.(activeAccountId, true, remapFor(activeAccountId));
219
+ return activeGetter();
220
+ }
221
+ return activeGetter();
222
+ }
223
+ function maybeTouchLastUsed(selector, tokens, providerId, accountId) {
224
+ if (!tokens.touchAccountLastUsed) return;
225
+ if (!selector.duePersist(providerId, accountId)) return;
226
+ void tokens.touchAccountLastUsed(providerId, accountId, (/* @__PURE__ */ new Date()).toISOString()).catch(() => {
227
+ });
228
+ }
229
+ async function refreshSelectedAccount(selector, tokens, mutex, providerId, sessionKey) {
230
+ if (!sessionKey || !selector || !tokens.refreshAccountToken) return null;
231
+ const config = await tokens.getFullConfig();
232
+ const { accounts, activeAccountId } = readSchedulableAccounts(config, providerId);
233
+ const selection = selector.select({ providerId, accounts, activeAccountId, sessionKey });
234
+ if (!selection || selection.isActive) return null;
235
+ const accountId = selection.accountId;
236
+ return mutex.run(`${providerId}:${accountId}`, async () => {
237
+ try {
238
+ return await tokens.refreshAccountToken(providerId, accountId) ?? false;
239
+ } catch (err) {
240
+ console.warn(`[accountSelection] ${providerId}:${accountId} by-id refresh failed:`, err);
241
+ return false;
242
+ }
243
+ });
244
+ }
245
+
7
246
  // src/auth/OAuthBearerAuthStrategy.ts
8
247
  var REFRESH_LEAD_MS = 5 * 6e4;
9
248
  var OAuthBearerAuthStrategy = class {
10
- constructor(providerId, tokens, mutex) {
249
+ constructor(providerId, tokens, mutex, selector, health) {
11
250
  this.tokens = tokens;
12
251
  this.mutex = mutex;
252
+ this.selector = selector;
253
+ this.health = health;
13
254
  this.providerId = providerId;
14
255
  }
15
256
  tokens;
16
257
  mutex;
258
+ selector;
259
+ health;
17
260
  kind = "oauth-bearer";
18
261
  providerId;
19
- async applyHeaders(headers, _hints) {
20
- const token = await this.resolveAccessToken();
262
+ async applyHeaders(headers, hints) {
263
+ const token = await resolveSelectedToken(
264
+ this.selector,
265
+ this.tokens,
266
+ this.providerId,
267
+ hints?.sessionKey,
268
+ () => this.resolveAccessToken(),
269
+ { health: this.health, reportSelection: hints?.reportSelection, resolvedModel: hints?.resolvedModel }
270
+ );
21
271
  if (!token) {
22
272
  return;
23
273
  }
24
274
  headers["Authorization"] = `Bearer ${token}`;
25
275
  }
26
- async onUnauthorized() {
276
+ async onUnauthorized(sessionKey) {
277
+ const byId = await refreshSelectedAccount(this.selector, this.tokens, this.mutex, this.providerId, sessionKey);
278
+ if (byId !== null) return byId;
27
279
  return this.mutex.run(`${this.providerId}:refresh`, async () => {
28
280
  try {
29
281
  return this.providerId === "codex" ? await this.tokens.refreshCodexToken() : await this.tokens.refreshGeminiToken();
@@ -72,20 +324,33 @@ var OAuthBearerAuthStrategy = class {
72
324
 
73
325
  // src/auth/PassThroughAuthStrategy.ts
74
326
  var PassThroughAuthStrategy = class {
75
- constructor(tokens, mutex) {
327
+ constructor(tokens, mutex, selector, health) {
76
328
  this.tokens = tokens;
77
329
  this.mutex = mutex;
330
+ this.selector = selector;
331
+ this.health = health;
78
332
  }
79
333
  tokens;
80
334
  mutex;
335
+ selector;
336
+ health;
81
337
  kind = "pass-through";
82
338
  providerId = "claude";
83
- async applyHeaders(headers, _hints) {
84
- const token = await this.tokens.getValidClaudeAccessToken();
339
+ async applyHeaders(headers, hints) {
340
+ const token = await resolveSelectedToken(
341
+ this.selector,
342
+ this.tokens,
343
+ "claude",
344
+ hints?.sessionKey,
345
+ () => this.tokens.getValidClaudeAccessToken(),
346
+ { health: this.health, reportSelection: hints?.reportSelection, resolvedModel: hints?.resolvedModel }
347
+ );
85
348
  if (!token) return;
86
349
  headers["Authorization"] = `Bearer ${token}`;
87
350
  }
88
- async onUnauthorized() {
351
+ async onUnauthorized(sessionKey) {
352
+ const byId = await refreshSelectedAccount(this.selector, this.tokens, this.mutex, "claude", sessionKey);
353
+ if (byId !== null) return byId;
89
354
  return this.mutex.run("claude:refresh", async () => {
90
355
  try {
91
356
  return await this.tokens.refreshClaudeToken();
@@ -139,14 +404,25 @@ var RefreshMutex = class {
139
404
  // src/auth/StaticBearerAuthStrategy.ts
140
405
  var ANTHROPIC_SHAPE_PATH = "/v1/messages";
141
406
  var StaticBearerAuthStrategy = class {
142
- constructor(tokens) {
407
+ constructor(tokens, selector, health) {
143
408
  this.tokens = tokens;
409
+ this.selector = selector;
410
+ this.health = health;
144
411
  }
145
412
  tokens;
413
+ selector;
414
+ health;
146
415
  kind = "static-bearer";
147
416
  providerId = "opencodego";
148
417
  async applyHeaders(headers, hints) {
149
- const key = await this.tokens.getValidOpenCodeGoApiKey();
418
+ const key = await resolveSelectedToken(
419
+ this.selector,
420
+ this.tokens,
421
+ "opencodego",
422
+ hints?.sessionKey,
423
+ () => this.tokens.getValidOpenCodeGoApiKey(),
424
+ { health: this.health, reportSelection: hints?.reportSelection, resolvedModel: hints?.resolvedModel }
425
+ );
150
426
  if (!key) {
151
427
  return;
152
428
  }
@@ -155,7 +431,7 @@ var StaticBearerAuthStrategy = class {
155
431
  headers["x-api-key"] = key;
156
432
  }
157
433
  }
158
- async onUnauthorized() {
434
+ async onUnauthorized(_sessionKey) {
159
435
  return false;
160
436
  }
161
437
  async describeStatus() {
@@ -180,13 +456,17 @@ var DISPLAY_NAMES = {
180
456
  };
181
457
  var SubscriptionAccountService = class {
182
458
  mutex = new RefreshMutex();
459
+ /** ONE account-pool scheduler (subscription-account-scheduling) shared by all
460
+ * four strategies so they share the affinity map + the `lastUsedAt` overlay. */
461
+ selector = new SubscriptionAccountSelector();
183
462
  strategies;
184
463
  constructor(tokens) {
464
+ const health = getSharedAccountHealth();
185
465
  this.strategies = /* @__PURE__ */ new Map([
186
- ["claude", new PassThroughAuthStrategy(tokens, this.mutex)],
187
- ["codex", new OAuthBearerAuthStrategy("codex", tokens, this.mutex)],
188
- ["gemini", new OAuthBearerAuthStrategy("gemini", tokens, this.mutex)],
189
- ["opencodego", new StaticBearerAuthStrategy(tokens)]
466
+ ["claude", new PassThroughAuthStrategy(tokens, this.mutex, this.selector, health)],
467
+ ["codex", new OAuthBearerAuthStrategy("codex", tokens, this.mutex, this.selector, health)],
468
+ ["gemini", new OAuthBearerAuthStrategy("gemini", tokens, this.mutex, this.selector, health)],
469
+ ["opencodego", new StaticBearerAuthStrategy(tokens, this.selector, health)]
190
470
  ]);
191
471
  }
192
472
  /** Returns the strategy bound to a subscription provider, or `null` for unknown ids. */
@@ -830,7 +1110,11 @@ function getSubscriptionProviderRegistry() {
830
1110
 
831
1111
  // src/SubscriptionDispatcher.ts
832
1112
  import { getGeminiCodeAssistResolver } from "@omnicross/core/ports/gemini-code-assist-resolver";
833
- import { collectMatchText } from "@omnicross/core/provider-proxy/matchText";
1113
+ import {
1114
+ getSharedAccountHealth as getSharedAccountHealth2,
1115
+ resolveResetSeconds
1116
+ } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
1117
+ import { collectMatchText, deriveSubscriptionSessionKey } from "@omnicross/core/provider-proxy/matchText";
834
1118
  import { serializeError } from "@omnicross/core/serializeError";
835
1119
 
836
1120
  // src/opencodego/token-count.ts
@@ -858,6 +1142,7 @@ var SubscriptionDispatcher = class {
858
1142
  * resolution and probe-detection.
859
1143
  */
860
1144
  async dispatch(req) {
1145
+ const sessionKey = deriveSubscriptionSessionKey(req.anthropicBody);
861
1146
  const ocConfig = this.profile.providerId === "opencodego" ? await this.getOpenCodeGoConfig() : void 0;
862
1147
  let scenario = "default";
863
1148
  let resolvedModel = req.fallbackModel;
@@ -876,40 +1161,52 @@ var SubscriptionDispatcher = class {
876
1161
  provider: resolveOpenCodeGoHalf(resolvedModel, ocConfig),
877
1162
  modelId: resolvedModel
878
1163
  }) === "anthropic") {
879
- await this.dispatchAnthropicShapeBypass(req, upstreamUrl, resolvedModel, scenario, ocConfig);
1164
+ await this.dispatchAnthropicShapeBypass(req, upstreamUrl, resolvedModel, scenario, ocConfig, sessionKey);
880
1165
  return;
881
1166
  }
882
- await this.dispatchTransformerChain(req, upstreamUrl, resolvedModel, scenario, ocConfig);
1167
+ await this.dispatchTransformerChain(req, upstreamUrl, resolvedModel, scenario, ocConfig, sessionKey);
883
1168
  }
884
1169
  /** Bypass path for OpenCodeGo MiniMax models — forwards Anthropic body verbatim. */
885
- async dispatchAnthropicShapeBypass(req, upstreamUrl, resolvedModel, scenario, ocConfig) {
1170
+ async dispatchAnthropicShapeBypass(req, upstreamUrl, resolvedModel, scenario, ocConfig, sessionKey) {
886
1171
  const gate = this.gatePrimaryModel(resolvedModel, scenario, ocConfig);
887
1172
  const attempted = gate.attempted;
888
1173
  let currentModel = gate.firstModel;
1174
+ let usedAccountId;
1175
+ const reportSelection = (accountId) => {
1176
+ usedAccountId = accountId;
1177
+ };
889
1178
  while (attempted.length < MAX_FALLBACK_ATTEMPTS_LOCAL) {
890
1179
  attempted.push(currentModel);
891
1180
  req.anthropicBody.model = currentModel;
892
1181
  const headers = { "content-type": "application/json" };
893
- await this.applyHeadersWithRetry(headers, { upstreamUrl, resolvedModel: currentModel });
1182
+ await this.applyHeadersWithRetry(headers, { upstreamUrl, resolvedModel: currentModel, sessionKey, reportSelection });
894
1183
  console.info(
895
1184
  `[AgentProxy:subscription] REQ#${req.reqId} | opencodego anthropic-shape -> ${upstreamUrl} model=${currentModel} attempt=${attempted.length}`
896
1185
  );
897
1186
  try {
898
1187
  const upstream = await this.hooks.fetchWithRetry(upstreamUrl, headers, req.anthropicBody, currentModel);
899
1188
  this.profile.recordModelOutcome?.(currentModel, true);
1189
+ this.markHealth(usedAccountId, 200);
900
1190
  await this.hooks.writeProxyResponse(req.res, upstream, req.isStream, req.reqId);
901
1191
  return;
902
1192
  } catch (err) {
903
- const handled = await this.maybeRetryAfterError(err, headers, req, currentModel);
1193
+ const handled = await this.maybeRetryAfterError(err, headers, req, currentModel, sessionKey);
904
1194
  if (handled.retryOnce) {
905
- const upstream = await this.hooks.fetchWithRetry(upstreamUrl, handled.headers, req.anthropicBody, currentModel);
906
- this.profile.recordModelOutcome?.(currentModel, true);
907
- await this.hooks.writeProxyResponse(req.res, upstream, req.isStream, req.reqId);
908
- return;
1195
+ try {
1196
+ const upstream = await this.hooks.fetchWithRetry(upstreamUrl, handled.headers, req.anthropicBody, currentModel);
1197
+ this.profile.recordModelOutcome?.(currentModel, true);
1198
+ this.markHealth(usedAccountId, 200);
1199
+ await this.hooks.writeProxyResponse(req.res, upstream, req.isStream, req.reqId);
1200
+ return;
1201
+ } catch (retryErr) {
1202
+ this.markHealth(usedAccountId, errStatus(retryErr), retryErr);
1203
+ throw retryErr;
1204
+ }
909
1205
  }
910
1206
  if (caughtErrorBreakerOutcome(err) === "failure") {
911
1207
  this.profile.recordModelOutcome?.(currentModel, false);
912
1208
  }
1209
+ this.markHealth(usedAccountId, errStatus(err), err);
913
1210
  const next = this.profile.nextFallback?.(scenario, attempted, ocConfig);
914
1211
  if (!next || attempted.length >= MAX_FALLBACK_ATTEMPTS_LOCAL) {
915
1212
  throw err;
@@ -923,7 +1220,7 @@ var SubscriptionDispatcher = class {
923
1220
  }
924
1221
  }
925
1222
  /** Standard subscription transformer chain — Codex/Gemini/OpenCodeGo OpenAI-shape. */
926
- async dispatchTransformerChain(req, upstreamUrl, resolvedModel, scenario, ocConfig) {
1223
+ async dispatchTransformerChain(req, upstreamUrl, resolvedModel, scenario, ocConfig, sessionKey) {
927
1224
  const providerNames = this.profile.resolveProviderTransformerNames?.(resolvedModel, ocConfig) ?? this.profile.providerTransformerNames;
928
1225
  const providerTransformers = this.resolveTransformers(providerNames);
929
1226
  const modelTransformers = this.resolveTransformers(this.profile.modelTransformerNames);
@@ -940,6 +1237,10 @@ var SubscriptionDispatcher = class {
940
1237
  const gate = this.gatePrimaryModel(resolvedModel, scenario, ocConfig);
941
1238
  const attempted = gate.attempted;
942
1239
  let currentModel = gate.firstModel;
1240
+ let usedAccountId;
1241
+ const reportSelection = (accountId) => {
1242
+ usedAccountId = accountId;
1243
+ };
943
1244
  while (attempted.length < MAX_FALLBACK_ATTEMPTS_LOCAL) {
944
1245
  attempted.push(currentModel);
945
1246
  req.anthropicBody.model = currentModel;
@@ -954,7 +1255,7 @@ var SubscriptionDispatcher = class {
954
1255
  ...config.headers
955
1256
  };
956
1257
  stripAuthHeaders(headers);
957
- await this.applyHeadersWithRetry(headers, { upstreamUrl, resolvedModel: currentModel });
1258
+ await this.applyHeadersWithRetry(headers, { upstreamUrl, resolvedModel: currentModel, sessionKey, reportSelection });
958
1259
  const fetchUrl = usesResponsesChain(providerNames) ? upstreamUrl : resolveConfigUrl(config.url) ?? upstreamUrl;
959
1260
  console.info(
960
1261
  `[AgentProxy:subscription] REQ#${req.reqId} | provider=${this.profile.providerId} -> ${fetchUrl} model=${currentModel} attempt=${attempted.length}`
@@ -962,6 +1263,7 @@ var SubscriptionDispatcher = class {
962
1263
  try {
963
1264
  const upstream = await this.hooks.fetchWithRetry(fetchUrl, headers, requestBody, currentModel);
964
1265
  this.profile.recordModelOutcome?.(currentModel, true);
1266
+ this.markHealth(usedAccountId, 200);
965
1267
  const finalResponse = await this.hooks.executor.executeResponseChain(
966
1268
  requestBody,
967
1269
  upstream,
@@ -972,23 +1274,30 @@ var SubscriptionDispatcher = class {
972
1274
  await this.hooks.writeProxyResponse(req.res, finalResponse, req.isStream, req.reqId);
973
1275
  return;
974
1276
  } catch (err) {
975
- const handled = await this.maybeRetryAfterError(err, headers, req, currentModel);
1277
+ const handled = await this.maybeRetryAfterError(err, headers, req, currentModel, sessionKey);
976
1278
  if (handled.retryOnce) {
977
- const upstream = await this.hooks.fetchWithRetry(fetchUrl, handled.headers, requestBody, currentModel);
978
- this.profile.recordModelOutcome?.(currentModel, true);
979
- const finalResponse = await this.hooks.executor.executeResponseChain(
980
- requestBody,
981
- upstream,
982
- transformerProvider,
983
- { providerTransformers, modelTransformers },
984
- { endpointTransformer: this.hooks.endpointTransformer }
985
- );
986
- await this.hooks.writeProxyResponse(req.res, finalResponse, req.isStream, req.reqId);
987
- return;
1279
+ try {
1280
+ const upstream = await this.hooks.fetchWithRetry(fetchUrl, handled.headers, requestBody, currentModel);
1281
+ this.profile.recordModelOutcome?.(currentModel, true);
1282
+ this.markHealth(usedAccountId, 200);
1283
+ const finalResponse = await this.hooks.executor.executeResponseChain(
1284
+ requestBody,
1285
+ upstream,
1286
+ transformerProvider,
1287
+ { providerTransformers, modelTransformers },
1288
+ { endpointTransformer: this.hooks.endpointTransformer }
1289
+ );
1290
+ await this.hooks.writeProxyResponse(req.res, finalResponse, req.isStream, req.reqId);
1291
+ return;
1292
+ } catch (retryErr) {
1293
+ this.markHealth(usedAccountId, errStatus(retryErr), retryErr);
1294
+ throw retryErr;
1295
+ }
988
1296
  }
989
1297
  if (caughtErrorBreakerOutcome(err) === "failure") {
990
1298
  this.profile.recordModelOutcome?.(currentModel, false);
991
1299
  }
1300
+ this.markHealth(usedAccountId, errStatus(err), err);
992
1301
  const next = this.profile.nextFallback?.(scenario, attempted, ocConfig);
993
1302
  if (!next || attempted.length >= MAX_FALLBACK_ATTEMPTS_LOCAL) {
994
1303
  throw err;
@@ -1036,12 +1345,12 @@ var SubscriptionDispatcher = class {
1036
1345
  * Returns `{ retryOnce: true, headers }` when the strategy refreshed
1037
1346
  * successfully (caller should retry once); otherwise re-throws.
1038
1347
  */
1039
- async maybeRetryAfterError(err, headers, req, resolvedModel) {
1348
+ async maybeRetryAfterError(err, headers, req, resolvedModel, sessionKey) {
1040
1349
  const status = err?.status ?? 0;
1041
1350
  if (status !== 401) {
1042
1351
  return { retryOnce: false, headers };
1043
1352
  }
1044
- const refreshed = await this.profile.authStrategy.onUnauthorized();
1353
+ const refreshed = await this.profile.authStrategy.onUnauthorized(sessionKey);
1045
1354
  if (!refreshed) {
1046
1355
  console.warn(
1047
1356
  `[AgentProxy:subscription] REQ#${req.reqId} | 401 not recoverable for provider=${this.profile.providerId}`
@@ -1050,7 +1359,7 @@ var SubscriptionDispatcher = class {
1050
1359
  }
1051
1360
  const fresh = { ...headers };
1052
1361
  stripAuthHeaders(fresh);
1053
- await this.applyHeadersWithRetry(fresh, { upstreamUrl: "", resolvedModel });
1362
+ await this.applyHeadersWithRetry(fresh, { upstreamUrl: "", resolvedModel, sessionKey });
1054
1363
  return { retryOnce: true, headers: fresh };
1055
1364
  }
1056
1365
  async applyHeadersWithRetry(headers, hints) {
@@ -1060,6 +1369,28 @@ var SubscriptionDispatcher = class {
1060
1369
  console.warn("[AgentProxy:subscription] authStrategy.applyHeaders threw:", serializeError(err));
1061
1370
  }
1062
1371
  }
1372
+ /**
1373
+ * Mark the served account's health against ONE attempt's outcome
1374
+ * (subscription-account-health, task 3.4). No-op when no account was reported
1375
+ * (non-pooled / single-account) or on a session-cancel (status 0). On a caught
1376
+ * error `err` is passed so the 429-reset / 403-ban drivers are read STRUCTURALLY
1377
+ * from the error's upstream `headers` + `bodyText` (the `fetchWithRetry`
1378
+ * contract) — so daemon-path 429 cooldown + ban blocking function for
1379
+ * multi-account codex/gemini/opencodego pools; absent headers ⇒ a bare-429
1380
+ * (unmarked, lazy re-probe). Success (2xx) clears; 401/5xx/thrown → transient.
1381
+ */
1382
+ markHealth(accountId, status, err) {
1383
+ if (accountId === void 0 || status === 0) return;
1384
+ const headers = err !== void 0 ? errHeaders(err) : void 0;
1385
+ const reset = headers ? resolveResetSeconds(this.profile.providerId, headers) : { resetHeaderSeconds: null, retryAfterSeconds: null };
1386
+ const bodyText = status === 403 && err !== void 0 ? errBodyText(err) : void 0;
1387
+ getSharedAccountHealth2().recordUpstreamOutcome(this.profile.providerId, accountId, {
1388
+ status,
1389
+ resetHeaderSeconds: reset.resetHeaderSeconds,
1390
+ retryAfterSeconds: reset.retryAfterSeconds,
1391
+ bodyText
1392
+ });
1393
+ }
1063
1394
  /**
1064
1395
  * Resolve the Code Assist project for the gemini subscription profile. Pulls
1065
1396
  * the Bearer the bound `AuthStrategy` would inject (so the strategy stays the
@@ -1136,6 +1467,22 @@ var SubscriptionDispatcher = class {
1136
1467
  }
1137
1468
  };
1138
1469
  var MAX_FALLBACK_ATTEMPTS_LOCAL = 3;
1470
+ function errStatus(err) {
1471
+ const status = err?.status;
1472
+ return typeof status === "number" ? status : null;
1473
+ }
1474
+ function errHeaders(err) {
1475
+ const h = err?.headers;
1476
+ if (!h) return void 0;
1477
+ if (typeof h.get === "function") return h;
1478
+ if (typeof h === "object") return h;
1479
+ return void 0;
1480
+ }
1481
+ function errBodyText(err) {
1482
+ const e = err;
1483
+ const raw = typeof e?.bodyText === "string" ? e.bodyText : typeof e?.body === "string" ? e.body : void 0;
1484
+ return raw?.slice(0, 2048);
1485
+ }
1139
1486
  function caughtErrorBreakerOutcome(err) {
1140
1487
  const status = err?.status;
1141
1488
  if (typeof status !== "number") return "failure";
@@ -1161,6 +1508,10 @@ function stripAuthHeaders(headers) {
1161
1508
  delete headers["X-Goog-Api-Key"];
1162
1509
  }
1163
1510
  export {
1511
+ DEFAULT_ACCOUNT_PRIORITY,
1512
+ LAST_USED_PERSIST_THROTTLE_MS,
1513
+ SESSION_AFFINITY_TTL_MS,
1514
+ SubscriptionAccountSelector,
1164
1515
  SubscriptionAccountService,
1165
1516
  SubscriptionDispatcher,
1166
1517
  SubscriptionProviderRegistry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnicross/subscriptions",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Omnicross subscription-as-provider auth strategies, OAuth flows, and the OpenCodeGo scenario dispatcher.",
5
5
  "license": "MIT",
6
6
  "author": "Sayo (https://github.com/Dumoedss)",