@antseed/node 0.2.156 → 0.2.157

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 (155) hide show
  1. package/dist/discovery/announcer.d.ts +13 -1
  2. package/dist/discovery/announcer.d.ts.map +1 -1
  3. package/dist/discovery/announcer.js +64 -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-unit-billing.d.ts +8 -0
  58. package/dist/discovery/metadata/fields/service-unit-billing.d.ts.map +1 -0
  59. package/dist/discovery/metadata/fields/service-unit-billing.js +81 -0
  60. package/dist/discovery/metadata/fields/service-unit-billing.js.map +1 -0
  61. package/dist/discovery/metadata/fields/verifications.d.ts +7 -0
  62. package/dist/discovery/metadata/fields/verifications.d.ts.map +1 -0
  63. package/dist/discovery/metadata/fields/verifications.js +96 -0
  64. package/dist/discovery/metadata/fields/verifications.js.map +1 -0
  65. package/dist/discovery/metadata/index.d.ts +27 -0
  66. package/dist/discovery/metadata/index.d.ts.map +1 -0
  67. package/dist/discovery/metadata/index.js +55 -0
  68. package/dist/discovery/metadata/index.js.map +1 -0
  69. package/dist/discovery/metadata/versions/v10.d.ts +5 -0
  70. package/dist/discovery/metadata/versions/v10.d.ts.map +1 -0
  71. package/dist/discovery/metadata/versions/v10.js +32 -0
  72. package/dist/discovery/metadata/versions/v10.js.map +1 -0
  73. package/dist/discovery/metadata/versions/v11.d.ts +5 -0
  74. package/dist/discovery/metadata/versions/v11.d.ts.map +1 -0
  75. package/dist/discovery/metadata/versions/v11.js +11 -0
  76. package/dist/discovery/metadata/versions/v11.js.map +1 -0
  77. package/dist/discovery/metadata/versions/v12.d.ts +5 -0
  78. package/dist/discovery/metadata/versions/v12.d.ts.map +1 -0
  79. package/dist/discovery/metadata/versions/v12.js +66 -0
  80. package/dist/discovery/metadata/versions/v12.js.map +1 -0
  81. package/dist/discovery/metadata/versions/v13.d.ts +5 -0
  82. package/dist/discovery/metadata/versions/v13.d.ts.map +1 -0
  83. package/dist/discovery/metadata/versions/v13.js +16 -0
  84. package/dist/discovery/metadata/versions/v13.js.map +1 -0
  85. package/dist/discovery/metadata/wire.d.ts +54 -0
  86. package/dist/discovery/metadata/wire.d.ts.map +1 -0
  87. package/dist/discovery/metadata/wire.js +149 -0
  88. package/dist/discovery/metadata/wire.js.map +1 -0
  89. package/dist/discovery/metadata-codec.d.ts +1 -38
  90. package/dist/discovery/metadata-codec.d.ts.map +1 -1
  91. package/dist/discovery/metadata-codec.js +3 -1154
  92. package/dist/discovery/metadata-codec.js.map +1 -1
  93. package/dist/discovery/metadata-server.d.ts +3 -2
  94. package/dist/discovery/metadata-server.d.ts.map +1 -1
  95. package/dist/discovery/metadata-server.js +4 -1
  96. package/dist/discovery/metadata-server.js.map +1 -1
  97. package/dist/discovery/metadata-validator.d.ts.map +1 -1
  98. package/dist/discovery/metadata-validator.js +62 -1
  99. package/dist/discovery/metadata-validator.js.map +1 -1
  100. package/dist/discovery/service-catalog.d.ts +13 -2
  101. package/dist/discovery/service-catalog.d.ts.map +1 -1
  102. package/dist/discovery/service-catalog.js +6 -1
  103. package/dist/discovery/service-catalog.js.map +1 -1
  104. package/dist/health/gas-auto-refuel.d.ts +138 -0
  105. package/dist/health/gas-auto-refuel.d.ts.map +1 -0
  106. package/dist/health/gas-auto-refuel.js +216 -0
  107. package/dist/health/gas-auto-refuel.js.map +1 -0
  108. package/dist/health/gas-health-monitor.d.ts +4 -0
  109. package/dist/health/gas-health-monitor.d.ts.map +1 -1
  110. package/dist/health/gas-health-monitor.js +13 -0
  111. package/dist/health/gas-health-monitor.js.map +1 -1
  112. package/dist/health/model-health-checker.d.ts +58 -33
  113. package/dist/health/model-health-checker.d.ts.map +1 -1
  114. package/dist/health/model-health-checker.js +250 -121
  115. package/dist/health/model-health-checker.js.map +1 -1
  116. package/dist/health/self-health-error.d.ts +20 -0
  117. package/dist/health/self-health-error.d.ts.map +1 -0
  118. package/dist/health/self-health-error.js +60 -0
  119. package/dist/health/self-health-error.js.map +1 -0
  120. package/dist/health/seller-events.d.ts +1 -1
  121. package/dist/health/seller-events.d.ts.map +1 -1
  122. package/dist/health/seller-events.js.map +1 -1
  123. package/dist/index.d.ts +7 -4
  124. package/dist/index.d.ts.map +1 -1
  125. package/dist/index.js +5 -2
  126. package/dist/index.js.map +1 -1
  127. package/dist/interfaces/seller-provider.d.ts +11 -5
  128. package/dist/interfaces/seller-provider.d.ts.map +1 -1
  129. package/dist/node.d.ts +8 -2
  130. package/dist/node.d.ts.map +1 -1
  131. package/dist/node.js +36 -89
  132. package/dist/node.js.map +1 -1
  133. package/dist/p2p/connection-manager.d.ts +5 -1
  134. package/dist/p2p/connection-manager.d.ts.map +1 -1
  135. package/dist/p2p/connection-manager.js +48 -13
  136. package/dist/p2p/connection-manager.js.map +1 -1
  137. package/dist/payments/chain-config.d.ts +11 -0
  138. package/dist/payments/chain-config.d.ts.map +1 -1
  139. package/dist/payments/chain-config.js +7 -0
  140. package/dist/payments/chain-config.js.map +1 -1
  141. package/dist/payments/evm/usdc-eth-swap-client.d.ts +45 -0
  142. package/dist/payments/evm/usdc-eth-swap-client.d.ts.map +1 -0
  143. package/dist/payments/evm/usdc-eth-swap-client.js +83 -0
  144. package/dist/payments/evm/usdc-eth-swap-client.js.map +1 -0
  145. package/dist/payments/index.d.ts +2 -0
  146. package/dist/payments/index.d.ts.map +1 -1
  147. package/dist/payments/index.js +2 -0
  148. package/dist/payments/index.js.map +1 -1
  149. package/dist/seller-request-handler.d.ts +9 -0
  150. package/dist/seller-request-handler.d.ts.map +1 -1
  151. package/dist/seller-request-handler.js +78 -12
  152. package/dist/seller-request-handler.js.map +1 -1
  153. package/dist/types/peer.d.ts +4 -2
  154. package/dist/types/peer.d.ts.map +1 -1
  155. package/package.json +3 -3
@@ -1,23 +1,28 @@
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;
13
+ class ProbeTimeoutError extends Error {
14
+ }
10
15
  /**
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.
16
+ * The seller's self health check. Every service stays announced; its latest
17
+ * result is published on `provider.serviceHealth`, which goes into signed
18
+ * metadata (v13+) so buyers can hide failing services from catalogs and
19
+ * routing and explain why a pinned request failed.
15
20
  *
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.
21
+ * Buyer traffic is the main evidence: a successful billable response marks a
22
+ * service healthy, and a service that had one within the last interval is not
23
+ * probed. Idle services get a minimal (1-token) completion each interval, and
24
+ * a failed buyer request triggers an early probe. A service is failing after
25
+ * `failureThreshold` failed checks in a row and recovers on the first pass.
21
26
  */
22
27
  export class ModelHealthChecker {
23
28
  _targets;
@@ -36,8 +41,8 @@ export class ModelHealthChecker {
36
41
  _onSweepComplete;
37
42
  /** Each upstream's credit as last seen; unset until something said. */
38
43
  _accounts = new Map();
39
- /** Services withdrawn before `onChange` could be told; reported on the first start. */
40
- _withdrawnAtInit = [];
44
+ /** Services marked failing before `onChange` could be told; reported on the first start. */
45
+ _failingAtInit = [];
41
46
  constructor(config) {
42
47
  this._targets = config.targets;
43
48
  this._intervalMs = config.intervalMs ?? DEFAULT_HEALTH_CHECK_INTERVAL_MS;
@@ -47,20 +52,23 @@ export class ModelHealthChecker {
47
52
  this._onUpstreamAccount = config.onUpstreamAccount;
48
53
  this._lowBalanceUsd = config.lowBalanceUsd;
49
54
  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.
55
+ // Providers retained after an OAuth initialization failure stay announced
56
+ // as failing until a real check passes.
57
+ const now = Date.now();
53
58
  for (const { provider } of this._targets) {
54
59
  if (provider.healthCheckAvailable !== false)
55
60
  continue;
56
- for (const [index, service] of provider.services.entries()) {
61
+ for (const service of provider.services) {
57
62
  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);
63
+ state.status = 'failing';
64
+ state.reason = 'init_failed';
65
+ state.failingSince = now;
66
+ state.checkedAt = now;
67
+ state.source = 'probe';
68
+ state.lastDetail = 'Initialization failed; awaiting a successful check';
69
+ this._publish(state);
70
+ this._failingAtInit.push(state);
62
71
  }
63
- provider.services.splice(0);
64
72
  }
65
73
  }
66
74
  /** Start periodic sweeps. The first sweep runs immediately (async). */
@@ -68,15 +76,16 @@ export class ModelHealthChecker {
68
76
  if (this._timer)
69
77
  return;
70
78
  this._stopped = false;
71
- for (const state of this._withdrawnAtInit.splice(0)) {
72
- if (!state.removed)
79
+ for (const state of this._failingAtInit.splice(0)) {
80
+ if (state.status !== 'failing')
73
81
  continue;
74
82
  this._emitChange({
75
83
  provider: state.providerName,
76
84
  providerId: state.providerId,
77
85
  service: state.service,
78
- status: 'removed',
86
+ status: 'failing',
79
87
  consecutiveFailures: state.consecutiveFailures,
88
+ reason: 'init_failed',
80
89
  detail: state.lastDetail ?? 'initialization failed',
81
90
  });
82
91
  }
@@ -94,7 +103,7 @@ export class ModelHealthChecker {
94
103
  this._timer = null;
95
104
  }
96
105
  }
97
- /** Probe every advertised (and previously removed) service once. */
106
+ /** Check every service once, skipping probes for services with recent buyer traffic. */
98
107
  async runSweep() {
99
108
  if (this._sweepInFlight)
100
109
  return this._sweepInFlight;
@@ -116,11 +125,16 @@ export class ModelHealthChecker {
116
125
  provider: state.providerName,
117
126
  providerId: state.providerId,
118
127
  service: state.service,
119
- advertised: !state.removed,
128
+ status: state.status,
120
129
  consecutiveFailures: state.consecutiveFailures,
130
+ failingSince: state.failingSince,
131
+ reason: state.reason,
132
+ checkedAt: state.checkedAt,
133
+ source: state.source,
121
134
  lastProbeAt: state.lastProbeAt,
122
135
  lastStatusCode: state.lastStatusCode,
123
136
  lastDetail: state.lastDetail,
137
+ lastTrafficAt: state.lastTrafficAt,
124
138
  }));
125
139
  }
126
140
  async _runSweep() {
@@ -128,39 +142,29 @@ export class ModelHealthChecker {
128
142
  if (this._stopped)
129
143
  return;
130
144
  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
- }
145
+ if (this._atCapacity(provider))
146
+ continue;
147
+ const services = [...provider.services];
151
148
  const hasFreeCheck = (candidate) => Boolean(candidate?.checkServiceHealth || candidate?.checkUpstreamAccount);
152
149
  const checkProvider = hasFreeCheck(target.probeProvider) ? target.probeProvider : provider;
150
+ // Free checks also read the account balance, so they run even with traffic.
153
151
  const checked = hasFreeCheck(checkProvider)
154
- ? [...services].filter((service) => !supportsHealthProbe(resolveProbeProtocol(provider, service)))
152
+ ? services.filter((service) => !supportsHealthProbe(resolveProbeProtocol(provider, service)))
155
153
  : [];
156
154
  if (checked.length > 0) {
157
155
  await this._checkServices(target, checkProvider, checked);
158
156
  }
157
+ const now = Date.now();
159
158
  for (const service of services) {
160
159
  if (this._stopped)
161
160
  return;
162
161
  if (checked.includes(service))
163
162
  continue;
163
+ const state = this._stateFor(provider, service);
164
+ if (state.lastTrafficAt !== null && now - state.lastTrafficAt < this._intervalMs) {
165
+ debugLog(`[ModelHealth] ${provider.name}/${service}: skipping probe, served a buyer ${Math.round((now - state.lastTrafficAt) / 1000)}s ago`);
166
+ continue;
167
+ }
164
168
  await this._probeService(target, service);
165
169
  }
166
170
  }
@@ -170,12 +174,13 @@ export class ModelHealthChecker {
170
174
  * upstream account check (key valid, account funded) once, and the model
171
175
  * check (model still offered) for every service. A service is unavailable
172
176
  * when either check says so, and available only when no check is unsure.
173
- * `unavailable` counts as a failed check, `available` re-advertises,
177
+ * `unavailable` counts as a failed check, `available` as a pass,
174
178
  * `unknown` changes nothing.
175
179
  */
176
180
  async _checkServices(target, checkProvider, services) {
177
181
  const provider = target.provider;
178
182
  const timed = (label, check) => withTimeout(Promise.resolve().then(check), this._probeTimeoutMs, `${label} for ${provider.name} timed out after ${this._probeTimeoutMs}ms`);
183
+ const checkStartedAt = Date.now();
179
184
  const [account, models] = await Promise.all([
180
185
  checkProvider.checkUpstreamAccount
181
186
  ? timed('account check', () => checkProvider.checkUpstreamAccount())
@@ -186,7 +191,8 @@ export class ModelHealthChecker {
186
191
  : undefined,
187
192
  ]);
188
193
  const balance = account?.status === 'available' ? account.balanceUsd : undefined;
189
- if (account?.status === 'unavailable' && (account.outOfCredit === true || isUpstreamOutOfCredit(null, account.reason))) {
194
+ const accountOutOfCredit = account?.status === 'unavailable' && (account.outOfCredit === true || isUpstreamOutOfCredit(null, account.reason));
195
+ if (accountOutOfCredit) {
190
196
  this._setAccount(provider, 'out', account.reason, null);
191
197
  }
192
198
  else if (account?.status === 'available') {
@@ -204,15 +210,17 @@ export class ModelHealthChecker {
204
210
  const model = models === undefined
205
211
  ? undefined
206
212
  : 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 };
213
+ if (account?.status === 'unavailable')
214
+ return { result: account, reason: accountOutOfCredit ? 'out_of_credit' : reasonForAccount(account.reason) };
215
+ if (model?.status === 'unavailable')
216
+ return { result: model, reason: 'model_unavailable' };
217
+ const unsure = [account, model].find((result) => result?.status === 'unknown');
218
+ return { result: unsure ?? { status: 'available', balanceUsd: balance }, reason: 'upstream_error' };
211
219
  };
212
220
  for (const service of services) {
213
221
  if (this._stopped)
214
222
  return;
215
- const result = combine(service);
223
+ const { result, reason } = combine(service);
216
224
  const state = this._stateFor(provider, service);
217
225
  if (result.status === 'unknown') {
218
226
  debugLog(`[ModelHealth] ${provider.name}/${service}: inconclusive (${result.reason ?? 'no result'})`);
@@ -222,14 +230,10 @@ export class ModelHealthChecker {
222
230
  state.lastStatusCode = null;
223
231
  if (result.status === 'unavailable') {
224
232
  state.lastDetail = result.reason;
225
- this._recordFailure(provider, state);
233
+ this._recordFailure(state, reason, null, checkStartedAt);
226
234
  continue;
227
235
  }
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);
236
+ this._recordSuccess(state, 'probe', balance === undefined ? 'Account check passed' : `Account check passed (balance $${balance.toFixed(2)})`);
233
237
  }
234
238
  }
235
239
  _stateFor(provider, service) {
@@ -246,12 +250,18 @@ export class ModelHealthChecker {
246
250
  providerName: provider.name,
247
251
  providerId: this._targets.find((target) => target.provider === provider)?.id ?? provider.name,
248
252
  service,
253
+ status: 'unknown',
249
254
  consecutiveFailures: 0,
250
- removed: false,
251
- removedAtIndex: 0,
255
+ failingSince: null,
256
+ reason: null,
257
+ httpStatus: null,
258
+ checkedAt: null,
259
+ source: null,
252
260
  lastProbeAt: null,
253
261
  lastStatusCode: null,
254
262
  lastDetail: null,
263
+ lastTrafficAt: null,
264
+ probeInFlight: false,
255
265
  };
256
266
  this._states.set(key, state);
257
267
  }
@@ -261,8 +271,13 @@ export class ModelHealthChecker {
261
271
  const provider = target.provider;
262
272
  const probeProvider = target.probeProvider ?? provider;
263
273
  const state = this._stateFor(provider, service);
264
- state.lastProbeAt = Date.now();
274
+ if (state.probeInFlight)
275
+ return;
276
+ state.probeInFlight = true;
277
+ const probeStartedAt = Date.now();
278
+ state.lastProbeAt = probeStartedAt;
265
279
  let outcome;
280
+ let reason;
266
281
  let errorText = null;
267
282
  try {
268
283
  const protocol = resolveProbeProtocol(provider, service);
@@ -276,6 +291,7 @@ export class ModelHealthChecker {
276
291
  const response = await withTimeout(probeProvider.handleRequest(request), this._probeTimeoutMs, `health probe for ${service} timed out after ${this._probeTimeoutMs}ms`);
277
292
  state.lastStatusCode = response.statusCode;
278
293
  outcome = classifyProbeStatus(response.statusCode);
294
+ reason = reasonForStatus(response.statusCode);
279
295
  // Classified on the body itself, not the 200-byte summary: a billing code can follow a long message.
280
296
  if (outcome !== 'healthy')
281
297
  errorText = decodeBody(response.body, 16_384);
@@ -288,17 +304,22 @@ export class ModelHealthChecker {
288
304
  state.lastDetail = err instanceof Error ? err.message : String(err);
289
305
  errorText = state.lastDetail;
290
306
  outcome = 'unhealthy';
307
+ reason = err instanceof ProbeTimeoutError ? 'timeout' : 'unreachable';
308
+ }
309
+ finally {
310
+ state.probeInFlight = false;
291
311
  }
292
312
  // A billing error is a failure whatever its status (Anthropic answers one with a 400);
293
313
  // a 404 or 422 is about the probe itself, so its text never marks the account.
294
314
  if (outcome !== 'healthy' && state.lastStatusCode !== 404 && state.lastStatusCode !== 422 && isUpstreamOutOfCredit(state.lastStatusCode, errorText)) {
295
315
  outcome = 'unhealthy';
316
+ reason = 'out_of_credit';
296
317
  this._setAccount(provider, 'out', state.lastDetail ?? 'out of credit', null);
297
318
  }
298
319
  if (outcome === 'inconclusive') {
299
320
  // 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.
321
+ // (for example, 400 for an unsupported probe parameter). Leave the
322
+ // service's health untouched.
302
323
  debugLog(`[ModelHealth] ${provider.name}/${service}: inconclusive (${state.lastDetail})`);
303
324
  return;
304
325
  }
@@ -306,22 +327,22 @@ export class ModelHealthChecker {
306
327
  // An answered completion means the account pays again (its balance is unknown here).
307
328
  if (this._accounts.get(provider) === 'out')
308
329
  this._setAccount(provider, 'ok', state.lastDetail ?? 'probe succeeded', null);
309
- state.consecutiveFailures = 0;
310
- if (state.removed) {
311
- this._restoreService(provider, state);
312
- }
330
+ this._recordSuccess(state, 'probe', state.lastDetail ?? 'probe succeeded');
313
331
  return;
314
332
  }
315
- this._recordFailure(provider, state);
333
+ this._recordFailure(state, reason, state.lastStatusCode, probeStartedAt);
316
334
  }
317
335
  /**
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).
336
+ * A live request's upstream answer (the seller's own traffic). A billable
337
+ * success marks `service` healthy and spares it the next probe. A failure
338
+ * the upstream is responsible for (auth, limits, 5xx) triggers an early
339
+ * probe instead of counting directly, so one buyer's odd request cannot mark
340
+ * a service failing. An out-of-credit error is reported at once, and a
341
+ * billable success after one means the account pays again (polling or
342
+ * downloading a video already paid for is not billable and proves nothing).
322
343
  */
323
- reportUpstreamResponse(provider, statusCode, body, billable = true) {
324
- const target = this._targets.find((entry) => entry.provider === provider || entry.probeProvider === provider);
344
+ reportUpstreamResponse(provider, statusCode, body, billable = true, service) {
345
+ const target = this._findTarget(provider);
325
346
  if (!target || this._stopped)
326
347
  return;
327
348
  if (statusCode >= 400 && isLiveResponseOutOfCredit(statusCode, body)) {
@@ -330,6 +351,66 @@ export class ModelHealthChecker {
330
351
  else if (statusCode < 400 && billable && this._accounts.get(target.provider) === 'out') {
331
352
  this._setAccount(target.provider, 'ok', `HTTP ${statusCode} on a request`, null);
332
353
  }
354
+ if (!service || !target.provider.services.includes(service))
355
+ return;
356
+ if (statusCode < 400) {
357
+ if (!billable)
358
+ return;
359
+ const state = this._stateFor(target.provider, service);
360
+ state.lastTrafficAt = Date.now();
361
+ state.lastStatusCode = statusCode;
362
+ this._recordSuccess(state, 'traffic', `HTTP ${statusCode} on a buyer request`);
363
+ return;
364
+ }
365
+ if (statusCode !== 404 && classifyProbeStatus(statusCode) === 'unhealthy') {
366
+ this._probeSoon(target, service);
367
+ }
368
+ }
369
+ /** A live request threw before the upstream answered (network error, timeout). */
370
+ reportUpstreamFailure(provider, service) {
371
+ const target = this._findTarget(provider);
372
+ if (!target || this._stopped || !target.provider.services.includes(service))
373
+ return;
374
+ this._probeSoon(target, service);
375
+ }
376
+ /** The announced health of `service`, if it has been checked. */
377
+ getServiceHealth(provider, service) {
378
+ const target = this._findTarget(provider);
379
+ return serviceHealthOf(target?.provider.serviceHealth, service);
380
+ }
381
+ _findTarget(provider) {
382
+ return this._targets.find((entry) => entry.provider === provider || entry.probeProvider === provider);
383
+ }
384
+ _probeSoon(target, service) {
385
+ // Image and video services are never probed (a probe would cost a real generation).
386
+ if (!supportsHealthProbe(resolveProbeProtocol(target.provider, service)))
387
+ return;
388
+ const state = this._stateFor(target.provider, service);
389
+ if (state.probeInFlight)
390
+ return;
391
+ if (state.lastProbeAt !== null && Date.now() - state.lastProbeAt < LIVE_FAILURE_PROBE_MIN_GAP_MS)
392
+ return;
393
+ // A full provider answers its own 429, which says nothing about the upstream.
394
+ if (this._atCapacity(target.provider))
395
+ return;
396
+ debugLog(`[ModelHealth] ${target.provider.name}/${service}: buyer request failed upstream, probing now`);
397
+ void this._probeService(target, service).catch((err) => {
398
+ debugWarn(`[ModelHealth] early probe failed: ${err instanceof Error ? err.message : err}`);
399
+ });
400
+ }
401
+ /** Probes never compete with paying buyers for concurrency slots. */
402
+ _atCapacity(provider) {
403
+ try {
404
+ const capacity = provider.getCapacity();
405
+ if (capacity.current < capacity.max)
406
+ return false;
407
+ debugLog(`[ModelHealth] Skipping ${provider.name}: at capacity (${capacity.current}/${capacity.max})`);
408
+ return true;
409
+ }
410
+ catch {
411
+ // getCapacity should not throw; probe anyway if it does.
412
+ return false;
413
+ }
333
414
  }
334
415
  _lowLine() {
335
416
  const value = typeof this._lowBalanceUsd === 'function' ? this._lowBalanceUsd() : this._lowBalanceUsd;
@@ -344,14 +425,13 @@ export class ModelHealthChecker {
344
425
  if (next === 'ok' && previous === undefined)
345
426
  return;
346
427
  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
428
  const event = {
349
429
  provider: provider.name,
350
430
  providerId: target?.id ?? provider.name,
351
431
  status: next === 'out' ? 'out_of_credit' : next === 'low' ? 'low_balance' : 'funded',
352
432
  detail,
353
433
  balanceUsd,
354
- services,
434
+ services: [...provider.services],
355
435
  };
356
436
  debugWarn(`[ModelHealth] ${event.providerId}: upstream ${event.status} (${detail})`);
357
437
  if (!this._onUpstreamAccount)
@@ -366,59 +446,88 @@ export class ModelHealthChecker {
366
446
  debugWarn(`[ModelHealth] onUpstreamAccount failed: ${err instanceof Error ? err.message : err}`);
367
447
  }
368
448
  }
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
- }
449
+ _recordSuccess(state, source, detail) {
450
+ const wasFailing = state.status === 'failing';
451
+ state.status = 'healthy';
452
+ state.consecutiveFailures = 0;
453
+ state.failingSince = null;
454
+ state.reason = null;
455
+ state.httpStatus = null;
456
+ state.checkedAt = Date.now();
457
+ state.source = source;
458
+ state.lastDetail = detail;
459
+ if (state.provider.healthCheckAvailable === false)
460
+ state.provider.healthCheckAvailable = true;
461
+ this._publish(state);
462
+ if (!wasFailing)
386
463
  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
464
  this._emitChange({
395
- provider: provider.name,
465
+ provider: state.providerName,
396
466
  providerId: state.providerId,
397
467
  service: state.service,
398
- status: 'removed',
399
- consecutiveFailures: state.consecutiveFailures,
400
- detail: state.lastDetail ?? 'probe failed',
468
+ status: 'recovered',
469
+ consecutiveFailures: 0,
470
+ detail,
401
471
  });
402
472
  }
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);
473
+ /** A failed check started at `checkStartedAt`; a buyer success since then outweighs it. */
474
+ _recordFailure(state, reason, httpStatus, checkStartedAt) {
475
+ if (state.lastTrafficAt !== null && state.lastTrafficAt >= checkStartedAt) {
476
+ debugLog(`[ModelHealth] ${state.providerName}/${state.service}: check failed, but a buyer request succeeded meanwhile`);
477
+ return;
408
478
  }
409
- provider.healthCheckAvailable = true;
479
+ const now = Date.now();
480
+ state.consecutiveFailures += 1;
481
+ state.reason = reason;
482
+ state.httpStatus = httpStatus;
483
+ state.failingSince ??= now;
484
+ debugWarn(`[ModelHealth] ${state.providerName}/${state.service}: health check failed `
485
+ + `(${state.consecutiveFailures}/${this._failureThreshold}, ${reason}) — ${state.lastDetail}`);
486
+ const crossed = state.status !== 'failing' && state.consecutiveFailures >= this._failureThreshold;
487
+ if (crossed)
488
+ state.status = 'failing';
489
+ // Below the threshold the announced status and its evidence stand; only
490
+ // a failing status is backed by this check.
491
+ if (state.status !== 'failing')
492
+ return;
493
+ state.checkedAt = now;
494
+ state.source = 'probe';
495
+ this._publish(state);
496
+ if (!crossed)
497
+ return;
410
498
  this._emitChange({
411
- provider: provider.name,
499
+ provider: state.providerName,
412
500
  providerId: state.providerId,
413
501
  service: state.service,
414
- status: 'restored',
415
- consecutiveFailures: 0,
416
- detail: state.lastDetail ?? 'probe succeeded',
502
+ status: 'failing',
503
+ consecutiveFailures: state.consecutiveFailures,
504
+ reason,
505
+ detail: state.lastDetail ?? 'check failed',
417
506
  });
418
507
  }
508
+ /** Writes the announced form of `state` onto the provider. */
509
+ _publish(state) {
510
+ const health = { status: state.status };
511
+ if (state.checkedAt !== null)
512
+ health.checkedAt = state.checkedAt;
513
+ if (state.source !== null)
514
+ health.source = state.source;
515
+ if (state.status === 'failing') {
516
+ if (state.failingSince !== null)
517
+ health.failingSince = state.failingSince;
518
+ if (state.consecutiveFailures > 0)
519
+ health.consecutiveFailures = Math.min(0xffff, state.consecutiveFailures);
520
+ if (state.reason !== null)
521
+ health.reason = state.reason;
522
+ if (state.httpStatus !== null && state.httpStatus >= 100 && state.httpStatus <= 599)
523
+ health.httpStatus = state.httpStatus;
524
+ }
525
+ // An own entry even for a service named like an Object.prototype property ("__proto__").
526
+ state.provider.serviceHealth ??= Object.create(null);
527
+ Object.defineProperty(state.provider.serviceHealth, state.service, { value: health, enumerable: true, writable: true, configurable: true });
528
+ }
419
529
  _emitChange(event) {
420
- const action = event.status === 'removed' ? 'unadvertised' : 're-advertised';
421
- debugLog(`[ModelHealth] ${event.provider}/${event.service} ${action} (${event.detail})`);
530
+ debugLog(`[ModelHealth] ${event.provider}/${event.service} ${event.status === 'failing' ? 'marked failing' : 'recovered'} (${event.detail})`);
422
531
  if (!this._onChange)
423
532
  return;
424
533
  try {
@@ -434,6 +543,26 @@ export class ModelHealthChecker {
434
543
  }
435
544
  }
436
545
  }
546
+ /** The announced reason for an account check that says the upstream cannot serve. */
547
+ function reasonForAccount(reason) {
548
+ if (/balance|credit|fund|top.?up|quota/i.test(reason))
549
+ return 'out_of_credit';
550
+ if (/key|auth|credential|forbidden|permission|token/i.test(reason))
551
+ return 'auth';
552
+ return 'upstream_error';
553
+ }
554
+ /** The announced reason for a failed check's upstream status. */
555
+ function reasonForStatus(statusCode) {
556
+ if (statusCode === 429)
557
+ return 'rate_limited';
558
+ if (statusCode === 402)
559
+ return 'out_of_credit';
560
+ if (statusCode === 401 || statusCode === 403)
561
+ return 'auth';
562
+ if (statusCode === 404)
563
+ return 'model_unavailable';
564
+ return 'upstream_error';
565
+ }
437
566
  /** Pick the probe request shape from the service's advertised API protocol. */
438
567
  function resolveProbeProtocol(provider, service) {
439
568
  return provider.serviceApiProtocols?.[service]?.[0] ?? 'openai-chat-completions';
@@ -507,7 +636,7 @@ export function buildHealthProbeRequest(service, protocol) {
507
636
  * collapses upstream network errors/timeouts to 502.)
508
637
  * - Everything else (400, 422, ...) — the endpoint is alive but the probe
509
638
  * itself was rejected (for example, an unsupported parameter): inconclusive,
510
- * so a working model is never unadvertised over a probe quirk.
639
+ * so a working model is never marked failing over a probe quirk.
511
640
  */
512
641
  export function classifyProbeStatus(statusCode) {
513
642
  if (statusCode >= 200 && statusCode < 400)
@@ -538,7 +667,7 @@ function summarizeBody(body) {
538
667
  async function withTimeout(promise, timeoutMs, message) {
539
668
  let timer;
540
669
  const timeout = new Promise((_, reject) => {
541
- timer = setTimeout(() => reject(new Error(message)), timeoutMs);
670
+ timer = setTimeout(() => reject(new ProbeTimeoutError(message)), timeoutMs);
542
671
  timer.unref?.();
543
672
  });
544
673
  try {