feat: add balance-aware provider resale agent

This commit is contained in:
david
2026-08-27 09:54:53 +02:00
parent 8baf127c77
commit e54eb55f2b
41 changed files with 3579 additions and 38 deletions

View File

@@ -30,6 +30,7 @@ import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/ty
import type { Utxo, OutputReference } from "../../packages/shared/src/chain/ChainProvider.js";
import type { ChatMessage } from "../../packages/shared/src/tx/types.js";
import type { ProgressEvent } from "../../buyer/src/sdk/types.js";
import { BOUNDED_INPUT_DETAIL_MARKER } from "../../packages/shared/src/tx/inputBound.js";
// ─── Fixtures ─────────────────────────────────────────────────────────────────
@@ -473,6 +474,43 @@ describe("Marketplace.submitPrompt() — rejection paths", () => {
expect(e.name).toBe("ReceiptVerificationError");
expect(e.reason).toBe("test reason");
});
it("rejects a bounded-input request before posting escrow", async () => {
const advert = makeActiveAdvert({
detail_uri: `https://supplier.example.com/reseller${BOUNDED_INPUT_DETAIL_MARKER}`,
});
seedAdvertUtxo(chain, advert);
const submitSpy = vi.spyOn(chain, "submitTx");
fetchSpy.mockImplementation((url: unknown) => {
if (String(url).endsWith("/capability")) {
return jsonFetch({
capability_id: advert.capability_id,
model: advert.model,
max_output_tokens: advert.max_output_tokens,
max_processing_ms: advert.max_processing_ms,
max_input_tokens: 1,
price_lovelace: advert.price_lovelace.toString(),
advert_ref: `${ADVERT_REF.txHash}#${ADVERT_REF.index}`,
supplier_pkh: advert.supplier_pkh,
pub_key_hex: supplier.pubKeyHex,
});
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({
advertRef: ADVERT_REF,
messages: SAMPLE_MESSAGES,
payment_lovelace: PAYMENT,
}),
).rejects.toSatisfy(
(error: unknown) =>
error instanceof TxConstructionError &&
error.reason === "input_cap_exceeded",
);
expect(submitSpy).not.toHaveBeenCalled();
});
});
// ─── M1-F-1: isSyncThrow dead-code removal (RED) ─────────────────────────────

View File

@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
import { chatInputTokenUpperBound } from "../../packages/shared/src/tx/inputBound.js";
import { CapacityGate } from "../../supplier/src/reseller/capacityGate.js";
import type {
CapacityProvider,
ProviderCapacityReading,
} from "../../supplier/src/reseller/provider.js";
import { OpenRouterCapacityProvider } from "../../supplier/src/reseller/openRouterCapacity.js";
function reading(remaining: bigint): ProviderCapacityReading {
return {
provider: "openrouter",
keyLimitUsdNanos: 10_000_000_000n,
keyRemainingUsdNanos: remaining,
limitReset: "monthly",
checkedAtMs: 1_000,
pricing: {
model: "provider/model",
promptUsdNanosPerToken: 1_000_000n,
completionUsdNanosPerToken: 2_000_000n,
requestUsdNanos: 0n,
fetchedAtMs: 1_000,
},
};
}
describe("chatInputTokenUpperBound", () => {
it("uses canonical UTF-8 bytes and fixed chat overhead", () => {
expect(chatInputTokenUpperBound([])).toBe(5);
expect(chatInputTokenUpperBound([{ role: "user", content: "a" }])).toBe(42);
expect(chatInputTokenUpperBound([{ role: "user", content: "é" }])).toBe(43);
expect(chatInputTokenUpperBound([{ role: "user", content: "🙂" }])).toBe(45);
});
it("adds each message independently", () => {
expect(chatInputTokenUpperBound([
{ role: "system", content: "x" },
{ role: "user", content: "yz" },
])).toBe(83);
});
it("counts tool-call arguments present on direct SDK messages", () => {
expect(chatInputTokenUpperBound([{
role: "assistant",
content: "",
tool_calls: [{
id: "x",
type: "function",
function: { name: "f", arguments: "a".repeat(1000) },
}],
}])).toBe(1129);
});
});
describe("CapacityGate", () => {
it("opens only when allowance above reserve covers a worst-case job", async () => {
let current = reading(2_000_000_000n);
const provider: CapacityProvider = {
id: "openrouter",
readCapacity: vi.fn(async () => current),
};
const gate = new CapacityGate({
provider,
model: "provider/model",
reserveUsd: "1",
maxInputTokens: 100,
maxOutputTokens: 10,
staleAfterMs: 5_000,
now: () => 1_000,
});
const open = await gate.refresh();
expect(open.canServe).toBe(true);
expect(open.worstCaseJobUsdNanos).toBe(120_000_000n);
expect(open.sellableUsdNanos).toBe(1_000_000_000n);
expect(open.availableJobs).toBe(8n);
current = reading(1_100_000_000n);
const closed = await gate.refresh();
expect(closed.canServe).toBe(false);
expect(closed.reason).toBe("insufficient_for_job");
});
it("fails closed when the provider check fails", async () => {
const provider: CapacityProvider = {
id: "openrouter",
readCapacity: vi.fn(async () => {
throw new Error("provider down");
}),
};
const gate = new CapacityGate({
provider,
model: "provider/model",
reserveUsd: "0",
maxInputTokens: 10,
maxOutputTokens: 10,
staleAfterMs: 5_000,
});
const snapshot = await gate.refresh();
expect(snapshot.canServe).toBe(false);
expect(snapshot.reason).toBe("provider_unavailable");
});
});
describe("OpenRouterCapacityProvider", () => {
it("reads a capped key and rounds model prices up", async () => {
const fetchFn = vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith("/v1/key")) {
return new Response(JSON.stringify({
data: { limit: 5, limit_remaining: 4.5, limit_reset: "monthly" },
}), { status: 200 });
}
return new Response(JSON.stringify({
data: [{
id: "provider/model",
pricing: { prompt: "0.00000007952", completion: "0.00000015904" },
}],
}), { status: 200 });
});
const provider = new OpenRouterCapacityProvider({
baseUrl: "https://openrouter.ai/api",
apiKey: "secret",
timeoutMs: 1_000,
fetchFn: fetchFn as typeof fetch,
now: () => 123,
});
const result = await provider.readCapacity("provider/model");
expect(result.keyRemainingUsdNanos).toBe(4_499_999_999n);
expect(result.pricing.promptUsdNanosPerToken).toBe(80n);
expect(result.pricing.completionUsdNanosPerToken).toBe(160n);
});
it("rejects an uncapped inference key", async () => {
const fetchFn = vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith("/v1/key")) {
return new Response(JSON.stringify({
data: { limit: null, limit_remaining: null, limit_reset: null },
}), { status: 200 });
}
return new Response(JSON.stringify({ data: [] }), { status: 200 });
});
const provider = new OpenRouterCapacityProvider({
baseUrl: "https://openrouter.ai/api",
apiKey: "secret",
timeoutMs: 1_000,
fetchFn: fetchFn as typeof fetch,
});
await expect(provider.readCapacity("provider/model")).rejects.toThrow(/finite/);
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { redactPublicPreview } from "../../supplier/src/reseller/evidenceStore.js";
describe("redactPublicPreview", () => {
it("removes common credentials and personal data before publication", () => {
const preview = redactPublicPreview(
[
"SUPPLIER_PRIV_KEY_HEX=" + "a".repeat(64),
"password=hunter2",
"Authorization: Bearer secret-token",
'{"password":"hunter two words"}',
"GATEWAY_API_KEY=plain-secret-value",
"Authorization: Basic dXNlcjpwYXNz",
"email test@example.com",
"https://example.com/private",
"eyJheader.payload.signature",
"ghp_1234567890abcdefghijklmnop",
].join(" "),
1000,
);
expect(preview).not.toContain("hunter2");
expect(preview).not.toContain("secret-token");
expect(preview).not.toContain("hunter two words");
expect(preview).not.toContain("plain-secret-value");
expect(preview).not.toContain("dXNlcjpwYXNz");
expect(preview).not.toContain("test@example.com");
expect(preview).not.toContain("example.com/private");
expect(preview).not.toContain("eyJheader");
expect(preview).not.toContain("ghp_");
expect(preview).not.toContain("a".repeat(64));
});
it("applies the configured public preview limit", () => {
const preview = redactPublicPreview("x".repeat(100), 20);
expect(preview).toHaveLength(20);
expect(preview.endsWith("…")).toBe(true);
});
});

View File

@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadResellerSetupConfig } from "../../supplier/src/reseller/setupConfig.js";
const tempDirs: string[] = [];
async function configPath(overrides: Record<string, unknown> = {}): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "reseller-config-"));
tempDirs.push(dir);
const path = join(dir, "config.json");
await writeFile(path, JSON.stringify({
networkId: 1,
ogmiosUrl: "https://ogmios.vector.mainnet.apexfusion.org",
endpointUrl: "https://mp-suppliers-openrouter-reseller.vector.apexfusion.org",
marketplaceModel: "vector/resold-model",
openRouterModel: "provider/model",
maxInputTokens: 4096,
maxOutputTokens: 256,
maxProcessingMs: 600000,
inferenceTimeoutMs: 180000,
priceLovelace: "200000",
supplierBondLovelace: "1000000",
buyerBondLovelace: "1000000",
reserveUsd: "1.00",
composeFile: "deploy/mainnet/docker-compose.supplier-openrouter-reseller.yml",
...overrides,
}));
return path;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true })));
});
describe("loadResellerSetupConfig", () => {
it("accepts a bounded mainnet setup", async () => {
const config = await loadResellerSetupConfig(await configPath());
expect(config.inferenceTimeoutMs).toBe(180000);
expect(config.endpointUrl).toBe(
"https://mp-suppliers-openrouter-reseller.vector.apexfusion.org",
);
});
it("rejects unsupported Ogmios transports", async () => {
await expect(loadResellerSetupConfig(await configPath({
ogmiosUrl: "wss://ogmios.example",
}))).rejects.toThrow(/ogmiosUrl/);
});
it("rejects endpoint credentials, query data, and fragments", async () => {
for (const endpointUrl of [
"https://user:secret@example.com",
"https://example.com?token=secret",
"https://example.com#secret",
]) {
await expect(loadResellerSetupConfig(await configPath({ endpointUrl })))
.rejects.toThrow(/plain HTTPS origin/);
}
});
it("requires settlement time outside the inference timeout", async () => {
await expect(loadResellerSetupConfig(await configPath({
inferenceTimeoutMs: 500000,
}))).rejects.toThrow(/does not leave time/);
});
});

View File

@@ -331,3 +331,62 @@ describe("loadConfig() — OPENAI_SESSION_PASSTHROUGH", () => {
expect(cfg.openaiModelOverride).toBe("openclaw");
});
});
describe("loadConfig() — OpenRouter reseller", () => {
const resellerEnv = () => ({
...buildSampleEnv(),
CAPABILITY_KIND: "chat",
LLM_BACKEND: "openai",
OPENAI_BASE_URL: "https://openrouter.ai/api",
OPENAI_API_KEY: "seller-only-key",
OPENAI_MAX_TOKENS: "256",
RESELLER_PROVIDER: "openrouter",
RESELLER_DATABASE_URL: "postgresql://reseller:secret@postgres:5432/reseller",
RESELLER_RESERVE_USD: "1.25",
RESELLER_MAX_INPUT_TOKENS: "4096",
});
it("stays disabled for every legacy config", () => {
expect(loadConfig(buildSampleEnv()).reseller).toBeNull();
});
it("parses a complete balance-aware config", () => {
const cfg = loadConfig(resellerEnv());
expect(cfg.reseller).toMatchObject({
provider: "openrouter",
reserveUsd: "1.25",
maxInputTokens: 4096,
pollIntervalMs: 30_000,
providerTimeoutMs: 10_000,
});
});
it("rejects partial and unsafe configurations", () => {
expect(() => loadConfig({
...buildSampleEnv(),
RESELLER_RESERVE_USD: "1",
})).toThrow(/RESELLER_PROVIDER/);
expect(() => loadConfig({
...resellerEnv(),
OPENAI_BASE_URL: "https://example.com",
})).toThrow(/OPENAI_BASE_URL/);
for (const OPENAI_BASE_URL of [
"http://openrouter.ai/api",
"https://openrouter.ai/v1",
"https://user:secret@openrouter.ai/api",
]) {
expect(() => loadConfig({
...resellerEnv(),
OPENAI_BASE_URL,
})).toThrow(/OPENAI_BASE_URL/);
}
expect(() => loadConfig({
...resellerEnv(),
OPENAI_MAX_TOKENS: "",
})).toThrow(/OPENAI_MAX_TOKENS/);
expect(() => loadConfig({
...resellerEnv(),
RESELLER_RESERVE_USD: "-1",
})).toThrow(/RESELLER_RESERVE_USD/);
});
});

View File

@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import request from "supertest";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { createApp } from "../../supplier/src/server.js";
import { SupplierState } from "../../supplier/src/state.js";
import type { ResellerRuntime } from "../../supplier/src/reseller/runtime.js";
import { buildSampleConfig } from "../fixtures/supplier-side/sample-config.js";
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
const ESCROW_REF = `${"f".repeat(64)}#0`;
function fakeReseller(canServe: boolean, maxInputTokens = 100): ResellerRuntime {
const snapshot = {
provider: "openrouter",
model: "provider/model",
canServe,
reason: canServe ? "ready" as const : "insufficient_for_job" as const,
checkedAtMs: 1_000,
lastSuccessAtMs: 1_000,
keyLimitUsdNanos: 5_000_000_000n,
remainingAllowanceUsdNanos: 2_000_000_000n,
reserveUsdNanos: 1_000_000_000n,
sellableUsdNanos: 1_000_000_000n,
worstCaseJobUsdNanos: 100_000_000n,
promptPriceUsdNanosPerToken: 1n,
completionPriceUsdNanosPerToken: 1n,
requestPriceUsdNanos: 0n,
availableJobs: 10n,
limitReset: "monthly",
error: null,
};
return {
maxInputTokens,
effectiveStatus: vi.fn(() => canServe ? "free" : "offline"),
capacitySnapshot: vi.fn(() => snapshot),
refreshCapacity: vi.fn(async () => snapshot),
} as unknown as ResellerRuntime;
}
describe("reseller supplier routes", () => {
it("reports offline without retiring an exhausted supplier", async () => {
const app = createApp({
chain: new MockChainProvider(),
state: new SupplierState(),
config: buildSampleConfig(),
supplierKey: buildSupplierWalletKey(),
reseller: fakeReseller(false),
});
const response = await request(app).get("/status");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
status: "offline",
capacity: {
provider: "openrouter",
reason: "insufficient_for_job",
},
});
});
it("rejects oversized input before any chain submission", async () => {
const chain = new MockChainProvider();
const submitSpy = vi.spyOn(chain, "submitTx");
const app = createApp({
chain,
state: new SupplierState(),
config: buildSampleConfig(),
supplierKey: buildSupplierWalletKey(),
reseller: fakeReseller(true, 1),
});
const response = await request(app)
.post("/v1/chat/completions")
.set("X-Escrow-Ref", ESCROW_REF)
.send({ messages: [{ role: "user", content: "too large" }] });
expect(response.status).toBe(413);
expect(response.body.reason).toBe("input_cap_exceeded");
expect(submitSpy).not.toHaveBeenCalled();
});
});

View File

@@ -104,6 +104,28 @@ describe("buildSubmitTx() — happy path", () => {
const newUtxo = await chain.queryUtxo({ txHash: result.expectedTxHash, index: 0 });
expect(newUtxo!.lovelace).toBe(TOTAL_LOCKED);
});
it("journals signed Submit CBOR before broadcast", async () => {
const supplier = buildSupplierWalletKey();
const utxo = buildClaimedEscrowUtxo();
let hookCalled = false;
const result = await buildSubmitTx({
chain,
supplierKey: supplier,
escrowRef: utxo.ref,
receiptHash: VALID_RECEIPT_HASH,
beforeSubmit: async (built) => {
hookCalled = true;
expect(
await chain.queryUtxo({ txHash: built.expectedTxHash, index: 0 }),
).toBeNull();
},
});
expect(hookCalled).toBe(true);
expect(
await chain.queryUtxo({ txHash: result.expectedTxHash, index: 0 }),
).not.toBeNull();
});
});
// ─── Rejection: double-submit ────────────────────────────────────────────────