@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.js CHANGED
@@ -4,26 +4,290 @@ 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 preferredId = ctx?.preferredAccountId;
198
+ if (preferredId) {
199
+ const preferred = gated.find((a) => a.id === preferredId);
200
+ if (preferred && preferred.schedulable !== false) {
201
+ const preferredToken = await tokens.getAccessTokenForAccount(providerId, preferredId);
202
+ if (preferredToken) {
203
+ maybeTouchLastUsed(selector, tokens, providerId, preferredId);
204
+ report?.(preferredId, preferredId === activeAccountId, remapFor(preferredId));
205
+ return preferredToken;
206
+ }
207
+ }
208
+ }
209
+ const targetId = pickByIdTarget(selector, gated, providerId, activeAccountId, sessionKey, now, poolGated);
210
+ if (targetId !== void 0) {
211
+ const byId = await tokens.getAccessTokenForAccount(providerId, targetId);
212
+ if (byId) {
213
+ maybeTouchLastUsed(selector, tokens, providerId, targetId);
214
+ report?.(targetId, false, remapFor(targetId));
215
+ return byId;
216
+ }
217
+ selector.evictAffinity(providerId, targetId);
218
+ health?.recordUpstreamOutcome(providerId, targetId, { status: 401, now });
219
+ const remaining = gated.filter((a) => a.id !== targetId);
220
+ const retryId = pickByIdTarget(selector, remaining, providerId, activeAccountId, sessionKey, now, poolGated);
221
+ if (retryId !== void 0) {
222
+ const retryToken = await tokens.getAccessTokenForAccount(providerId, retryId);
223
+ if (retryToken) {
224
+ maybeTouchLastUsed(selector, tokens, providerId, retryId);
225
+ report?.(retryId, false, remapFor(retryId));
226
+ return retryToken;
227
+ }
228
+ }
229
+ }
230
+ if (activeAccountId) report?.(activeAccountId, true, remapFor(activeAccountId));
231
+ return activeGetter();
232
+ }
233
+ return activeGetter();
234
+ }
235
+ function maybeTouchLastUsed(selector, tokens, providerId, accountId) {
236
+ if (!tokens.touchAccountLastUsed) return;
237
+ if (!selector.duePersist(providerId, accountId)) return;
238
+ void tokens.touchAccountLastUsed(providerId, accountId, (/* @__PURE__ */ new Date()).toISOString()).catch(() => {
239
+ });
240
+ }
241
+ async function refreshSelectedAccount(selector, tokens, mutex, providerId, sessionKey) {
242
+ if (!sessionKey || !selector || !tokens.refreshAccountToken) return null;
243
+ const config = await tokens.getFullConfig();
244
+ const { accounts, activeAccountId } = readSchedulableAccounts(config, providerId);
245
+ const selection = selector.select({ providerId, accounts, activeAccountId, sessionKey });
246
+ if (!selection || selection.isActive) return null;
247
+ const accountId = selection.accountId;
248
+ return mutex.run(`${providerId}:${accountId}`, async () => {
249
+ try {
250
+ return await tokens.refreshAccountToken(providerId, accountId) ?? false;
251
+ } catch (err) {
252
+ console.warn(`[accountSelection] ${providerId}:${accountId} by-id refresh failed:`, err);
253
+ return false;
254
+ }
255
+ });
256
+ }
257
+
7
258
  // src/auth/OAuthBearerAuthStrategy.ts
8
259
  var REFRESH_LEAD_MS = 5 * 6e4;
9
260
  var OAuthBearerAuthStrategy = class {
10
- constructor(providerId, tokens, mutex) {
261
+ constructor(providerId, tokens, mutex, selector, health) {
11
262
  this.tokens = tokens;
12
263
  this.mutex = mutex;
264
+ this.selector = selector;
265
+ this.health = health;
13
266
  this.providerId = providerId;
14
267
  }
15
268
  tokens;
16
269
  mutex;
270
+ selector;
271
+ health;
17
272
  kind = "oauth-bearer";
18
273
  providerId;
19
- async applyHeaders(headers, _hints) {
20
- const token = await this.resolveAccessToken();
274
+ async applyHeaders(headers, hints) {
275
+ const token = await resolveSelectedToken(
276
+ this.selector,
277
+ this.tokens,
278
+ this.providerId,
279
+ hints?.sessionKey,
280
+ () => this.resolveAccessToken(),
281
+ { health: this.health, reportSelection: hints?.reportSelection, resolvedModel: hints?.resolvedModel, preferredAccountId: hints?.preferredAccountId }
282
+ );
21
283
  if (!token) {
22
284
  return;
23
285
  }
24
286
  headers["Authorization"] = `Bearer ${token}`;
25
287
  }
26
- async onUnauthorized() {
288
+ async onUnauthorized(sessionKey) {
289
+ const byId = await refreshSelectedAccount(this.selector, this.tokens, this.mutex, this.providerId, sessionKey);
290
+ if (byId !== null) return byId;
27
291
  return this.mutex.run(`${this.providerId}:refresh`, async () => {
28
292
  try {
29
293
  return this.providerId === "codex" ? await this.tokens.refreshCodexToken() : await this.tokens.refreshGeminiToken();
@@ -72,20 +336,33 @@ var OAuthBearerAuthStrategy = class {
72
336
 
73
337
  // src/auth/PassThroughAuthStrategy.ts
74
338
  var PassThroughAuthStrategy = class {
75
- constructor(tokens, mutex) {
339
+ constructor(tokens, mutex, selector, health) {
76
340
  this.tokens = tokens;
77
341
  this.mutex = mutex;
342
+ this.selector = selector;
343
+ this.health = health;
78
344
  }
79
345
  tokens;
80
346
  mutex;
347
+ selector;
348
+ health;
81
349
  kind = "pass-through";
82
350
  providerId = "claude";
83
- async applyHeaders(headers, _hints) {
84
- const token = await this.tokens.getValidClaudeAccessToken();
351
+ async applyHeaders(headers, hints) {
352
+ const token = await resolveSelectedToken(
353
+ this.selector,
354
+ this.tokens,
355
+ "claude",
356
+ hints?.sessionKey,
357
+ () => this.tokens.getValidClaudeAccessToken(),
358
+ { health: this.health, reportSelection: hints?.reportSelection, resolvedModel: hints?.resolvedModel, preferredAccountId: hints?.preferredAccountId }
359
+ );
85
360
  if (!token) return;
86
361
  headers["Authorization"] = `Bearer ${token}`;
87
362
  }
88
- async onUnauthorized() {
363
+ async onUnauthorized(sessionKey) {
364
+ const byId = await refreshSelectedAccount(this.selector, this.tokens, this.mutex, "claude", sessionKey);
365
+ if (byId !== null) return byId;
89
366
  return this.mutex.run("claude:refresh", async () => {
90
367
  try {
91
368
  return await this.tokens.refreshClaudeToken();
@@ -139,14 +416,25 @@ var RefreshMutex = class {
139
416
  // src/auth/StaticBearerAuthStrategy.ts
140
417
  var ANTHROPIC_SHAPE_PATH = "/v1/messages";
141
418
  var StaticBearerAuthStrategy = class {
142
- constructor(tokens) {
419
+ constructor(tokens, selector, health) {
143
420
  this.tokens = tokens;
421
+ this.selector = selector;
422
+ this.health = health;
144
423
  }
145
424
  tokens;
425
+ selector;
426
+ health;
146
427
  kind = "static-bearer";
147
428
  providerId = "opencodego";
148
429
  async applyHeaders(headers, hints) {
149
- const key = await this.tokens.getValidOpenCodeGoApiKey();
430
+ const key = await resolveSelectedToken(
431
+ this.selector,
432
+ this.tokens,
433
+ "opencodego",
434
+ hints?.sessionKey,
435
+ () => this.tokens.getValidOpenCodeGoApiKey(),
436
+ { health: this.health, reportSelection: hints?.reportSelection, resolvedModel: hints?.resolvedModel, preferredAccountId: hints?.preferredAccountId }
437
+ );
150
438
  if (!key) {
151
439
  return;
152
440
  }
@@ -155,7 +443,7 @@ var StaticBearerAuthStrategy = class {
155
443
  headers["x-api-key"] = key;
156
444
  }
157
445
  }
158
- async onUnauthorized() {
446
+ async onUnauthorized(_sessionKey) {
159
447
  return false;
160
448
  }
161
449
  async describeStatus() {
@@ -180,13 +468,17 @@ var DISPLAY_NAMES = {
180
468
  };
181
469
  var SubscriptionAccountService = class {
182
470
  mutex = new RefreshMutex();
471
+ /** ONE account-pool scheduler (subscription-account-scheduling) shared by all
472
+ * four strategies so they share the affinity map + the `lastUsedAt` overlay. */
473
+ selector = new SubscriptionAccountSelector();
183
474
  strategies;
184
475
  constructor(tokens) {
476
+ const health = getSharedAccountHealth();
185
477
  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)]
478
+ ["claude", new PassThroughAuthStrategy(tokens, this.mutex, this.selector, health)],
479
+ ["codex", new OAuthBearerAuthStrategy("codex", tokens, this.mutex, this.selector, health)],
480
+ ["gemini", new OAuthBearerAuthStrategy("gemini", tokens, this.mutex, this.selector, health)],
481
+ ["opencodego", new StaticBearerAuthStrategy(tokens, this.selector, health)]
190
482
  ]);
191
483
  }
192
484
  /** Returns the strategy bound to a subscription provider, or `null` for unknown ids. */
@@ -830,7 +1122,11 @@ function getSubscriptionProviderRegistry() {
830
1122
 
831
1123
  // src/SubscriptionDispatcher.ts
832
1124
  import { getGeminiCodeAssistResolver } from "@omnicross/core/ports/gemini-code-assist-resolver";
833
- import { collectMatchText } from "@omnicross/core/provider-proxy/matchText";
1125
+ import {
1126
+ getSharedAccountHealth as getSharedAccountHealth2,
1127
+ resolveResetSeconds
1128
+ } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
1129
+ import { collectMatchText, deriveSubscriptionSessionKey } from "@omnicross/core/provider-proxy/matchText";
834
1130
  import { serializeError } from "@omnicross/core/serializeError";
835
1131
 
836
1132
  // src/opencodego/token-count.ts
@@ -858,6 +1154,7 @@ var SubscriptionDispatcher = class {
858
1154
  * resolution and probe-detection.
859
1155
  */
860
1156
  async dispatch(req) {
1157
+ const sessionKey = deriveSubscriptionSessionKey(req.anthropicBody);
861
1158
  const ocConfig = this.profile.providerId === "opencodego" ? await this.getOpenCodeGoConfig() : void 0;
862
1159
  let scenario = "default";
863
1160
  let resolvedModel = req.fallbackModel;
@@ -876,40 +1173,52 @@ var SubscriptionDispatcher = class {
876
1173
  provider: resolveOpenCodeGoHalf(resolvedModel, ocConfig),
877
1174
  modelId: resolvedModel
878
1175
  }) === "anthropic") {
879
- await this.dispatchAnthropicShapeBypass(req, upstreamUrl, resolvedModel, scenario, ocConfig);
1176
+ await this.dispatchAnthropicShapeBypass(req, upstreamUrl, resolvedModel, scenario, ocConfig, sessionKey);
880
1177
  return;
881
1178
  }
882
- await this.dispatchTransformerChain(req, upstreamUrl, resolvedModel, scenario, ocConfig);
1179
+ await this.dispatchTransformerChain(req, upstreamUrl, resolvedModel, scenario, ocConfig, sessionKey);
883
1180
  }
884
1181
  /** Bypass path for OpenCodeGo MiniMax models — forwards Anthropic body verbatim. */
885
- async dispatchAnthropicShapeBypass(req, upstreamUrl, resolvedModel, scenario, ocConfig) {
1182
+ async dispatchAnthropicShapeBypass(req, upstreamUrl, resolvedModel, scenario, ocConfig, sessionKey) {
886
1183
  const gate = this.gatePrimaryModel(resolvedModel, scenario, ocConfig);
887
1184
  const attempted = gate.attempted;
888
1185
  let currentModel = gate.firstModel;
1186
+ let usedAccountId;
1187
+ const reportSelection = (accountId) => {
1188
+ usedAccountId = accountId;
1189
+ };
889
1190
  while (attempted.length < MAX_FALLBACK_ATTEMPTS_LOCAL) {
890
1191
  attempted.push(currentModel);
891
1192
  req.anthropicBody.model = currentModel;
892
1193
  const headers = { "content-type": "application/json" };
893
- await this.applyHeadersWithRetry(headers, { upstreamUrl, resolvedModel: currentModel });
1194
+ await this.applyHeadersWithRetry(headers, { upstreamUrl, resolvedModel: currentModel, sessionKey, reportSelection });
894
1195
  console.info(
895
1196
  `[AgentProxy:subscription] REQ#${req.reqId} | opencodego anthropic-shape -> ${upstreamUrl} model=${currentModel} attempt=${attempted.length}`
896
1197
  );
897
1198
  try {
898
1199
  const upstream = await this.hooks.fetchWithRetry(upstreamUrl, headers, req.anthropicBody, currentModel);
899
1200
  this.profile.recordModelOutcome?.(currentModel, true);
1201
+ this.markHealth(usedAccountId, 200);
900
1202
  await this.hooks.writeProxyResponse(req.res, upstream, req.isStream, req.reqId);
901
1203
  return;
902
1204
  } catch (err) {
903
- const handled = await this.maybeRetryAfterError(err, headers, req, currentModel);
1205
+ const handled = await this.maybeRetryAfterError(err, headers, req, currentModel, sessionKey);
904
1206
  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;
1207
+ try {
1208
+ const upstream = await this.hooks.fetchWithRetry(upstreamUrl, handled.headers, req.anthropicBody, currentModel);
1209
+ this.profile.recordModelOutcome?.(currentModel, true);
1210
+ this.markHealth(usedAccountId, 200);
1211
+ await this.hooks.writeProxyResponse(req.res, upstream, req.isStream, req.reqId);
1212
+ return;
1213
+ } catch (retryErr) {
1214
+ this.markHealth(usedAccountId, errStatus(retryErr), retryErr);
1215
+ throw retryErr;
1216
+ }
909
1217
  }
910
1218
  if (caughtErrorBreakerOutcome(err) === "failure") {
911
1219
  this.profile.recordModelOutcome?.(currentModel, false);
912
1220
  }
1221
+ this.markHealth(usedAccountId, errStatus(err), err);
913
1222
  const next = this.profile.nextFallback?.(scenario, attempted, ocConfig);
914
1223
  if (!next || attempted.length >= MAX_FALLBACK_ATTEMPTS_LOCAL) {
915
1224
  throw err;
@@ -923,7 +1232,7 @@ var SubscriptionDispatcher = class {
923
1232
  }
924
1233
  }
925
1234
  /** Standard subscription transformer chain — Codex/Gemini/OpenCodeGo OpenAI-shape. */
926
- async dispatchTransformerChain(req, upstreamUrl, resolvedModel, scenario, ocConfig) {
1235
+ async dispatchTransformerChain(req, upstreamUrl, resolvedModel, scenario, ocConfig, sessionKey) {
927
1236
  const providerNames = this.profile.resolveProviderTransformerNames?.(resolvedModel, ocConfig) ?? this.profile.providerTransformerNames;
928
1237
  const providerTransformers = this.resolveTransformers(providerNames);
929
1238
  const modelTransformers = this.resolveTransformers(this.profile.modelTransformerNames);
@@ -940,6 +1249,10 @@ var SubscriptionDispatcher = class {
940
1249
  const gate = this.gatePrimaryModel(resolvedModel, scenario, ocConfig);
941
1250
  const attempted = gate.attempted;
942
1251
  let currentModel = gate.firstModel;
1252
+ let usedAccountId;
1253
+ const reportSelection = (accountId) => {
1254
+ usedAccountId = accountId;
1255
+ };
943
1256
  while (attempted.length < MAX_FALLBACK_ATTEMPTS_LOCAL) {
944
1257
  attempted.push(currentModel);
945
1258
  req.anthropicBody.model = currentModel;
@@ -954,7 +1267,7 @@ var SubscriptionDispatcher = class {
954
1267
  ...config.headers
955
1268
  };
956
1269
  stripAuthHeaders(headers);
957
- await this.applyHeadersWithRetry(headers, { upstreamUrl, resolvedModel: currentModel });
1270
+ await this.applyHeadersWithRetry(headers, { upstreamUrl, resolvedModel: currentModel, sessionKey, reportSelection });
958
1271
  const fetchUrl = usesResponsesChain(providerNames) ? upstreamUrl : resolveConfigUrl(config.url) ?? upstreamUrl;
959
1272
  console.info(
960
1273
  `[AgentProxy:subscription] REQ#${req.reqId} | provider=${this.profile.providerId} -> ${fetchUrl} model=${currentModel} attempt=${attempted.length}`
@@ -962,6 +1275,7 @@ var SubscriptionDispatcher = class {
962
1275
  try {
963
1276
  const upstream = await this.hooks.fetchWithRetry(fetchUrl, headers, requestBody, currentModel);
964
1277
  this.profile.recordModelOutcome?.(currentModel, true);
1278
+ this.markHealth(usedAccountId, 200);
965
1279
  const finalResponse = await this.hooks.executor.executeResponseChain(
966
1280
  requestBody,
967
1281
  upstream,
@@ -972,23 +1286,30 @@ var SubscriptionDispatcher = class {
972
1286
  await this.hooks.writeProxyResponse(req.res, finalResponse, req.isStream, req.reqId);
973
1287
  return;
974
1288
  } catch (err) {
975
- const handled = await this.maybeRetryAfterError(err, headers, req, currentModel);
1289
+ const handled = await this.maybeRetryAfterError(err, headers, req, currentModel, sessionKey);
976
1290
  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;
1291
+ try {
1292
+ const upstream = await this.hooks.fetchWithRetry(fetchUrl, handled.headers, requestBody, currentModel);
1293
+ this.profile.recordModelOutcome?.(currentModel, true);
1294
+ this.markHealth(usedAccountId, 200);
1295
+ const finalResponse = await this.hooks.executor.executeResponseChain(
1296
+ requestBody,
1297
+ upstream,
1298
+ transformerProvider,
1299
+ { providerTransformers, modelTransformers },
1300
+ { endpointTransformer: this.hooks.endpointTransformer }
1301
+ );
1302
+ await this.hooks.writeProxyResponse(req.res, finalResponse, req.isStream, req.reqId);
1303
+ return;
1304
+ } catch (retryErr) {
1305
+ this.markHealth(usedAccountId, errStatus(retryErr), retryErr);
1306
+ throw retryErr;
1307
+ }
988
1308
  }
989
1309
  if (caughtErrorBreakerOutcome(err) === "failure") {
990
1310
  this.profile.recordModelOutcome?.(currentModel, false);
991
1311
  }
1312
+ this.markHealth(usedAccountId, errStatus(err), err);
992
1313
  const next = this.profile.nextFallback?.(scenario, attempted, ocConfig);
993
1314
  if (!next || attempted.length >= MAX_FALLBACK_ATTEMPTS_LOCAL) {
994
1315
  throw err;
@@ -1036,12 +1357,12 @@ var SubscriptionDispatcher = class {
1036
1357
  * Returns `{ retryOnce: true, headers }` when the strategy refreshed
1037
1358
  * successfully (caller should retry once); otherwise re-throws.
1038
1359
  */
1039
- async maybeRetryAfterError(err, headers, req, resolvedModel) {
1360
+ async maybeRetryAfterError(err, headers, req, resolvedModel, sessionKey) {
1040
1361
  const status = err?.status ?? 0;
1041
1362
  if (status !== 401) {
1042
1363
  return { retryOnce: false, headers };
1043
1364
  }
1044
- const refreshed = await this.profile.authStrategy.onUnauthorized();
1365
+ const refreshed = await this.profile.authStrategy.onUnauthorized(sessionKey);
1045
1366
  if (!refreshed) {
1046
1367
  console.warn(
1047
1368
  `[AgentProxy:subscription] REQ#${req.reqId} | 401 not recoverable for provider=${this.profile.providerId}`
@@ -1050,7 +1371,7 @@ var SubscriptionDispatcher = class {
1050
1371
  }
1051
1372
  const fresh = { ...headers };
1052
1373
  stripAuthHeaders(fresh);
1053
- await this.applyHeadersWithRetry(fresh, { upstreamUrl: "", resolvedModel });
1374
+ await this.applyHeadersWithRetry(fresh, { upstreamUrl: "", resolvedModel, sessionKey });
1054
1375
  return { retryOnce: true, headers: fresh };
1055
1376
  }
1056
1377
  async applyHeadersWithRetry(headers, hints) {
@@ -1060,6 +1381,28 @@ var SubscriptionDispatcher = class {
1060
1381
  console.warn("[AgentProxy:subscription] authStrategy.applyHeaders threw:", serializeError(err));
1061
1382
  }
1062
1383
  }
1384
+ /**
1385
+ * Mark the served account's health against ONE attempt's outcome
1386
+ * (subscription-account-health, task 3.4). No-op when no account was reported
1387
+ * (non-pooled / single-account) or on a session-cancel (status 0). On a caught
1388
+ * error `err` is passed so the 429-reset / 403-ban drivers are read STRUCTURALLY
1389
+ * from the error's upstream `headers` + `bodyText` (the `fetchWithRetry`
1390
+ * contract) — so daemon-path 429 cooldown + ban blocking function for
1391
+ * multi-account codex/gemini/opencodego pools; absent headers ⇒ a bare-429
1392
+ * (unmarked, lazy re-probe). Success (2xx) clears; 401/5xx/thrown → transient.
1393
+ */
1394
+ markHealth(accountId, status, err) {
1395
+ if (accountId === void 0 || status === 0) return;
1396
+ const headers = err !== void 0 ? errHeaders(err) : void 0;
1397
+ const reset = headers ? resolveResetSeconds(this.profile.providerId, headers) : { resetHeaderSeconds: null, retryAfterSeconds: null };
1398
+ const bodyText = status === 403 && err !== void 0 ? errBodyText(err) : void 0;
1399
+ getSharedAccountHealth2().recordUpstreamOutcome(this.profile.providerId, accountId, {
1400
+ status,
1401
+ resetHeaderSeconds: reset.resetHeaderSeconds,
1402
+ retryAfterSeconds: reset.retryAfterSeconds,
1403
+ bodyText
1404
+ });
1405
+ }
1063
1406
  /**
1064
1407
  * Resolve the Code Assist project for the gemini subscription profile. Pulls
1065
1408
  * the Bearer the bound `AuthStrategy` would inject (so the strategy stays the
@@ -1136,6 +1479,22 @@ var SubscriptionDispatcher = class {
1136
1479
  }
1137
1480
  };
1138
1481
  var MAX_FALLBACK_ATTEMPTS_LOCAL = 3;
1482
+ function errStatus(err) {
1483
+ const status = err?.status;
1484
+ return typeof status === "number" ? status : null;
1485
+ }
1486
+ function errHeaders(err) {
1487
+ const h = err?.headers;
1488
+ if (!h) return void 0;
1489
+ if (typeof h.get === "function") return h;
1490
+ if (typeof h === "object") return h;
1491
+ return void 0;
1492
+ }
1493
+ function errBodyText(err) {
1494
+ const e = err;
1495
+ const raw = typeof e?.bodyText === "string" ? e.bodyText : typeof e?.body === "string" ? e.body : void 0;
1496
+ return raw?.slice(0, 2048);
1497
+ }
1139
1498
  function caughtErrorBreakerOutcome(err) {
1140
1499
  const status = err?.status;
1141
1500
  if (typeof status !== "number") return "failure";
@@ -1161,6 +1520,10 @@ function stripAuthHeaders(headers) {
1161
1520
  delete headers["X-Goog-Api-Key"];
1162
1521
  }
1163
1522
  export {
1523
+ DEFAULT_ACCOUNT_PRIORITY,
1524
+ LAST_USED_PERSIST_THROTTLE_MS,
1525
+ SESSION_AFFINITY_TTL_MS,
1526
+ SubscriptionAccountSelector,
1164
1527
  SubscriptionAccountService,
1165
1528
  SubscriptionDispatcher,
1166
1529
  SubscriptionProviderRegistry,