@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.
- package/dist/discovery/announcer.d.ts +13 -1
- package/dist/discovery/announcer.d.ts.map +1 -1
- package/dist/discovery/announcer.js +64 -9
- package/dist/discovery/announcer.js.map +1 -1
- package/dist/discovery/http-metadata-resolver.d.ts +1 -1
- package/dist/discovery/http-metadata-resolver.d.ts.map +1 -1
- package/dist/discovery/http-metadata-resolver.js +7 -1
- package/dist/discovery/http-metadata-resolver.js.map +1 -1
- package/dist/discovery/index.d.ts +3 -2
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/index.js +2 -1
- package/dist/discovery/index.js.map +1 -1
- package/dist/discovery/metadata/define-version.d.ts +40 -0
- package/dist/discovery/metadata/define-version.d.ts.map +1 -0
- package/dist/discovery/metadata/define-version.js +104 -0
- package/dist/discovery/metadata/define-version.js.map +1 -0
- package/dist/discovery/metadata/field.d.ts +26 -0
- package/dist/discovery/metadata/field.d.ts.map +1 -0
- package/dist/discovery/metadata/field.js +2 -0
- package/dist/discovery/metadata/field.js.map +1 -0
- package/dist/discovery/metadata/fields/offerings.d.ts +7 -0
- package/dist/discovery/metadata/fields/offerings.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/offerings.js +52 -0
- package/dist/discovery/metadata/fields/offerings.js.map +1 -0
- package/dist/discovery/metadata/fields/on-chain-stats.d.ts +6 -0
- package/dist/discovery/metadata/fields/on-chain-stats.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/on-chain-stats.js +30 -0
- package/dist/discovery/metadata/fields/on-chain-stats.js.map +1 -0
- package/dist/discovery/metadata/fields/peer-capabilities.d.ts +4 -0
- package/dist/discovery/metadata/fields/peer-capabilities.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/peer-capabilities.js +21 -0
- package/dist/discovery/metadata/fields/peer-capabilities.js.map +1 -0
- package/dist/discovery/metadata/fields/peer-profile.d.ts +11 -0
- package/dist/discovery/metadata/fields/peer-profile.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/peer-profile.js +58 -0
- package/dist/discovery/metadata/fields/peer-profile.js.map +1 -0
- package/dist/discovery/metadata/fields/provider-basics.d.ts +12 -0
- package/dist/discovery/metadata/fields/provider-basics.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/provider-basics.js +87 -0
- package/dist/discovery/metadata/fields/provider-basics.js.map +1 -0
- package/dist/discovery/metadata/fields/service-api-protocols.d.ts +4 -0
- package/dist/discovery/metadata/fields/service-api-protocols.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/service-api-protocols.js +40 -0
- package/dist/discovery/metadata/fields/service-api-protocols.js.map +1 -0
- package/dist/discovery/metadata/fields/service-capabilities.d.ts +7 -0
- package/dist/discovery/metadata/fields/service-capabilities.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/service-capabilities.js +229 -0
- package/dist/discovery/metadata/fields/service-capabilities.js.map +1 -0
- package/dist/discovery/metadata/fields/service-categories.d.ts +4 -0
- package/dist/discovery/metadata/fields/service-categories.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/service-categories.js +37 -0
- package/dist/discovery/metadata/fields/service-categories.js.map +1 -0
- package/dist/discovery/metadata/fields/service-health.d.ts +7 -0
- package/dist/discovery/metadata/fields/service-health.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/service-health.js +90 -0
- package/dist/discovery/metadata/fields/service-health.js.map +1 -0
- package/dist/discovery/metadata/fields/service-unit-billing.d.ts +8 -0
- package/dist/discovery/metadata/fields/service-unit-billing.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/service-unit-billing.js +81 -0
- package/dist/discovery/metadata/fields/service-unit-billing.js.map +1 -0
- package/dist/discovery/metadata/fields/verifications.d.ts +7 -0
- package/dist/discovery/metadata/fields/verifications.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/verifications.js +96 -0
- package/dist/discovery/metadata/fields/verifications.js.map +1 -0
- package/dist/discovery/metadata/index.d.ts +27 -0
- package/dist/discovery/metadata/index.d.ts.map +1 -0
- package/dist/discovery/metadata/index.js +55 -0
- package/dist/discovery/metadata/index.js.map +1 -0
- package/dist/discovery/metadata/versions/v10.d.ts +5 -0
- package/dist/discovery/metadata/versions/v10.d.ts.map +1 -0
- package/dist/discovery/metadata/versions/v10.js +32 -0
- package/dist/discovery/metadata/versions/v10.js.map +1 -0
- package/dist/discovery/metadata/versions/v11.d.ts +5 -0
- package/dist/discovery/metadata/versions/v11.d.ts.map +1 -0
- package/dist/discovery/metadata/versions/v11.js +11 -0
- package/dist/discovery/metadata/versions/v11.js.map +1 -0
- package/dist/discovery/metadata/versions/v12.d.ts +5 -0
- package/dist/discovery/metadata/versions/v12.d.ts.map +1 -0
- package/dist/discovery/metadata/versions/v12.js +66 -0
- package/dist/discovery/metadata/versions/v12.js.map +1 -0
- package/dist/discovery/metadata/versions/v13.d.ts +5 -0
- package/dist/discovery/metadata/versions/v13.d.ts.map +1 -0
- package/dist/discovery/metadata/versions/v13.js +16 -0
- package/dist/discovery/metadata/versions/v13.js.map +1 -0
- package/dist/discovery/metadata/wire.d.ts +54 -0
- package/dist/discovery/metadata/wire.d.ts.map +1 -0
- package/dist/discovery/metadata/wire.js +149 -0
- package/dist/discovery/metadata/wire.js.map +1 -0
- package/dist/discovery/metadata-codec.d.ts +1 -38
- package/dist/discovery/metadata-codec.d.ts.map +1 -1
- package/dist/discovery/metadata-codec.js +3 -1154
- package/dist/discovery/metadata-codec.js.map +1 -1
- package/dist/discovery/metadata-server.d.ts +3 -2
- package/dist/discovery/metadata-server.d.ts.map +1 -1
- package/dist/discovery/metadata-server.js +4 -1
- package/dist/discovery/metadata-server.js.map +1 -1
- package/dist/discovery/metadata-validator.d.ts.map +1 -1
- package/dist/discovery/metadata-validator.js +62 -1
- package/dist/discovery/metadata-validator.js.map +1 -1
- package/dist/discovery/service-catalog.d.ts +13 -2
- package/dist/discovery/service-catalog.d.ts.map +1 -1
- package/dist/discovery/service-catalog.js +6 -1
- package/dist/discovery/service-catalog.js.map +1 -1
- package/dist/health/gas-auto-refuel.d.ts +138 -0
- package/dist/health/gas-auto-refuel.d.ts.map +1 -0
- package/dist/health/gas-auto-refuel.js +216 -0
- package/dist/health/gas-auto-refuel.js.map +1 -0
- package/dist/health/gas-health-monitor.d.ts +4 -0
- package/dist/health/gas-health-monitor.d.ts.map +1 -1
- package/dist/health/gas-health-monitor.js +13 -0
- package/dist/health/gas-health-monitor.js.map +1 -1
- package/dist/health/model-health-checker.d.ts +58 -33
- package/dist/health/model-health-checker.d.ts.map +1 -1
- package/dist/health/model-health-checker.js +250 -121
- package/dist/health/model-health-checker.js.map +1 -1
- package/dist/health/self-health-error.d.ts +20 -0
- package/dist/health/self-health-error.d.ts.map +1 -0
- package/dist/health/self-health-error.js +60 -0
- package/dist/health/self-health-error.js.map +1 -0
- package/dist/health/seller-events.d.ts +1 -1
- package/dist/health/seller-events.d.ts.map +1 -1
- package/dist/health/seller-events.js.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/interfaces/seller-provider.d.ts +11 -5
- package/dist/interfaces/seller-provider.d.ts.map +1 -1
- package/dist/node.d.ts +8 -2
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +36 -89
- package/dist/node.js.map +1 -1
- package/dist/p2p/connection-manager.d.ts +5 -1
- package/dist/p2p/connection-manager.d.ts.map +1 -1
- package/dist/p2p/connection-manager.js +48 -13
- package/dist/p2p/connection-manager.js.map +1 -1
- package/dist/payments/chain-config.d.ts +11 -0
- package/dist/payments/chain-config.d.ts.map +1 -1
- package/dist/payments/chain-config.js +7 -0
- package/dist/payments/chain-config.js.map +1 -1
- package/dist/payments/evm/usdc-eth-swap-client.d.ts +45 -0
- package/dist/payments/evm/usdc-eth-swap-client.d.ts.map +1 -0
- package/dist/payments/evm/usdc-eth-swap-client.js +83 -0
- package/dist/payments/evm/usdc-eth-swap-client.js.map +1 -0
- package/dist/payments/index.d.ts +2 -0
- package/dist/payments/index.d.ts.map +1 -1
- package/dist/payments/index.js +2 -0
- package/dist/payments/index.js.map +1 -1
- package/dist/seller-request-handler.d.ts +9 -0
- package/dist/seller-request-handler.d.ts.map +1 -1
- package/dist/seller-request-handler.js +78 -12
- package/dist/seller-request-handler.js.map +1 -1
- package/dist/types/peer.d.ts +4 -2
- package/dist/types/peer.d.ts.map +1 -1
- 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
|
|
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
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
40
|
-
|
|
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
|
|
51
|
-
//
|
|
52
|
-
|
|
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
|
|
61
|
+
for (const service of provider.services) {
|
|
57
62
|
const state = this._stateFor(provider, service);
|
|
58
|
-
state.
|
|
59
|
-
state.
|
|
60
|
-
state.
|
|
61
|
-
|
|
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.
|
|
72
|
-
if (
|
|
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: '
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
?
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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(
|
|
233
|
+
this._recordFailure(state, reason, null, checkStartedAt);
|
|
226
234
|
continue;
|
|
227
235
|
}
|
|
228
|
-
state
|
|
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
|
-
|
|
251
|
-
|
|
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.
|
|
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
|
|
301
|
-
//
|
|
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.
|
|
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(
|
|
333
|
+
this._recordFailure(state, reason, state.lastStatusCode, probeStartedAt);
|
|
316
334
|
}
|
|
317
335
|
/**
|
|
318
|
-
* A live request's upstream answer (the seller's own traffic)
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
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.
|
|
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
|
-
|
|
370
|
-
state.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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:
|
|
465
|
+
provider: state.providerName,
|
|
396
466
|
providerId: state.providerId,
|
|
397
467
|
service: state.service,
|
|
398
|
-
status: '
|
|
399
|
-
consecutiveFailures:
|
|
400
|
-
detail
|
|
468
|
+
status: 'recovered',
|
|
469
|
+
consecutiveFailures: 0,
|
|
470
|
+
detail,
|
|
401
471
|
});
|
|
402
472
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
state.
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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:
|
|
499
|
+
provider: state.providerName,
|
|
412
500
|
providerId: state.providerId,
|
|
413
501
|
service: state.service,
|
|
414
|
-
status: '
|
|
415
|
-
consecutiveFailures:
|
|
416
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
670
|
+
timer = setTimeout(() => reject(new ProbeTimeoutError(message)), timeoutMs);
|
|
542
671
|
timer.unref?.();
|
|
543
672
|
});
|
|
544
673
|
try {
|