@antseed/node 0.2.156 → 0.2.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/discovery/announcer.d.ts +16 -1
  2. package/dist/discovery/announcer.d.ts.map +1 -1
  3. package/dist/discovery/announcer.js +69 -9
  4. package/dist/discovery/announcer.js.map +1 -1
  5. package/dist/discovery/http-metadata-resolver.d.ts +1 -1
  6. package/dist/discovery/http-metadata-resolver.d.ts.map +1 -1
  7. package/dist/discovery/http-metadata-resolver.js +7 -1
  8. package/dist/discovery/http-metadata-resolver.js.map +1 -1
  9. package/dist/discovery/index.d.ts +3 -2
  10. package/dist/discovery/index.d.ts.map +1 -1
  11. package/dist/discovery/index.js +2 -1
  12. package/dist/discovery/index.js.map +1 -1
  13. package/dist/discovery/metadata/define-version.d.ts +40 -0
  14. package/dist/discovery/metadata/define-version.d.ts.map +1 -0
  15. package/dist/discovery/metadata/define-version.js +104 -0
  16. package/dist/discovery/metadata/define-version.js.map +1 -0
  17. package/dist/discovery/metadata/field.d.ts +26 -0
  18. package/dist/discovery/metadata/field.d.ts.map +1 -0
  19. package/dist/discovery/metadata/field.js +2 -0
  20. package/dist/discovery/metadata/field.js.map +1 -0
  21. package/dist/discovery/metadata/fields/offerings.d.ts +7 -0
  22. package/dist/discovery/metadata/fields/offerings.d.ts.map +1 -0
  23. package/dist/discovery/metadata/fields/offerings.js +52 -0
  24. package/dist/discovery/metadata/fields/offerings.js.map +1 -0
  25. package/dist/discovery/metadata/fields/on-chain-stats.d.ts +6 -0
  26. package/dist/discovery/metadata/fields/on-chain-stats.d.ts.map +1 -0
  27. package/dist/discovery/metadata/fields/on-chain-stats.js +30 -0
  28. package/dist/discovery/metadata/fields/on-chain-stats.js.map +1 -0
  29. package/dist/discovery/metadata/fields/peer-capabilities.d.ts +4 -0
  30. package/dist/discovery/metadata/fields/peer-capabilities.d.ts.map +1 -0
  31. package/dist/discovery/metadata/fields/peer-capabilities.js +21 -0
  32. package/dist/discovery/metadata/fields/peer-capabilities.js.map +1 -0
  33. package/dist/discovery/metadata/fields/peer-profile.d.ts +11 -0
  34. package/dist/discovery/metadata/fields/peer-profile.d.ts.map +1 -0
  35. package/dist/discovery/metadata/fields/peer-profile.js +58 -0
  36. package/dist/discovery/metadata/fields/peer-profile.js.map +1 -0
  37. package/dist/discovery/metadata/fields/provider-basics.d.ts +12 -0
  38. package/dist/discovery/metadata/fields/provider-basics.d.ts.map +1 -0
  39. package/dist/discovery/metadata/fields/provider-basics.js +87 -0
  40. package/dist/discovery/metadata/fields/provider-basics.js.map +1 -0
  41. package/dist/discovery/metadata/fields/service-api-protocols.d.ts +4 -0
  42. package/dist/discovery/metadata/fields/service-api-protocols.d.ts.map +1 -0
  43. package/dist/discovery/metadata/fields/service-api-protocols.js +40 -0
  44. package/dist/discovery/metadata/fields/service-api-protocols.js.map +1 -0
  45. package/dist/discovery/metadata/fields/service-capabilities.d.ts +7 -0
  46. package/dist/discovery/metadata/fields/service-capabilities.d.ts.map +1 -0
  47. package/dist/discovery/metadata/fields/service-capabilities.js +229 -0
  48. package/dist/discovery/metadata/fields/service-capabilities.js.map +1 -0
  49. package/dist/discovery/metadata/fields/service-categories.d.ts +4 -0
  50. package/dist/discovery/metadata/fields/service-categories.d.ts.map +1 -0
  51. package/dist/discovery/metadata/fields/service-categories.js +37 -0
  52. package/dist/discovery/metadata/fields/service-categories.js.map +1 -0
  53. package/dist/discovery/metadata/fields/service-health.d.ts +7 -0
  54. package/dist/discovery/metadata/fields/service-health.d.ts.map +1 -0
  55. package/dist/discovery/metadata/fields/service-health.js +90 -0
  56. package/dist/discovery/metadata/fields/service-health.js.map +1 -0
  57. package/dist/discovery/metadata/fields/service-performance.d.ts +7 -0
  58. package/dist/discovery/metadata/fields/service-performance.d.ts.map +1 -0
  59. package/dist/discovery/metadata/fields/service-performance.js +63 -0
  60. package/dist/discovery/metadata/fields/service-performance.js.map +1 -0
  61. package/dist/discovery/metadata/fields/service-unit-billing.d.ts +8 -0
  62. package/dist/discovery/metadata/fields/service-unit-billing.d.ts.map +1 -0
  63. package/dist/discovery/metadata/fields/service-unit-billing.js +81 -0
  64. package/dist/discovery/metadata/fields/service-unit-billing.js.map +1 -0
  65. package/dist/discovery/metadata/fields/verifications.d.ts +7 -0
  66. package/dist/discovery/metadata/fields/verifications.d.ts.map +1 -0
  67. package/dist/discovery/metadata/fields/verifications.js +96 -0
  68. package/dist/discovery/metadata/fields/verifications.js.map +1 -0
  69. package/dist/discovery/metadata/index.d.ts +27 -0
  70. package/dist/discovery/metadata/index.d.ts.map +1 -0
  71. package/dist/discovery/metadata/index.js +56 -0
  72. package/dist/discovery/metadata/index.js.map +1 -0
  73. package/dist/discovery/metadata/versions/v10.d.ts +5 -0
  74. package/dist/discovery/metadata/versions/v10.d.ts.map +1 -0
  75. package/dist/discovery/metadata/versions/v10.js +32 -0
  76. package/dist/discovery/metadata/versions/v10.js.map +1 -0
  77. package/dist/discovery/metadata/versions/v11.d.ts +5 -0
  78. package/dist/discovery/metadata/versions/v11.d.ts.map +1 -0
  79. package/dist/discovery/metadata/versions/v11.js +11 -0
  80. package/dist/discovery/metadata/versions/v11.js.map +1 -0
  81. package/dist/discovery/metadata/versions/v12.d.ts +5 -0
  82. package/dist/discovery/metadata/versions/v12.d.ts.map +1 -0
  83. package/dist/discovery/metadata/versions/v12.js +66 -0
  84. package/dist/discovery/metadata/versions/v12.js.map +1 -0
  85. package/dist/discovery/metadata/versions/v13.d.ts +5 -0
  86. package/dist/discovery/metadata/versions/v13.d.ts.map +1 -0
  87. package/dist/discovery/metadata/versions/v13.js +16 -0
  88. package/dist/discovery/metadata/versions/v13.js.map +1 -0
  89. package/dist/discovery/metadata/versions/v14.d.ts +5 -0
  90. package/dist/discovery/metadata/versions/v14.d.ts.map +1 -0
  91. package/dist/discovery/metadata/versions/v14.js +16 -0
  92. package/dist/discovery/metadata/versions/v14.js.map +1 -0
  93. package/dist/discovery/metadata/wire.d.ts +54 -0
  94. package/dist/discovery/metadata/wire.d.ts.map +1 -0
  95. package/dist/discovery/metadata/wire.js +149 -0
  96. package/dist/discovery/metadata/wire.js.map +1 -0
  97. package/dist/discovery/metadata-codec.d.ts +1 -38
  98. package/dist/discovery/metadata-codec.d.ts.map +1 -1
  99. package/dist/discovery/metadata-codec.js +3 -1154
  100. package/dist/discovery/metadata-codec.js.map +1 -1
  101. package/dist/discovery/metadata-server.d.ts +3 -2
  102. package/dist/discovery/metadata-server.d.ts.map +1 -1
  103. package/dist/discovery/metadata-server.js +4 -1
  104. package/dist/discovery/metadata-server.js.map +1 -1
  105. package/dist/discovery/metadata-validator.d.ts.map +1 -1
  106. package/dist/discovery/metadata-validator.js +90 -1
  107. package/dist/discovery/metadata-validator.js.map +1 -1
  108. package/dist/discovery/service-catalog.d.ts +16 -2
  109. package/dist/discovery/service-catalog.d.ts.map +1 -1
  110. package/dist/discovery/service-catalog.js +8 -1
  111. package/dist/discovery/service-catalog.js.map +1 -1
  112. package/dist/health/gas-auto-refuel.d.ts +138 -0
  113. package/dist/health/gas-auto-refuel.d.ts.map +1 -0
  114. package/dist/health/gas-auto-refuel.js +216 -0
  115. package/dist/health/gas-auto-refuel.js.map +1 -0
  116. package/dist/health/gas-health-monitor.d.ts +4 -0
  117. package/dist/health/gas-health-monitor.d.ts.map +1 -1
  118. package/dist/health/gas-health-monitor.js +13 -0
  119. package/dist/health/gas-health-monitor.js.map +1 -1
  120. package/dist/health/model-health-checker.d.ts +67 -33
  121. package/dist/health/model-health-checker.d.ts.map +1 -1
  122. package/dist/health/model-health-checker.js +273 -121
  123. package/dist/health/model-health-checker.js.map +1 -1
  124. package/dist/health/self-health-error.d.ts +20 -0
  125. package/dist/health/self-health-error.d.ts.map +1 -0
  126. package/dist/health/self-health-error.js +60 -0
  127. package/dist/health/self-health-error.js.map +1 -0
  128. package/dist/health/seller-events.d.ts +1 -1
  129. package/dist/health/seller-events.d.ts.map +1 -1
  130. package/dist/health/seller-events.js.map +1 -1
  131. package/dist/health/service-performance.d.ts +28 -0
  132. package/dist/health/service-performance.d.ts.map +1 -0
  133. package/dist/health/service-performance.js +144 -0
  134. package/dist/health/service-performance.js.map +1 -0
  135. package/dist/health/stream-outcome.d.ts +19 -0
  136. package/dist/health/stream-outcome.d.ts.map +1 -0
  137. package/dist/health/stream-outcome.js +91 -0
  138. package/dist/health/stream-outcome.js.map +1 -0
  139. package/dist/index.d.ts +8 -5
  140. package/dist/index.d.ts.map +1 -1
  141. package/dist/index.js +6 -3
  142. package/dist/index.js.map +1 -1
  143. package/dist/interfaces/seller-provider.d.ts +11 -5
  144. package/dist/interfaces/seller-provider.d.ts.map +1 -1
  145. package/dist/node.d.ts +10 -2
  146. package/dist/node.d.ts.map +1 -1
  147. package/dist/node.js +43 -89
  148. package/dist/node.js.map +1 -1
  149. package/dist/p2p/connection-manager.d.ts +5 -1
  150. package/dist/p2p/connection-manager.d.ts.map +1 -1
  151. package/dist/p2p/connection-manager.js +48 -13
  152. package/dist/p2p/connection-manager.js.map +1 -1
  153. package/dist/payments/chain-config.d.ts +11 -0
  154. package/dist/payments/chain-config.d.ts.map +1 -1
  155. package/dist/payments/chain-config.js +7 -0
  156. package/dist/payments/chain-config.js.map +1 -1
  157. package/dist/payments/evm/usdc-eth-swap-client.d.ts +45 -0
  158. package/dist/payments/evm/usdc-eth-swap-client.d.ts.map +1 -0
  159. package/dist/payments/evm/usdc-eth-swap-client.js +83 -0
  160. package/dist/payments/evm/usdc-eth-swap-client.js.map +1 -0
  161. package/dist/payments/index.d.ts +2 -0
  162. package/dist/payments/index.d.ts.map +1 -1
  163. package/dist/payments/index.js +2 -0
  164. package/dist/payments/index.js.map +1 -1
  165. package/dist/payments/seller-payment-manager.d.ts +50 -0
  166. package/dist/payments/seller-payment-manager.d.ts.map +1 -1
  167. package/dist/payments/seller-payment-manager.js +320 -80
  168. package/dist/payments/seller-payment-manager.js.map +1 -1
  169. package/dist/routing/seller-health.d.ts +21 -0
  170. package/dist/routing/seller-health.d.ts.map +1 -1
  171. package/dist/routing/seller-health.js +19 -3
  172. package/dist/routing/seller-health.js.map +1 -1
  173. package/dist/seller-request-handler.d.ts +14 -0
  174. package/dist/seller-request-handler.d.ts.map +1 -1
  175. package/dist/seller-request-handler.js +207 -30
  176. package/dist/seller-request-handler.js.map +1 -1
  177. package/dist/types/peer.d.ts +6 -2
  178. package/dist/types/peer.d.ts.map +1 -1
  179. package/package.json +3 -3
@@ -1,23 +1,35 @@
1
+ import { serviceHealthOf } from '../discovery/peer-metadata.js';
1
2
  import { isNativeVideoProtocol, isRoutingProtocol } from '../types/service-api.js';
2
3
  import { debugLog, debugWarn } from '../utils/debug.js';
3
4
  import { isLiveResponseOutOfCredit, isUpstreamOutOfCredit } from './upstream-credit.js';
4
- /** Default sweep interval. Each sweep sends one 1-token probe per advertised service. */
5
+ /** Default sweep interval. Each sweep sends one 1-token probe per service without recent buyer traffic. */
5
6
  export const DEFAULT_HEALTH_CHECK_INTERVAL_MS = 5 * 60_000;
6
- /** Consecutive probe failures before a service is unadvertised. */
7
+ /** Consecutive failed checks before a service is announced as failing. */
7
8
  export const DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD = 3;
8
9
  /** Per-probe timeout. A model that hangs this long is treated as failing. */
9
10
  export const DEFAULT_HEALTH_CHECK_PROBE_TIMEOUT_MS = 60_000;
11
+ /** Shortest gap between probes triggered by failed buyer requests for one service. */
12
+ export const LIVE_FAILURE_PROBE_MIN_GAP_MS = 30_000;
10
13
  /**
11
- * Periodically probes every advertised service with a minimal (1-token)
12
- * completion and unadvertises services that fail repeatedly, so buyers stop
13
- * discovering models the seller cannot actually serve. Removed services keep
14
- * being probed and are re-advertised on the first healthy probe.
14
+ * A service that keeps failing is probed less often: every interval, then
15
+ * every second, then every third (with the default, 5, 10 and 15 minutes).
16
+ * Its upstream is often rate limited or out of credit, and each probe would
17
+ * add to that. A successful buyer request still marks it healthy at once.
18
+ */
19
+ export const MAX_FAILING_PROBE_BACKOFF = 3;
20
+ class ProbeTimeoutError extends Error {
21
+ }
22
+ /**
23
+ * The seller's self health check. Every service stays announced; its latest
24
+ * result is published on `provider.serviceHealth`, which goes into signed
25
+ * metadata (v13+) so buyers can hide failing services from catalogs and
26
+ * routing and explain why a pinned request failed.
15
27
  *
16
- * Removal mutates `provider.services` in place — the same array reference the
17
- * announcer and the seller request handler read live — so it takes effect on
18
- * the next `/metadata` fetch and the next incoming request. If every service
19
- * is removed, `provider.healthCheckAvailable` becomes false so discovery omits
20
- * the provider instead of treating its empty service list as a wildcard.
28
+ * Buyer traffic is the main evidence: a successful billable response marks a
29
+ * service healthy, and a service that had one within the last interval is not
30
+ * probed. Idle services get a minimal (1-token) completion each interval, and
31
+ * a failed buyer request triggers an early probe. A service is failing after
32
+ * `failureThreshold` failed checks in a row and recovers on the first pass.
21
33
  */
22
34
  export class ModelHealthChecker {
23
35
  _targets;
@@ -36,8 +48,8 @@ export class ModelHealthChecker {
36
48
  _onSweepComplete;
37
49
  /** Each upstream's credit as last seen; unset until something said. */
38
50
  _accounts = new Map();
39
- /** Services withdrawn before `onChange` could be told; reported on the first start. */
40
- _withdrawnAtInit = [];
51
+ /** Services marked failing before `onChange` could be told; reported on the first start. */
52
+ _failingAtInit = [];
41
53
  constructor(config) {
42
54
  this._targets = config.targets;
43
55
  this._intervalMs = config.intervalMs ?? DEFAULT_HEALTH_CHECK_INTERVAL_MS;
@@ -47,20 +59,23 @@ export class ModelHealthChecker {
47
59
  this._onUpstreamAccount = config.onUpstreamAccount;
48
60
  this._lowBalanceUsd = config.lowBalanceUsd;
49
61
  this._onSweepComplete = config.onSweepComplete;
50
- // Providers retained after an OAuth initialization failure must pass a real
51
- // probe before any service is advertised. Keep their services in recovery
52
- // state rather than dropping the provider permanently.
62
+ // Providers retained after an OAuth initialization failure stay announced
63
+ // as failing until a real check passes.
64
+ const now = Date.now();
53
65
  for (const { provider } of this._targets) {
54
66
  if (provider.healthCheckAvailable !== false)
55
67
  continue;
56
- for (const [index, service] of provider.services.entries()) {
68
+ for (const service of provider.services) {
57
69
  const state = this._stateFor(provider, service);
58
- state.removed = true;
59
- state.removedAtIndex = index;
60
- state.lastDetail = 'Awaiting successful probe after initialization failure';
61
- this._withdrawnAtInit.push(state);
70
+ state.status = 'failing';
71
+ state.reason = 'init_failed';
72
+ state.failingSince = now;
73
+ state.checkedAt = now;
74
+ state.source = 'probe';
75
+ state.lastDetail = 'Initialization failed; awaiting a successful check';
76
+ this._publish(state);
77
+ this._failingAtInit.push(state);
62
78
  }
63
- provider.services.splice(0);
64
79
  }
65
80
  }
66
81
  /** Start periodic sweeps. The first sweep runs immediately (async). */
@@ -68,15 +83,16 @@ export class ModelHealthChecker {
68
83
  if (this._timer)
69
84
  return;
70
85
  this._stopped = false;
71
- for (const state of this._withdrawnAtInit.splice(0)) {
72
- if (!state.removed)
86
+ for (const state of this._failingAtInit.splice(0)) {
87
+ if (state.status !== 'failing')
73
88
  continue;
74
89
  this._emitChange({
75
90
  provider: state.providerName,
76
91
  providerId: state.providerId,
77
92
  service: state.service,
78
- status: 'removed',
93
+ status: 'failing',
79
94
  consecutiveFailures: state.consecutiveFailures,
95
+ reason: 'init_failed',
80
96
  detail: state.lastDetail ?? 'initialization failed',
81
97
  });
82
98
  }
@@ -94,7 +110,7 @@ export class ModelHealthChecker {
94
110
  this._timer = null;
95
111
  }
96
112
  }
97
- /** Probe every advertised (and previously removed) service once. */
113
+ /** Check every service once, skipping probes for services with recent buyer traffic. */
98
114
  async runSweep() {
99
115
  if (this._sweepInFlight)
100
116
  return this._sweepInFlight;
@@ -116,11 +132,16 @@ export class ModelHealthChecker {
116
132
  provider: state.providerName,
117
133
  providerId: state.providerId,
118
134
  service: state.service,
119
- advertised: !state.removed,
135
+ status: state.status,
120
136
  consecutiveFailures: state.consecutiveFailures,
137
+ failingSince: state.failingSince,
138
+ reason: state.reason,
139
+ checkedAt: state.checkedAt,
140
+ source: state.source,
121
141
  lastProbeAt: state.lastProbeAt,
122
142
  lastStatusCode: state.lastStatusCode,
123
143
  lastDetail: state.lastDetail,
144
+ lastTrafficAt: state.lastTrafficAt,
124
145
  }));
125
146
  }
126
147
  async _runSweep() {
@@ -128,39 +149,33 @@ export class ModelHealthChecker {
128
149
  if (this._stopped)
129
150
  return;
130
151
  const provider = target.provider;
131
- // Don't compete with paying buyers for concurrency slots.
132
- try {
133
- const capacity = provider.getCapacity();
134
- if (capacity.current >= capacity.max) {
135
- debugLog(`[ModelHealth] Skipping ${provider.name}: at capacity (${capacity.current}/${capacity.max})`);
136
- continue;
137
- }
138
- }
139
- catch {
140
- // getCapacity should not throw; probe anyway if it does.
141
- }
142
- // Probe currently advertised services plus ones we removed earlier
143
- // (so they can recover). Snapshot before iterating — probes mutate
144
- // the live array on state transitions.
145
- const services = new Set(provider.services);
146
- for (const state of this._states.values()) {
147
- if (state.provider === provider && state.removed) {
148
- services.add(state.service);
149
- }
150
- }
152
+ if (this._atCapacity(provider))
153
+ continue;
154
+ const services = [...provider.services];
151
155
  const hasFreeCheck = (candidate) => Boolean(candidate?.checkServiceHealth || candidate?.checkUpstreamAccount);
152
156
  const checkProvider = hasFreeCheck(target.probeProvider) ? target.probeProvider : provider;
157
+ // Free checks also read the account balance, so they run even with traffic.
153
158
  const checked = hasFreeCheck(checkProvider)
154
- ? [...services].filter((service) => !supportsHealthProbe(resolveProbeProtocol(provider, service)))
159
+ ? services.filter((service) => !supportsHealthProbe(resolveProbeProtocol(provider, service)))
155
160
  : [];
156
161
  if (checked.length > 0) {
157
162
  await this._checkServices(target, checkProvider, checked);
158
163
  }
164
+ const now = Date.now();
159
165
  for (const service of services) {
160
166
  if (this._stopped)
161
167
  return;
162
168
  if (checked.includes(service))
163
169
  continue;
170
+ const state = this._stateFor(provider, service);
171
+ if (state.lastTrafficAt !== null && now - state.lastTrafficAt < this._intervalMs) {
172
+ debugLog(`[ModelHealth] ${provider.name}/${service}: skipping probe, served a buyer ${Math.round((now - state.lastTrafficAt) / 1000)}s ago`);
173
+ continue;
174
+ }
175
+ if (this._backingOff(state, now)) {
176
+ debugLog(`[ModelHealth] ${provider.name}/${service}: failing, next probe later (${state.consecutiveFailures} failed checks in a row)`);
177
+ continue;
178
+ }
164
179
  await this._probeService(target, service);
165
180
  }
166
181
  }
@@ -170,12 +185,13 @@ export class ModelHealthChecker {
170
185
  * upstream account check (key valid, account funded) once, and the model
171
186
  * check (model still offered) for every service. A service is unavailable
172
187
  * when either check says so, and available only when no check is unsure.
173
- * `unavailable` counts as a failed check, `available` re-advertises,
188
+ * `unavailable` counts as a failed check, `available` as a pass,
174
189
  * `unknown` changes nothing.
175
190
  */
176
191
  async _checkServices(target, checkProvider, services) {
177
192
  const provider = target.provider;
178
193
  const timed = (label, check) => withTimeout(Promise.resolve().then(check), this._probeTimeoutMs, `${label} for ${provider.name} timed out after ${this._probeTimeoutMs}ms`);
194
+ const checkStartedAt = Date.now();
179
195
  const [account, models] = await Promise.all([
180
196
  checkProvider.checkUpstreamAccount
181
197
  ? timed('account check', () => checkProvider.checkUpstreamAccount())
@@ -186,7 +202,8 @@ export class ModelHealthChecker {
186
202
  : undefined,
187
203
  ]);
188
204
  const balance = account?.status === 'available' ? account.balanceUsd : undefined;
189
- if (account?.status === 'unavailable' && (account.outOfCredit === true || isUpstreamOutOfCredit(null, account.reason))) {
205
+ const accountOutOfCredit = account?.status === 'unavailable' && (account.outOfCredit === true || isUpstreamOutOfCredit(null, account.reason));
206
+ if (accountOutOfCredit) {
190
207
  this._setAccount(provider, 'out', account.reason, null);
191
208
  }
192
209
  else if (account?.status === 'available') {
@@ -204,15 +221,17 @@ export class ModelHealthChecker {
204
221
  const model = models === undefined
205
222
  ? undefined
206
223
  : models?.[service] ?? { status: 'unknown', reason: 'model check gave no result' };
207
- const results = [account, model].filter((result) => result !== undefined);
208
- return results.find((result) => result.status === 'unavailable')
209
- ?? results.find((result) => result.status === 'unknown')
210
- ?? { status: 'available', balanceUsd: balance };
224
+ if (account?.status === 'unavailable')
225
+ return { result: account, reason: accountOutOfCredit ? 'out_of_credit' : reasonForAccount(account.reason) };
226
+ if (model?.status === 'unavailable')
227
+ return { result: model, reason: 'model_unavailable' };
228
+ const unsure = [account, model].find((result) => result?.status === 'unknown');
229
+ return { result: unsure ?? { status: 'available', balanceUsd: balance }, reason: 'upstream_error' };
211
230
  };
212
231
  for (const service of services) {
213
232
  if (this._stopped)
214
233
  return;
215
- const result = combine(service);
234
+ const { result, reason } = combine(service);
216
235
  const state = this._stateFor(provider, service);
217
236
  if (result.status === 'unknown') {
218
237
  debugLog(`[ModelHealth] ${provider.name}/${service}: inconclusive (${result.reason ?? 'no result'})`);
@@ -222,14 +241,10 @@ export class ModelHealthChecker {
222
241
  state.lastStatusCode = null;
223
242
  if (result.status === 'unavailable') {
224
243
  state.lastDetail = result.reason;
225
- this._recordFailure(provider, state);
244
+ this._recordFailure(state, reason, null, checkStartedAt);
226
245
  continue;
227
246
  }
228
- state.consecutiveFailures = 0;
229
- if (!state.removed)
230
- continue;
231
- state.lastDetail = balance === undefined ? 'Account check passed' : `Account check passed (balance $${balance.toFixed(2)})`;
232
- this._restoreService(provider, state);
247
+ this._recordSuccess(state, 'probe', balance === undefined ? 'Account check passed' : `Account check passed (balance $${balance.toFixed(2)})`);
233
248
  }
234
249
  }
235
250
  _stateFor(provider, service) {
@@ -246,12 +261,18 @@ export class ModelHealthChecker {
246
261
  providerName: provider.name,
247
262
  providerId: this._targets.find((target) => target.provider === provider)?.id ?? provider.name,
248
263
  service,
264
+ status: 'unknown',
249
265
  consecutiveFailures: 0,
250
- removed: false,
251
- removedAtIndex: 0,
266
+ failingSince: null,
267
+ reason: null,
268
+ httpStatus: null,
269
+ checkedAt: null,
270
+ source: null,
252
271
  lastProbeAt: null,
253
272
  lastStatusCode: null,
254
273
  lastDetail: null,
274
+ lastTrafficAt: null,
275
+ probeInFlight: false,
255
276
  };
256
277
  this._states.set(key, state);
257
278
  }
@@ -261,8 +282,13 @@ export class ModelHealthChecker {
261
282
  const provider = target.provider;
262
283
  const probeProvider = target.probeProvider ?? provider;
263
284
  const state = this._stateFor(provider, service);
264
- state.lastProbeAt = Date.now();
285
+ if (state.probeInFlight)
286
+ return;
287
+ state.probeInFlight = true;
288
+ const probeStartedAt = Date.now();
289
+ state.lastProbeAt = probeStartedAt;
265
290
  let outcome;
291
+ let reason;
266
292
  let errorText = null;
267
293
  try {
268
294
  const protocol = resolveProbeProtocol(provider, service);
@@ -276,6 +302,7 @@ export class ModelHealthChecker {
276
302
  const response = await withTimeout(probeProvider.handleRequest(request), this._probeTimeoutMs, `health probe for ${service} timed out after ${this._probeTimeoutMs}ms`);
277
303
  state.lastStatusCode = response.statusCode;
278
304
  outcome = classifyProbeStatus(response.statusCode);
305
+ reason = reasonForStatus(response.statusCode);
279
306
  // Classified on the body itself, not the 200-byte summary: a billing code can follow a long message.
280
307
  if (outcome !== 'healthy')
281
308
  errorText = decodeBody(response.body, 16_384);
@@ -288,17 +315,22 @@ export class ModelHealthChecker {
288
315
  state.lastDetail = err instanceof Error ? err.message : String(err);
289
316
  errorText = state.lastDetail;
290
317
  outcome = 'unhealthy';
318
+ reason = err instanceof ProbeTimeoutError ? 'timeout' : 'unreachable';
319
+ }
320
+ finally {
321
+ state.probeInFlight = false;
291
322
  }
292
323
  // A billing error is a failure whatever its status (Anthropic answers one with a 400);
293
324
  // a 404 or 422 is about the probe itself, so its text never marks the account.
294
325
  if (outcome !== 'healthy' && state.lastStatusCode !== 404 && state.lastStatusCode !== 422 && isUpstreamOutOfCredit(state.lastStatusCode, errorText)) {
295
326
  outcome = 'unhealthy';
327
+ reason = 'out_of_credit';
296
328
  this._setAccount(provider, 'out', state.lastDetail ?? 'out of credit', null);
297
329
  }
298
330
  if (outcome === 'inconclusive') {
299
331
  // The endpoint answered but the probe result proves nothing either way
300
- // (for example, 400 for an unsupported probe parameter). Leave failure
301
- // counters and advertisement untouched.
332
+ // (for example, 400 for an unsupported probe parameter). Leave the
333
+ // service's health untouched.
302
334
  debugLog(`[ModelHealth] ${provider.name}/${service}: inconclusive (${state.lastDetail})`);
303
335
  return;
304
336
  }
@@ -306,22 +338,22 @@ export class ModelHealthChecker {
306
338
  // An answered completion means the account pays again (its balance is unknown here).
307
339
  if (this._accounts.get(provider) === 'out')
308
340
  this._setAccount(provider, 'ok', state.lastDetail ?? 'probe succeeded', null);
309
- state.consecutiveFailures = 0;
310
- if (state.removed) {
311
- this._restoreService(provider, state);
312
- }
341
+ this._recordSuccess(state, 'probe', state.lastDetail ?? 'probe succeeded');
313
342
  return;
314
343
  }
315
- this._recordFailure(provider, state);
344
+ this._recordFailure(state, reason, state.lastStatusCode, probeStartedAt);
316
345
  }
317
346
  /**
318
- * A live request's upstream answer (the seller's own traffic): an out-of-credit
319
- * error is reported at once instead of waiting for the probes, and a billable
320
- * success after one means the account pays again (polling or downloading a
321
- * video already paid for is not billable and proves nothing).
347
+ * A live request's upstream answer (the seller's own traffic). A billable
348
+ * success marks `service` healthy and spares it the next probe. A failure
349
+ * the upstream is responsible for (auth, limits, 5xx) triggers an early
350
+ * probe instead of counting directly, so one buyer's odd request cannot mark
351
+ * a service failing. An out-of-credit error is reported at once, and a
352
+ * billable success after one means the account pays again (polling or
353
+ * downloading a video already paid for is not billable and proves nothing).
322
354
  */
323
- reportUpstreamResponse(provider, statusCode, body, billable = true) {
324
- const target = this._targets.find((entry) => entry.provider === provider || entry.probeProvider === provider);
355
+ reportUpstreamResponse(provider, statusCode, body, billable = true, service) {
356
+ const target = this._findTarget(provider);
325
357
  if (!target || this._stopped)
326
358
  return;
327
359
  if (statusCode >= 400 && isLiveResponseOutOfCredit(statusCode, body)) {
@@ -330,6 +362,78 @@ export class ModelHealthChecker {
330
362
  else if (statusCode < 400 && billable && this._accounts.get(target.provider) === 'out') {
331
363
  this._setAccount(target.provider, 'ok', `HTTP ${statusCode} on a request`, null);
332
364
  }
365
+ if (!service || !target.provider.services.includes(service))
366
+ return;
367
+ if (statusCode < 400) {
368
+ if (!billable)
369
+ return;
370
+ const state = this._stateFor(target.provider, service);
371
+ state.lastTrafficAt = Date.now();
372
+ state.lastStatusCode = statusCode;
373
+ this._recordSuccess(state, 'traffic', `HTTP ${statusCode} on a buyer request`);
374
+ return;
375
+ }
376
+ if (statusCode !== 404 && classifyProbeStatus(statusCode) === 'unhealthy') {
377
+ this._probeSoon(target, service);
378
+ }
379
+ }
380
+ /** A live request threw before the upstream answered (network error, timeout). */
381
+ reportUpstreamFailure(provider, service) {
382
+ const target = this._findTarget(provider);
383
+ if (!target || this._stopped || !target.provider.services.includes(service))
384
+ return;
385
+ this._probeSoon(target, service);
386
+ }
387
+ /** The announced health of `service`, if it has been checked. */
388
+ getServiceHealth(provider, service) {
389
+ const target = this._findTarget(provider);
390
+ return serviceHealthOf(target?.provider.serviceHealth, service);
391
+ }
392
+ _findTarget(provider) {
393
+ return this._targets.find((entry) => entry.provider === provider || entry.probeProvider === provider);
394
+ }
395
+ /** A failing service skips scheduled probes for longer the more checks it has failed in a row. */
396
+ _backingOff(state, now) {
397
+ if (state.status !== 'failing' || state.lastProbeAt === null)
398
+ return false;
399
+ const backoff = Math.min(MAX_FAILING_PROBE_BACKOFF, 1 + state.consecutiveFailures - this._failureThreshold);
400
+ // Sweeps come one interval apart, so allow one to come a little early.
401
+ return backoff > 1 && now - state.lastProbeAt < backoff * this._intervalMs - LIVE_FAILURE_PROBE_MIN_GAP_MS;
402
+ }
403
+ _probeSoon(target, service) {
404
+ // Image and video services are never probed (a probe would cost a real generation).
405
+ if (!supportsHealthProbe(resolveProbeProtocol(target.provider, service)))
406
+ return;
407
+ const state = this._stateFor(target.provider, service);
408
+ if (state.probeInFlight)
409
+ return;
410
+ // Already announced as failing: a probe could only confirm it. A buyer
411
+ // success or the next scheduled probe notices recovery.
412
+ if (state.status === 'failing')
413
+ return;
414
+ if (state.lastProbeAt !== null && Date.now() - state.lastProbeAt < LIVE_FAILURE_PROBE_MIN_GAP_MS)
415
+ return;
416
+ // A full provider answers its own 429, which says nothing about the upstream.
417
+ if (this._atCapacity(target.provider))
418
+ return;
419
+ debugLog(`[ModelHealth] ${target.provider.name}/${service}: buyer request failed upstream, probing now`);
420
+ void this._probeService(target, service).catch((err) => {
421
+ debugWarn(`[ModelHealth] early probe failed: ${err instanceof Error ? err.message : err}`);
422
+ });
423
+ }
424
+ /** Probes never compete with paying buyers for concurrency slots. */
425
+ _atCapacity(provider) {
426
+ try {
427
+ const capacity = provider.getCapacity();
428
+ if (capacity.current < capacity.max)
429
+ return false;
430
+ debugLog(`[ModelHealth] Skipping ${provider.name}: at capacity (${capacity.current}/${capacity.max})`);
431
+ return true;
432
+ }
433
+ catch {
434
+ // getCapacity should not throw; probe anyway if it does.
435
+ return false;
436
+ }
333
437
  }
334
438
  _lowLine() {
335
439
  const value = typeof this._lowBalanceUsd === 'function' ? this._lowBalanceUsd() : this._lowBalanceUsd;
@@ -344,14 +448,13 @@ export class ModelHealthChecker {
344
448
  if (next === 'ok' && previous === undefined)
345
449
  return;
346
450
  const target = this._targets.find((entry) => entry.provider === provider);
347
- const services = [...new Set([...provider.services, ...[...this._states.values()].filter((state) => state.provider === provider).map((state) => state.service)])];
348
451
  const event = {
349
452
  provider: provider.name,
350
453
  providerId: target?.id ?? provider.name,
351
454
  status: next === 'out' ? 'out_of_credit' : next === 'low' ? 'low_balance' : 'funded',
352
455
  detail,
353
456
  balanceUsd,
354
- services,
457
+ services: [...provider.services],
355
458
  };
356
459
  debugWarn(`[ModelHealth] ${event.providerId}: upstream ${event.status} (${detail})`);
357
460
  if (!this._onUpstreamAccount)
@@ -366,59 +469,88 @@ export class ModelHealthChecker {
366
469
  debugWarn(`[ModelHealth] onUpstreamAccount failed: ${err instanceof Error ? err.message : err}`);
367
470
  }
368
471
  }
369
- _recordFailure(provider, state) {
370
- state.consecutiveFailures += 1;
371
- debugWarn(`[ModelHealth] ${provider.name}/${state.service}: health check failed `
372
- + `(${state.consecutiveFailures}/${this._failureThreshold}) — ${state.lastDetail}`);
373
- if (!state.removed && state.consecutiveFailures >= this._failureThreshold) {
374
- this._removeService(provider, state);
375
- }
376
- }
377
- _removeService(provider, state) {
378
- const services = provider.services;
379
- const index = services.indexOf(state.service);
380
- if (index < 0) {
381
- // Already gone (removed externally) — track it as removed so it can recover.
382
- state.removed = true;
383
- if (services.length === 0) {
384
- provider.healthCheckAvailable = false;
385
- }
472
+ _recordSuccess(state, source, detail) {
473
+ const wasFailing = state.status === 'failing';
474
+ state.status = 'healthy';
475
+ state.consecutiveFailures = 0;
476
+ state.failingSince = null;
477
+ state.reason = null;
478
+ state.httpStatus = null;
479
+ state.checkedAt = Date.now();
480
+ state.source = source;
481
+ state.lastDetail = detail;
482
+ if (state.provider.healthCheckAvailable === false)
483
+ state.provider.healthCheckAvailable = true;
484
+ this._publish(state);
485
+ if (!wasFailing)
386
486
  return;
387
- }
388
- services.splice(index, 1);
389
- state.removed = true;
390
- state.removedAtIndex = index;
391
- if (services.length === 0) {
392
- provider.healthCheckAvailable = false;
393
- }
394
487
  this._emitChange({
395
- provider: provider.name,
488
+ provider: state.providerName,
396
489
  providerId: state.providerId,
397
490
  service: state.service,
398
- status: 'removed',
399
- consecutiveFailures: state.consecutiveFailures,
400
- detail: state.lastDetail ?? 'probe failed',
491
+ status: 'recovered',
492
+ consecutiveFailures: 0,
493
+ detail,
401
494
  });
402
495
  }
403
- _restoreService(provider, state) {
404
- const services = provider.services;
405
- state.removed = false;
406
- if (!services.includes(state.service)) {
407
- services.splice(Math.min(state.removedAtIndex, services.length), 0, state.service);
496
+ /** A failed check started at `checkStartedAt`; a buyer success since then outweighs it. */
497
+ _recordFailure(state, reason, httpStatus, checkStartedAt) {
498
+ if (state.lastTrafficAt !== null && state.lastTrafficAt >= checkStartedAt) {
499
+ debugLog(`[ModelHealth] ${state.providerName}/${state.service}: check failed, but a buyer request succeeded meanwhile`);
500
+ return;
408
501
  }
409
- provider.healthCheckAvailable = true;
502
+ const now = Date.now();
503
+ state.consecutiveFailures += 1;
504
+ state.reason = reason;
505
+ state.httpStatus = httpStatus;
506
+ state.failingSince ??= now;
507
+ debugWarn(`[ModelHealth] ${state.providerName}/${state.service}: health check failed `
508
+ + `(${state.consecutiveFailures}/${this._failureThreshold}, ${reason}) — ${state.lastDetail}`);
509
+ const crossed = state.status !== 'failing' && state.consecutiveFailures >= this._failureThreshold;
510
+ if (crossed)
511
+ state.status = 'failing';
512
+ // Below the threshold the announced status and its evidence stand; only
513
+ // a failing status is backed by this check.
514
+ if (state.status !== 'failing')
515
+ return;
516
+ state.checkedAt = now;
517
+ state.source = 'probe';
518
+ this._publish(state);
519
+ if (!crossed)
520
+ return;
410
521
  this._emitChange({
411
- provider: provider.name,
522
+ provider: state.providerName,
412
523
  providerId: state.providerId,
413
524
  service: state.service,
414
- status: 'restored',
415
- consecutiveFailures: 0,
416
- detail: state.lastDetail ?? 'probe succeeded',
525
+ status: 'failing',
526
+ consecutiveFailures: state.consecutiveFailures,
527
+ reason,
528
+ detail: state.lastDetail ?? 'check failed',
417
529
  });
418
530
  }
531
+ /** Writes the announced form of `state` onto the provider. */
532
+ _publish(state) {
533
+ const health = { status: state.status };
534
+ if (state.checkedAt !== null)
535
+ health.checkedAt = state.checkedAt;
536
+ if (state.source !== null)
537
+ health.source = state.source;
538
+ if (state.status === 'failing') {
539
+ if (state.failingSince !== null)
540
+ health.failingSince = state.failingSince;
541
+ if (state.consecutiveFailures > 0)
542
+ health.consecutiveFailures = Math.min(0xffff, state.consecutiveFailures);
543
+ if (state.reason !== null)
544
+ health.reason = state.reason;
545
+ if (state.httpStatus !== null && state.httpStatus >= 100 && state.httpStatus <= 599)
546
+ health.httpStatus = state.httpStatus;
547
+ }
548
+ // An own entry even for a service named like an Object.prototype property ("__proto__").
549
+ state.provider.serviceHealth ??= Object.create(null);
550
+ Object.defineProperty(state.provider.serviceHealth, state.service, { value: health, enumerable: true, writable: true, configurable: true });
551
+ }
419
552
  _emitChange(event) {
420
- const action = event.status === 'removed' ? 'unadvertised' : 're-advertised';
421
- debugLog(`[ModelHealth] ${event.provider}/${event.service} ${action} (${event.detail})`);
553
+ debugLog(`[ModelHealth] ${event.provider}/${event.service} ${event.status === 'failing' ? 'marked failing' : 'recovered'} (${event.detail})`);
422
554
  if (!this._onChange)
423
555
  return;
424
556
  try {
@@ -434,6 +566,26 @@ export class ModelHealthChecker {
434
566
  }
435
567
  }
436
568
  }
569
+ /** The announced reason for an account check that says the upstream cannot serve. */
570
+ function reasonForAccount(reason) {
571
+ if (/balance|credit|fund|top.?up|quota/i.test(reason))
572
+ return 'out_of_credit';
573
+ if (/key|auth|credential|forbidden|permission|token/i.test(reason))
574
+ return 'auth';
575
+ return 'upstream_error';
576
+ }
577
+ /** The announced reason for a failed check's upstream status. */
578
+ function reasonForStatus(statusCode) {
579
+ if (statusCode === 429)
580
+ return 'rate_limited';
581
+ if (statusCode === 402)
582
+ return 'out_of_credit';
583
+ if (statusCode === 401 || statusCode === 403)
584
+ return 'auth';
585
+ if (statusCode === 404)
586
+ return 'model_unavailable';
587
+ return 'upstream_error';
588
+ }
437
589
  /** Pick the probe request shape from the service's advertised API protocol. */
438
590
  function resolveProbeProtocol(provider, service) {
439
591
  return provider.serviceApiProtocols?.[service]?.[0] ?? 'openai-chat-completions';
@@ -507,7 +659,7 @@ export function buildHealthProbeRequest(service, protocol) {
507
659
  * collapses upstream network errors/timeouts to 502.)
508
660
  * - Everything else (400, 422, ...) — the endpoint is alive but the probe
509
661
  * itself was rejected (for example, an unsupported parameter): inconclusive,
510
- * so a working model is never unadvertised over a probe quirk.
662
+ * so a working model is never marked failing over a probe quirk.
511
663
  */
512
664
  export function classifyProbeStatus(statusCode) {
513
665
  if (statusCode >= 200 && statusCode < 400)
@@ -538,7 +690,7 @@ function summarizeBody(body) {
538
690
  async function withTimeout(promise, timeoutMs, message) {
539
691
  let timer;
540
692
  const timeout = new Promise((_, reject) => {
541
- timer = setTimeout(() => reject(new Error(message)), timeoutMs);
693
+ timer = setTimeout(() => reject(new ProbeTimeoutError(message)), timeoutMs);
542
694
  timer.unref?.();
543
695
  });
544
696
  try {