feat: prefer reseller for matching gateway requests

This commit is contained in:
david
2026-08-27 10:51:43 +02:00
parent 6b9f0f2284
commit 2b8409a499
5 changed files with 43 additions and 2 deletions

View File

@@ -34,6 +34,8 @@ services:
# Accept/Reclaim/Submit. Same values the buyer/suppliers use on mainnet.
ESCROW_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
ADVERT_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
# Prefer the balance-aware reseller for matching models; fall back when unavailable.
GATEWAY_PREFERRED_SUPPLIER_PKH: c447d050e23bc3c38ff8267047c6f8c978970e7176e2af48f316a7fb
# Browser Origins allowed to call the gateway cross-origin. The buyer
# frontend's "API Keys" page (served from marketplace.vector…) POSTs to
# /signup here. Comma-separated; empty disables CORS entirely.

View File

@@ -28,6 +28,7 @@
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const POS_INT_RE = /^[1-9]\d*$/;
const PKH_RE = /^[0-9a-fA-F]{56}$/;
export interface GatewayConfig {
masterKeyHex: string;
@@ -56,6 +57,8 @@ export interface GatewayConfig {
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
ocrCapabilityId: string;
/** Seller preferred for matching one-shot models. Other suppliers remain fallback. */
preferredSupplierPkh?: string;
}
function requireField(env: Record<string, string | undefined>, name: string): string {
@@ -115,6 +118,17 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
}
networkId = networkIdStr === "1" ? 1 : 0;
}
const preferredSupplierPkhRaw =
(env.GATEWAY_PREFERRED_SUPPLIER_PKH ?? "").trim();
if (
preferredSupplierPkhRaw !== "" &&
!PKH_RE.test(preferredSupplierPkhRaw)
) {
throw new Error(
"loadConfig: GATEWAY_PREFERRED_SUPPLIER_PKH must be a 28-byte hex payment-key hash",
);
}
return {
masterKeyHex,
@@ -142,6 +156,9 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
preferredSupplierPkh: preferredSupplierPkhRaw === ""
? undefined
: preferredSupplierPkhRaw.toLowerCase(),
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
.split(",")
.map((o) => o.trim().replace(/\/+$/, ""))

View File

@@ -97,6 +97,7 @@ async function runOneShot(
model: parsed.model,
capabilityId: CAPABILITY,
supplierPkh: parsed.supplierPkh,
preferredSupplierPkh: config.preferredSupplierPkh,
fetchFn,
});
if (candidates.length === 0) {

View File

@@ -60,6 +60,8 @@ export interface SelectSupplierOpts {
capabilityId: string;
/** Optional exact seller identity pin for deterministic agent routing. */
supplierPkh?: string;
/** Preferred seller ordered first when it is eligible. */
preferredSupplierPkh?: string;
fetchFn?: typeof globalThis.fetch;
/** supplier_pkhs eligible regardless of polled status — used after the demo
* executor frees a supplier the indexer poller (~20 s cadence) still reports
@@ -111,8 +113,16 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
status: raw.status,
});
}
// Prefer known-free over unknown so we route to confirmed-available first.
candidates.sort((a, b) => (a.status === "free" ? 0 : 1) - (b.status === "free" ? 0 : 1));
// Prefer the configured seller, then known-free over unknown.
candidates.sort((left, right) => {
const leftRank =
(left.supplierPkh === opts.preferredSupplierPkh ? 0 : 2) +
(left.status === "free" ? 0 : 1);
const rightRank =
(right.supplierPkh === opts.preferredSupplierPkh ? 0 : 2) +
(right.status === "free" ? 0 : 1);
return leftRank - rightRank;
});
return candidates;
}

View File

@@ -81,6 +81,17 @@ describe("routing/selectSupplier", () => {
expect(got.map((candidate) => candidate.supplierPkh)).toEqual(["s2"]);
});
it("orders the preferred supplier first and keeps fallback candidates", async () => {
const got = await selectCandidates({
indexerUrl: "http://ix",
model: "m",
capabilityId: "llm.text.generate.v1",
preferredSupplierPkh: "s2",
fetchFn: jsonResponse(rows),
});
expect(got.map((candidate) => candidate.supplierPkh)).toEqual(["s2", "s1"]);
});
it("routes chat.v1 separately", async () => {
const got = await selectCandidates({
indexerUrl: "http://ix",