feat: add balance-aware provider resale agent
This commit is contained in:
@@ -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) ─────────────────────────────
|
||||
|
||||
151
tests/unit/reseller-capacity.test.ts
Normal file
151
tests/unit/reseller-capacity.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
38
tests/unit/reseller-redaction.test.ts
Normal file
38
tests/unit/reseller-redaction.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
68
tests/unit/reseller-setup-config.test.ts
Normal file
68
tests/unit/reseller-setup-config.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
78
tests/unit/supplier-reseller-routes.test.ts
Normal file
78
tests/unit/supplier-reseller-routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user