Skip to content

Commit 5d99074

Browse files
authored
fix: recover AntSeed routing from unhealthy sellers (#103)
* fix: recover AntSeed routing from unhealthy sellers * fix: preserve AntSeed reachability evidence
1 parent 2b129b9 commit 5d99074

16 files changed

Lines changed: 474 additions & 39 deletions

.github/workflows/ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ jobs:
9797
- name: AntSeed CLI resolves its package dependencies inside the image
9898
run: docker run --rm --entrypoint antseed unhardcoded-antseed:ci --help >/dev/null
9999

100+
# CLI >=0.1.153 moved the default buyer router into a separately installed
101+
# plugin. Loading it here (with runtime updates disabled by the image) proves
102+
# a fresh pod does not need npm access before it can join the P2P network.
103+
- name: Vendored AntSeed router plugin loads without runtime npm
104+
run: >-
105+
docker run --rm --network none --entrypoint node unhardcoded-antseed:ci
106+
--input-type=module --eval
107+
"const {loadRouterPlugin}=await import('/usr/local/lib/node_modules/@antseed/cli/dist/plugins/loader.js');
108+
const plugin=await loadRouterPlugin('local');
109+
if (plugin.type !== 'router') throw new Error('local router plugin did not load');"
110+
100111
# --network host so the container reaches the runner's postgres service on
101112
# localhost; a bridged container cannot. No `|| true` on the run: a
102113
# container that fails to start must fail the job, not fall through to a

Dockerfile.antseed

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,19 @@ FROM node:22-slim AS antseed-dependencies
1111
RUN apt-get update \
1212
&& apt-get install -y --no-install-recommends python3 make g++ \
1313
&& for install_attempt in 1 2 3; do \
14-
if npm install -g @antseed/cli@0.1.128 pg@8.16.3; then break; fi; \
14+
if npm install -g @antseed/cli@0.1.153 pg@8.16.3; then break; fi; \
1515
rm -rf /usr/local/lib/node_modules/@antseed \
1616
/usr/local/lib/node_modules/pg /usr/local/bin/antseed; \
1717
if [ "${install_attempt}" = 3 ]; then exit 1; fi; \
1818
done \
19+
&& mkdir -p /opt/antseed-plugins \
20+
&& cd /opt/antseed-plugins \
21+
&& npm init -y \
22+
&& for install_attempt in 1 2 3; do \
23+
if npm install --ignore-scripts --save-exact @antseed/router-local@0.1.45; then break; fi; \
24+
rm -rf node_modules package-lock.json; \
25+
if [ "${install_attempt}" = 3 ]; then exit 1; fi; \
26+
done \
1927
&& rm -rf /var/lib/apt/lists/*
2028

2129
FROM node:22-slim
@@ -28,10 +36,15 @@ RUN apt-get update \
2836
# `pg` is the market writer's Postgres client (write-market.js upserts peer_offers
2937
# into the shared host store). NODE_PATH lets the scripts require these globals.
3038
COPY --from=antseed-dependencies /usr/local/lib/node_modules /usr/local/lib/node_modules
39+
# Since CLI 0.1.153 the default buyer router is a separately installed plugin.
40+
# Vendor the compatible release in the exact directory the CLI loads from so a
41+
# fresh container never reaches npm before it can join the P2P network.
42+
COPY --from=antseed-dependencies /opt/antseed-plugins /root/.antseed/plugins
3143
# Docker COPY dereferences the npm-created bin symlink. Recreate it explicitly
3244
# so Node resolves package imports from @antseed/cli instead of /usr/local/bin.
3345
RUN ln -s ../lib/node_modules/@antseed/cli/dist/cli/index.js /usr/local/bin/antseed
34-
ENV NODE_PATH=/usr/local/lib/node_modules
46+
ENV NODE_PATH=/usr/local/lib/node_modules \
47+
ANTSEED_SKIP_PLUGIN_UPDATE_CHECK=1
3548

3649
# Every non-test file under antseed/ — an explicit list silently ships a module
3750
# whose `require('./x.js')` has no target, and the control server then dies at

antseed/broadcast.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
// ---------------------------------------------------------------------------
2727
// WHAT CANNOT BE PROVED FROM CLI OUTPUT — read this before widening the rule.
2828
//
29-
// `@antseed/cli@0.1.128`'s `buyer deposit` runs SIX RPC calls inside one ora
29+
// `@antseed/cli@0.1.153`'s `buyer deposit --onchain` runs several RPC calls inside one ora
3030
// spinner, and TWO of them are broadcasts (an unconditional ERC-20 `approve`,
3131
// then the deposit itself), each followed by a `wait()` receipt poll:
3232
//

antseed/cli-args.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
'use strict';
2+
3+
// Keep the dashboard wallet API insulated from CLI syntax drift. Since
4+
// @antseed/cli 0.1.137, `buyer deposit` is the QR/watch flow; the legacy direct
5+
// on-chain operation moved to `buyer deposit --onchain <amount>`. The control
6+
// endpoint already receives funded-wallet amounts and must retain that exact
7+
// transaction semantics.
8+
function walletCommandArgs(verb, amount) {
9+
if (verb === 'deposit') return ['buyer', 'deposit', '--onchain', amount];
10+
if (verb === 'withdraw') return ['buyer', 'withdraw', amount];
11+
throw new Error(`unsupported wallet command: ${verb}`);
12+
}
13+
14+
module.exports = { walletCommandArgs };

antseed/cli-args.test.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert/strict');
5+
const { walletCommandArgs } = require('./cli-args.js');
6+
7+
test('direct deposits use the post-0.1.137 --onchain syntax', () => {
8+
assert.deepEqual(walletCommandArgs('deposit', '1.25'),
9+
['buyer', 'deposit', '--onchain', '1.25']);
10+
});
11+
12+
test('withdraw syntax remains positional', () => {
13+
assert.deepEqual(walletCommandArgs('withdraw', '2'),
14+
['buyer', 'withdraw', '2']);
15+
});
16+
17+
test('unknown wallet verbs fail closed', () => {
18+
assert.throws(() => walletCommandArgs('sweep', '1'), /unsupported wallet command/);
19+
});

antseed/control.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const { createQueue } = require('./queue.js');
2525
// reasoning about what is and is not provable from CLI stdio is long enough to
2626
// deserve a file. See antseed/broadcast.js.
2727
const { classifyCliFailure } = require('./broadcast.js');
28+
const { walletCommandArgs } = require('./cli-args.js');
2829

2930
const path = require('path');
3031

@@ -197,7 +198,7 @@ const server = http.createServer(async (req, res) => {
197198
return refuse(res, 400, 'amount must be a positive USDC value (<=6 decimals, <=' + MAX_AMOUNT_USDC + ')');
198199
}
199200
return serialize(async () => {
200-
const r = await run(['buyer', verb, amount], DEPOSIT_TIMEOUT_MS);
201+
const r = await run(walletCommandArgs(verb, amount), DEPOSIT_TIMEOUT_MS);
201202
if (r.code !== 0) {
202203
const why = (r.stderr || r.stdout || 'cli failed').slice(0, 600);
203204
// A CLI we KILLED on the timeout may already have broadcast the

antseed/write-market.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,34 @@ for (const peer of fresh.peers) {
4747
const maxc = posIntOrNull(peer.maxConcurrency);
4848
const rep = numOrNull(peer.onChainReputationScore);
4949
const lastSeen = numOrNull(peer.lastSeen);
50+
// `lastSeen` is only a DHT advertisement sighting. `lastReachedAt` is the
51+
// buyer's stronger signal that it actually connected to the peer; retain both
52+
// so host admission never mistakes a repeatedly re-announced dead seller for
53+
// an inference-ready one.
54+
const lastReachedAt = numOrNull(peer.lastReachedAt);
5055
for (const pricing of Object.values(peer.providerPricing || {})) {
5156
for (const [service, sp] of Object.entries((pricing || {}).services || {})) {
5257
rows.push([
5358
peer.peerId, service,
5459
numOr0(sp.inputUsdPerMillion), numOr0(sp.outputUsdPerMillion),
5560
numOrNull(sp.cachedInputUsdPerMillion),
56-
maxc, rep, lastSeen, now, now, now,
61+
maxc, rep, lastSeen, lastReachedAt, now, now, now,
5762
]);
5863
}
5964
}
6065
}
6166

6267
const UPSERT = `INSERT INTO peer_offers
6368
(peer_id, service, price_in, price_out, price_cached_in, max_concurrency,
64-
reputation, last_seen, observed_at, first_seen, fetched_at)
65-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
69+
reputation, last_seen, last_reached_at, observed_at, first_seen, fetched_at)
70+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
6671
ON CONFLICT (peer_id, service) DO UPDATE SET
6772
price_in=EXCLUDED.price_in, price_out=EXCLUDED.price_out,
6873
price_cached_in=EXCLUDED.price_cached_in,
6974
max_concurrency=EXCLUDED.max_concurrency, reputation=EXCLUDED.reputation,
70-
last_seen=EXCLUDED.last_seen, observed_at=EXCLUDED.observed_at,
75+
last_seen=EXCLUDED.last_seen,
76+
last_reached_at=COALESCE(EXCLUDED.last_reached_at, peer_offers.last_reached_at),
77+
observed_at=EXCLUDED.observed_at,
7178
fetched_at=EXCLUDED.fetched_at`; // first_seen preserved across conflicts
7279

7380
(async () => {

host_store.py

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,17 @@ def _retention_days() -> int:
132132
served_by TEXT,
133133
ok BOOLEAN NOT NULL,
134134
latency_ms DOUBLE PRECISION,
135+
error_kind TEXT,
136+
http_status INTEGER,
135137
tools_requested BOOLEAN,
136138
tool_calls_emitted BOOLEAN
137139
)""",
138140
"CREATE INDEX IF NOT EXISTS idx_route_obs_ts ON route_observations(ts)",
139141
"CREATE INDEX IF NOT EXISTS idx_route_obs_route"
140142
" ON route_observations(provider_id, model_family, served_by, ts)",
141143
# #4c: learned tool capability is derived from these per-attempt signals.
144+
"ALTER TABLE route_observations ADD COLUMN IF NOT EXISTS error_kind TEXT",
145+
"ALTER TABLE route_observations ADD COLUMN IF NOT EXISTS http_status INTEGER",
142146
"ALTER TABLE route_observations ADD COLUMN IF NOT EXISTS tools_requested BOOLEAN",
143147
"ALTER TABLE route_observations ADD COLUMN IF NOT EXISTS tool_calls_emitted BOOLEAN",
144148
"""CREATE TABLE IF NOT EXISTS settings_overrides (
@@ -224,11 +228,13 @@ def _retention_days() -> int:
224228
max_concurrency INTEGER,
225229
reputation DOUBLE PRECISION,
226230
last_seen BIGINT,
231+
last_reached_at BIGINT,
227232
observed_at BIGINT NOT NULL,
228233
first_seen BIGINT,
229234
fetched_at BIGINT,
230235
PRIMARY KEY (peer_id, service)
231236
)""",
237+
"ALTER TABLE peer_offers ADD COLUMN IF NOT EXISTS last_reached_at BIGINT",
232238
"CREATE INDEX IF NOT EXISTS idx_peer_offers_observed ON peer_offers(observed_at)",
233239
# The antseed buyer's status (escrow + session pin + wallet), one row per
234240
# buyer pid. WRITTEN by the antseed sidecar (write-status.js on the poll loop
@@ -901,12 +907,14 @@ def _insert_route_observation(row: dict[str, Any]) -> None:
901907
conn.execute(
902908
"INSERT INTO route_observations"
903909
" (ts, provider_id, model_family, served_by, ok, latency_ms,"
904-
" tools_requested, tool_calls_emitted)"
905-
" VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
910+
" error_kind, http_status, tools_requested, tool_calls_emitted)"
911+
" VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
906912
(int(row.get("ts") or time.time() * 1000),
907913
row.get("provider_id"), row.get("model_family"), row.get("served_by"),
908914
bool(row.get("ok")),
909915
float(row["latency_ms"]) if row.get("latency_ms") is not None else None,
916+
row.get("error_kind"),
917+
int(row["http_status"]) if row.get("http_status") is not None else None,
910918
bool(row.get("tools_requested")), bool(row.get("tool_calls_emitted"))))
911919
except Exception as exc: # noqa: BLE001 — the fold must never break a request
912920
_log.warning("host_store route observation insert failed: %s", exc)
@@ -942,6 +950,107 @@ def route_stats(window_ms: int = 900_000) -> dict[str, dict[str, Any]]:
942950
return {}
943951

944952

953+
# Failures in these classes describe the request rather than the route. They
954+
# remain in route_stats() for backwards-compatible measured reliability, but do
955+
# not put a marketplace seller into a durable cooldown.
956+
_ROUTE_HEALTH_NEUTRAL_ERRORS = frozenset({
957+
"bad_request", "content_filter", "context_overflow", "payment_required",
958+
})
959+
960+
# A failure in one service can prove the whole peer unhealthy only for transport,
961+
# capacity and server faults. A model_unavailable/404 is deliberately absent: it
962+
# quarantines that peer+family route without hiding the peer's other models.
963+
_PEER_HEALTH_FAILURE_ERRORS = frozenset({
964+
"rate_limit", "timeout", "server_error", "network_error", "auth_error",
965+
"bad_response", "unknown",
966+
})
967+
968+
969+
def _fold_health_rows(rows: list[tuple], provider_id: str, *, peer: bool) -> dict:
970+
"""Fold newest-first observation rows into consecutive attributable failures.
971+
972+
The SQL caps each identity to a bounded recent sample. A success ends the
973+
current failure streak; client/request faults are ignored. Old observations
974+
have no error_kind, so they remain route evidence (the safe migration
975+
direction) but are not promoted to peer-wide blame.
976+
"""
977+
out: dict[str, dict[str, Any]] = {}
978+
for family, served_by, ts, ok, error_kind, http_status in rows:
979+
key = served_by if peer else f"{provider_id}|{family}|{served_by}"
980+
state = out.setdefault(key, {
981+
"consecutive_failures": 0,
982+
"last_failure_at": None,
983+
"last_success_at": None,
984+
"latest_error_kind": None,
985+
"latest_http_status": None,
986+
"sample_count": 0,
987+
"_ended": False,
988+
})
989+
state["sample_count"] += 1
990+
if state["_ended"]:
991+
continue
992+
if ok:
993+
state["last_success_at"] = int(ts)
994+
state["_ended"] = True
995+
continue
996+
kind = str(error_kind) if error_kind else None
997+
attributable = (
998+
kind in _PEER_HEALTH_FAILURE_ERRORS if peer
999+
else kind not in _ROUTE_HEALTH_NEUTRAL_ERRORS
1000+
)
1001+
if not attributable:
1002+
continue
1003+
state["consecutive_failures"] += 1
1004+
if state["last_failure_at"] is None:
1005+
state["last_failure_at"] = int(ts)
1006+
state["latest_error_kind"] = kind
1007+
state["latest_http_status"] = (
1008+
int(http_status) if http_status is not None else None)
1009+
for key in list(out):
1010+
state = out[key]
1011+
state.pop("_ended", None)
1012+
# A group containing only neutral failures carries no health evidence.
1013+
if not state["consecutive_failures"] and state["last_success_at"] is None:
1014+
out.pop(key)
1015+
return out
1016+
1017+
1018+
def marketplace_route_health(provider_id: str, window_ms: int = 86_400_000,
1019+
sample_limit: int = 64) -> dict[str, dict]:
1020+
"""Bounded durable health for one marketplace provider.
1021+
1022+
Returns ``{"routes": {provider|family|peer: state}, "peers": {peer: state}}``.
1023+
Route state drives service-specific cooldowns; peer state is restricted to
1024+
failures that can safely be attributed across services. Both are derived
1025+
from the shared Postgres ledger, so replicas and restarts agree.
1026+
"""
1027+
try:
1028+
cutoff = int(time.time() * 1000) - max(0, window_ms)
1029+
limit = max(1, min(int(sample_limit), 512))
1030+
1031+
def read(partition: str, order_prefix: str) -> list[tuple]:
1032+
with _get_pool().connection() as conn:
1033+
cur = conn.execute(
1034+
"SELECT model_family,served_by,ts,ok,error_kind,http_status"
1035+
" FROM (SELECT id,model_family,served_by,ts,ok,error_kind,http_status,"
1036+
f" row_number() OVER (PARTITION BY {partition} ORDER BY ts DESC,id DESC) AS rn"
1037+
" FROM route_observations WHERE provider_id=%s AND ts >= %s) recent"
1038+
" WHERE rn <= %s"
1039+
f" ORDER BY {order_prefix},ts DESC,id DESC",
1040+
(provider_id, cutoff, limit))
1041+
return list(cur.fetchall())
1042+
1043+
route_rows = read("model_family,served_by", "served_by,model_family")
1044+
peer_rows = read("served_by", "served_by")
1045+
return {
1046+
"routes": _fold_health_rows(route_rows, provider_id, peer=False),
1047+
"peers": _fold_health_rows(peer_rows, provider_id, peer=True),
1048+
}
1049+
except Exception as exc: # noqa: BLE001 — admission degrades to legacy behavior
1050+
_log.warning("host_store marketplace_route_health failed: %s", exc)
1051+
return {"routes": {}, "peers": {}}
1052+
1053+
9451054
def provider_attempt_counts(provider_id: str, window_ms: int = 3_600_000) -> dict[str, int]:
9461055
"""{ok, failed, total} attempts for one provider over the last `window_ms`,
9471056
across every family and peer. The wallet keeper's "is this provider fully
@@ -1752,7 +1861,7 @@ def recent_logins(limit: int = 100) -> list[dict[str, Any]]:
17521861
# window/housekeeping columns (observed_at/first_seen/fetched_at) stay internal.
17531862
_PEER_OFFER_FIELDS = ("peer_id", "service", "price_in", "price_out",
17541863
"price_cached_in", "max_concurrency", "reputation",
1755-
"last_seen")
1864+
"last_seen", "last_reached_at")
17561865

17571866

17581867
def peer_offers(window_ms: int = 900_000) -> list[dict[str, Any]]:

llm_router_host.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,8 @@ def _fold_route_outcome(request: dict, result: dict,
805805
host_store.observe_route_call_async({
806806
"ts": int(time.time() * 1000), "provider_id": pid, "model_family": fam,
807807
"served_by": peer_id or pid, "ok": ok, "latency_ms": result.get("latency_ms"),
808+
"error_kind": None if ok else result.get("error_kind"),
809+
"http_status": None if ok else result.get("http_status"),
808810
"tools_requested": bool(request.get("tools")),
809811
"tool_calls_emitted": bool((result.get("response") or {}).get("tool_calls"))})
810812
# Cache affinity + the per-session meter are DERIVED on the fly from `calls`

providers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ def _present(provider_id):
160160
"offers_top_n": {
161161
"type": "int", "default": env_int("ANTSEED_OFFERS_TOP_N", 3),
162162
"min": 1, "max": 10, "label": "Offers per family (top-N peers)",
163-
"help": "Cheapest distinct seller peers surfaced per family to rotate between on failure."},
163+
"help": "Best viable distinct sellers per family after route health, "
164+
"reachability and reputation admission; price ranks inside that set."},
164165
"reputation_min": {
165166
"type": "float", "default": env_float("ANTSEED_REPUTATION_MIN", 0),
166167
"min": 0, "max": 100, "label": "Min peer on-chain reputation",

0 commit comments

Comments
 (0)