diff --git a/deploy/mainnet/docker-compose.gateway.yml b/deploy/mainnet/docker-compose.gateway.yml index 09b1256..c19e2bc 100644 --- a/deploy/mainnet/docker-compose.gateway.yml +++ b/deploy/mainnet/docker-compose.gateway.yml @@ -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. diff --git a/gateway/src/config.ts b/gateway/src/config.ts index f11b5f0..e9d9e74 100644 --- a/gateway/src/config.ts +++ b/gateway/src/config.ts @@ -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, name: string): string { @@ -115,6 +118,17 @@ export function loadConfig(env: Record): 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): 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(/\/+$/, "")) diff --git a/gateway/src/openai/chatCompletions.ts b/gateway/src/openai/chatCompletions.ts index 7eb2306..afaffb8 100644 --- a/gateway/src/openai/chatCompletions.ts +++ b/gateway/src/openai/chatCompletions.ts @@ -97,6 +97,7 @@ async function runOneShot( model: parsed.model, capabilityId: CAPABILITY, supplierPkh: parsed.supplierPkh, + preferredSupplierPkh: config.preferredSupplierPkh, fetchFn, }); if (candidates.length === 0) { diff --git a/gateway/src/routing/selectSupplier.ts b/gateway/src/routing/selectSupplier.ts index c1708b8..2f41623 100644 --- a/gateway/src/routing/selectSupplier.ts +++ b/gateway/src/routing/selectSupplier.ts @@ -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 (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; } diff --git a/gateway/test/unit.test.ts b/gateway/test/unit.test.ts index be544ca..632edc9 100644 --- a/gateway/test/unit.test.ts +++ b/gateway/test/unit.test.ts @@ -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",