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. # Accept/Reclaim/Submit. Same values the buyer/suppliers use on mainnet.
ESCROW_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0 ESCROW_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
ADVERT_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1 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 # Browser Origins allowed to call the gateway cross-origin. The buyer
# frontend's "API Keys" page (served from marketplace.vector…) POSTs to # frontend's "API Keys" page (served from marketplace.vector…) POSTs to
# /signup here. Comma-separated; empty disables CORS entirely. # /signup here. Comma-separated; empty disables CORS entirely.

View File

@@ -28,6 +28,7 @@
const HEX64_RE = /^[0-9a-fA-F]{64}$/; const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const POS_INT_RE = /^[1-9]\d*$/; const POS_INT_RE = /^[1-9]\d*$/;
const PKH_RE = /^[0-9a-fA-F]{56}$/;
export interface GatewayConfig { export interface GatewayConfig {
masterKeyHex: string; masterKeyHex: string;
@@ -56,6 +57,8 @@ export interface GatewayConfig {
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped /** Capability id the /v1/ocr/extract route escrows against. Model-scoped
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */ * per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
ocrCapabilityId: string; 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 { 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; 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 { return {
masterKeyHex, masterKeyHex,
@@ -142,6 +156,9 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000), walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500), sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1", ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
preferredSupplierPkh: preferredSupplierPkhRaw === ""
? undefined
: preferredSupplierPkhRaw.toLowerCase(),
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "") corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
.split(",") .split(",")
.map((o) => o.trim().replace(/\/+$/, "")) .map((o) => o.trim().replace(/\/+$/, ""))

View File

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

View File

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

View File

@@ -81,6 +81,17 @@ describe("routing/selectSupplier", () => {
expect(got.map((candidate) => candidate.supplierPkh)).toEqual(["s2"]); 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 () => { it("routes chat.v1 separately", async () => {
const got = await selectCandidates({ const got = await selectCandidates({
indexerUrl: "http://ix", indexerUrl: "http://ix",