Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,26 @@ ollama serve & ollama pull qwen2.5:7b
| POST | `/v1/chat/completions` | **dynamic** | OpenAI-compatible; price by model + tokens |
| POST | `/v1/chat` | fixed | Simple `{prompt}` demo, default model |
| POST | `/v1/providers` | free | Register an external provider (Phase 2) |
| POST | `/v1/providers/{id}/flag` | free | **Operator only** (admin token): flag/unflag a provider |
| POST | `/v1/feedback` | free | Submit reputation (Phase 3) |

## Enforcement & trust

Be explicit about how bad providers are handled, so the catalog can be trusted:

- **Who enforces:** the **marketplace operator**. A provider can be **flagged**, which
de-routes it everywhere and drops it from the catalog. Flagging is a **manual operator
action** via `POST /v1/providers/{id}/flag`, gated by `ADMIN_TOKEN` (the `X-Admin-Token`
header). It is **not** a key/multisig and **not** a crowd vote — it is the operator.
- **No auto-detection yet:** there is no automatic cheat-detection. A provider is flagged
only when the operator acts on a complaint or manual review. Agents/users can *report*; only
the operator can *flag*.
- **Transparency:** `flagged` + `flagReason` are public on `GET /v1/providers`. A flagged
provider never serves traffic and is excluded from routing and the catalog.
- **Mainnet safety:** the flag endpoint is disabled on mainnet unless `ADMIN_TOKEN` is set.
- **Roadmap:** move the flag decision on-chain to a stake-weighted `legion-gov` vote (the
legion decides, not the operator).

## Pricing (dynamic, two axes)

```
Expand Down
41 changes: 36 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"cf-typegen": "wrangler types"
},
"dependencies": {
"@stacks/transactions": "^7.5.0",
"hono": "^4.7.0",
"x402-stacks": "^2.0.3"
},
Expand Down
29 changes: 28 additions & 1 deletion src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export interface Provider {
health: HealthResult | null;
/** Marketplace status, derived from health. */
status: 'pending' | 'live' | 'degraded' | 'down';
/** Flagged by the marketplace (cheating / abuse). Orthogonal to health: a
* flagged provider is de-routed regardless of health, and stays flagged
* across health checks until explicitly cleared. */
flagged?: boolean;
/** Why it was flagged + when (for the audit trail / UI). */
flagReason?: string;
/** ERC-8004 reputation (Phase 3). null until it has feedback. */
reputation: { agentId: number; score?: number } | null;
registeredAt: string;
Expand Down Expand Up @@ -118,7 +124,10 @@ export async function registerProvider(kv: KV, input: ProviderInput): Promise<Pr

if (!name) throw new Error('name is required (set it in schema.json or the form)');
assertSafeEndpoint(endpoint, input.allowLocal ?? false); // SSRF guard
if (!/^S[PM][0-9A-Z]+$/.test(payoutAddress)) throw new Error('payoutAddress must be a mainnet Stacks address (SP… / SM…)');
// Basic hygiene only: a Stacks address on either network (mainnet SP/SM,
// testnet ST/SN). Not a security check — the bond gate requires this to be a
// real bonded principal and settlement verifies the actual on-chain txid.
if (!/^S[PMTN][0-9A-Z]+$/.test(payoutAddress)) throw new Error('payoutAddress must be a Stacks address (SP/SM mainnet, ST/SN testnet)');
if (!models.length) throw new Error('at least one model (with an id) is required');

const providers = await listProviders(kv);
Expand Down Expand Up @@ -161,6 +170,24 @@ export async function setHealth(kv: KV, id: string, health: HealthResult): Promi
return p;
}

/** Flag (or clear) a provider. A flagged provider is de-routed regardless of
* health; clearing restores it to normal routing. Enforcement lives here, not
* in the inference plane. */
export async function setFlag(kv: KV, id: string, flagged: boolean, reason?: string): Promise<Provider | undefined> {
const providers = await listProviders(kv);
const p = providers.find((x) => x.id === id);
if (!p) return undefined;
if (flagged) {
p.flagged = true;
if (reason) p.flagReason = reason;
} else {
delete p.flagged;
delete p.flagReason;
}
await saveAll(kv, providers);
return p;
}

export async function removeProvider(kv: KV, id: string): Promise<boolean> {
const providers = await listProviders(kv);
const next = providers.filter((p) => p.id !== id);
Expand Down
54 changes: 51 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { ChatCompletionBody } from './upstream';
import { getModel, MODELS, DEFAULT_MODEL } from './catalog';
import { getProvider } from './registry';
import * as directory from './directory';
import { rankByStake } from './legion';
import { checkEndpoint, verifyProvider } from './health';
import { REGISTRATION_SCHEMA } from './schema';
import { validateHfModel } from './hf';
Expand All @@ -37,6 +38,17 @@ type Env = {
PRICE_MARKUP?: string;
/** DEV-ONLY: 'true' bypasses payment on non-mainnet (see x402-middleware). */
SKIP_PAYMENT?: string;
/** Legion engagement-stake contract ("ADDR.legion-engage"). When set, the
* gateway reads each community provider's stake from it and ranks
* higher-staked providers first. Optional: staking is not required to earn,
* it only buys ranking. Unset → no stake ranking (registration order). */
LEGION_ENGAGE?: string;
/** Seconds to cache a provider's read stake. Default 60; 0 disables. */
LEGION_STAKE_CACHE_TTL?: string;
/** Shared secret for marketplace enforcement actions (flagging). Sent as the
* X-Admin-Token header. On mainnet the flag endpoint is disabled unless this
* is set; off mainnet it is open for testing. */
ADMIN_TOKEN?: string;
};

/** Where a /v1/chat/completions request was resolved to, decided in the pricing
Expand Down Expand Up @@ -161,8 +173,15 @@ app.get('/', (c) => {
chatCompletions: 'POST /v1/chat/completions (OpenAI-compatible, dynamic price by model)',
chat: 'POST /v1/chat (simple prompt, cheap tier)',
registerProvider: 'POST /v1/providers (Phase 2)',
flagProvider: 'POST /v1/providers/{id}/flag (operator only, admin token)',
feedback: 'POST /v1/feedback (Phase 3, reputation)',
},
trust: {
enforcedBy: 'operator',
model: 'A provider can be flagged (de-routed everywhere, removed from catalog) by the marketplace operator via an admin-token-gated endpoint. This is a manual action; there is no automatic cheat-detection yet. Agents/users can report a provider, but only the operator can flag.',
flaggedStatusPublic: "GET /v1/providers exposes each provider's `flagged` and `flagReason`",
roadmap: 'flag decision moving on-chain to a stake-weighted legion-gov vote',
},
});
});

Expand Down Expand Up @@ -223,7 +242,10 @@ app.get('/v1/models', async (c) => {
// Community models — every live registered provider's declared models, priced
// at their per-token rate. Callable via /v1/chat/completions (model id) or
// the explicit /v1/route/:id proxy; payment settles to the provider directly.
const providers = (await directory.listProviders(c.env.PROVIDERS)).filter((p) => p.status !== 'down');
// Exclude flagged + down providers, then rank by stake so higher-staked
// providers list first (staking buys ranking; it is never required to earn).
const liveProviders = (await directory.listProviders(c.env.PROVIDERS)).filter((p) => p.status !== 'down' && !p.flagged);
const providers = await rankByStake(c.env, liveProviders);
const btcUsd = await getBtcUsd();
const community = [];
for (const p of providers) {
Expand Down Expand Up @@ -297,8 +319,13 @@ app.post('/v1/chat/completions',

// 2) Community model — served by a registered provider. The client pays the
// provider directly (non-custodial) at the provider's declared rate.
// Exclude flagged providers, then pick the highest-staked one for the model
// (staking buys ranking; it is never required to earn).
const providers = await directory.listProviders(c.env.PROVIDERS);
const provider = providers.find((p) => p.status === 'live' && p.models.some((m) => m.id === body.model));
const candidates = providers.filter(
(p) => p.status === 'live' && !p.flagged && p.models.some((m) => m.id === body.model),
);
const provider = (await rankByStake(c.env, candidates))[0];
const model = provider?.models.find((m) => m.id === body.model);
if (!provider || !model) {
return c.json({
Expand Down Expand Up @@ -326,7 +353,7 @@ app.post('/v1/chat/completions',
// Community provider -> forward to their endpoint (already paid to them).
if (route?.kind === 'community') {
const provider = await directory.getProvider(c.env.PROVIDERS, route.providerId);
if (!provider || provider.status === 'down') {
if (!provider || provider.status === 'down' || provider.flagged) {
return c.json({ error: `No live provider for model ${body.model}` }, 503);
}
return routeToProvider(c, provider, body, payment);
Expand Down Expand Up @@ -494,6 +521,27 @@ app.delete('/v1/providers/:id', async (c) => {
return c.json({ removed: ok }, ok ? 200 : 404);
});

// Flag / unflag a provider (marketplace enforcement). A flagged provider is
// de-routed everywhere regardless of health, until cleared. Body:
// { "flagged": true|false, "reason": "..." } (flagged defaults to true)
// Gated by ADMIN_TOKEN (X-Admin-Token header) when set; disabled on mainnet
// unless ADMIN_TOKEN is configured. This is the enforcement lever; it lives in
// the gateway, not the inference plane.
app.post('/v1/providers/:id/flag', async (c) => {
const admin = c.env.ADMIN_TOKEN;
const network = c.env.NETWORK || 'testnet';
if (admin) {
if (c.req.header('X-Admin-Token') !== admin) return c.json({ error: 'unauthorized' }, 401);
} else if (network === 'mainnet') {
return c.json({ error: 'flag endpoint requires ADMIN_TOKEN on mainnet' }, 503);
}
const body = await c.req.json<{ flagged?: boolean; reason?: string }>().catch(() => ({} as { flagged?: boolean; reason?: string }));
const flagged = body.flagged !== false; // default true
const updated = await directory.setFlag(c.env.PROVIDERS, c.req.param('id'), flagged, body.reason);
if (!updated) return c.json({ error: 'Provider not found' }, 404);
return c.json({ provider: updated, flagged });
});


// Test console: proxy a chat completion to a provider's endpoint so anyone can
// try it from the UI (server-side call avoids browser CORS). Auto-discovers the
Expand Down
88 changes: 88 additions & 0 deletions src/legion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Legion engagement-stake reads.
*
* Staking is OPTIONAL and never required to earn — it only buys ranking. The
* gateway reads a community provider's stake from `legion-engage` and ranks
* higher-staked providers first. All reads are best-effort and cached; ranking
* must never break routing, so failures degrade to "unstaked" (stake 0).
*/
import { principalCV, cvToHex, hexToCV, cvToValue } from '@stacks/transactions';

type KV =
| {
get(k: string): Promise<string | null>;
put(k: string, v: string, o?: { expirationTtl?: number }): Promise<void>;
}
| undefined;

function stacksApiBase(env: any): string {
const net = (env.NETWORK || 'testnet') as 'mainnet' | 'testnet';
return env.STACKS_API || (net === 'mainnet' ? 'https://api.hiro.so' : 'https://api.testnet.hiro.so');
}

/**
* A provider's staked sBTC (base units) from `legion-engage`, or 0n if not
* staked / not configured / unreadable. Never throws. Cached in KV for
* LEGION_STAKE_CACHE_TTL seconds.
*/
export async function getProviderStake(env: any, principal: string): Promise<bigint> {
const contract: string | undefined = env.LEGION_ENGAGE; // "ADDR.legion-engage"
if (!contract) return 0n;
const dot = contract.indexOf('.');
if (dot < 1 || dot === contract.length - 1) return 0n;
const addr = contract.slice(0, dot);
const name = contract.slice(dot + 1);

const kv = env.PROVIDERS as KV;
const ttl = Number(env.LEGION_STAKE_CACHE_TTL ?? '60');
const cacheKey = `stake:${principal}`;
if (kv && ttl > 0) {
try {
const cached = await kv.get(cacheKey);
if (cached != null) return BigInt(cached);
} catch {
/* cache is best-effort */
}
}

let stake = 0n;
try {
const r = await fetch(`${stacksApiBase(env)}/v2/contracts/call-read/${addr}/${name}/get-stake`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sender: addr, arguments: [cvToHex(principalCV(principal))] }),
});
if (r.ok) {
const body: any = await r.json();
if (body?.okay && typeof body.result === 'string') {
const v = cvToValue(hexToCV(body.result));
stake = BigInt(v && typeof v === 'object' && 'value' in v ? v.value : v);
}
}
} catch {
/* ranking is best-effort; treat as unstaked */
}

if (kv && ttl > 0) {
try {
await kv.put(cacheKey, stake.toString(), { expirationTtl: ttl });
} catch {
/* best-effort */
}
}
return stake;
}

/**
* Sort providers by on-chain stake (descending). Reads stakes concurrently.
* Returns a NEW array; ties keep input order (stable). No-op when LEGION_ENGAGE
* is unset or there's nothing to sort.
*/
export async function rankByStake<T extends { payoutAddress: string }>(env: any, providers: T[]): Promise<T[]> {
if (!env.LEGION_ENGAGE || providers.length < 2) return providers;
const stakes = await Promise.all(providers.map((p) => getProviderStake(env, p.payoutAddress)));
return providers
.map((p, i) => ({ p, stake: stakes[i], i }))
.sort((a, b) => (b.stake > a.stake ? 1 : b.stake < a.stake ? -1 : a.i - b.i))
.map((x) => x.p);
}
18 changes: 17 additions & 1 deletion src/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,32 @@ the client's chosen token at the live spot rate, so your USD price stays stable.

---

## Enforcement & trust

How bad providers are handled, so you can trust the catalog:

- **Who enforces:** the **marketplace operator**. A provider can be **flagged**, which
de-routes it everywhere and removes it from the catalog. Today this is a **manual operator
action** via an admin-gated endpoint — there is **no automatic cheat-detection yet**, so a
provider is flagged only when the operator acts on a complaint or review.
- **Transparency:** a provider's \`flagged\` status (and \`flagReason\`) is **public** in
\`GET /v1/providers\`. A flagged provider never serves traffic.
- **You can report, not flag:** reporting a suspect provider signals the operator; only the
operator turns a report into a flag. Flagging is **not** a crowd action.
- **Roadmap:** the flag decision is planned to move on-chain to a stake-weighted
\`legion-gov\` vote (the legion decides, not the operator).

## Endpoints
| Method | Path | Cost | Purpose |
|--------|------|------|---------|
| GET | \`/\` | free | Service + payment info |
| GET | \`/skill.md\` | free | This document |
| GET | \`/v1/models\` | free | Live catalog (house + community) |
| GET | \`/v1/providers\` | free | Registered providers + health |
| GET | \`/v1/providers\` | free | Registered providers + health + \`flagged\` status |
| GET | \`/v1/schema\` | free | Provider registration JSON Schema |
| POST | \`/v1/chat/completions\` | **paid** | OpenAI-compatible; price by model + tokens |
| POST | \`/v1/route/{id}/chat/completions\` | **paid** | Call a specific provider; settles to them |
| POST | \`/v1/providers\` | free | Register a provider (verified before listing) |
| POST | \`/v1/providers/{id}/flag\` | free | **Operator only** (admin token): flag/unflag a provider (de-route) |
`;
}
8 changes: 6 additions & 2 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@
// Legion fee-rail settlement (testnet). Non-secret config; UPSTREAM_* +
// (optionally) RECIPIENT_ADDRESS are still set via `wrangler secret put`.
"STACKS_API": "https://api.testnet.hiro.so",
"LEGION_FEES": "STBEMQQVSS3K3SQTF2NRZMF82JHMNTHQKQ2J7DW5.legion-fees",
"LEGION_TREASURY": "STBEMQQVSS3K3SQTF2NRZMF82JHMNTHQKQ2J7DW5.legion-treasury",
// v1 provider-Legion guild bundle (legion-agent-06). Settlement skims 8%
// into this treasury; LEGION_ENGAGE is the optional engagement-stake
// contract the gateway reads to rank higher-staked providers first.
"LEGION_FEES": "STGX5YP51NKM69ZMP6DVB6GAJAANCG5WB3718KD9.legion-fees",
"LEGION_TREASURY": "STGX5YP51NKM69ZMP6DVB6GAJAANCG5WB3718KD9.legion-treasury",
"LEGION_ENGAGE": "STGX5YP51NKM69ZMP6DVB6GAJAANCG5WB3718KD9.legion-engage",
"LEGION_SBTC": "STV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RJ5XDY2.sbtc-token",
"LEGION_MIN_AMOUNT": "1250",
"RECIPIENT_ADDRESS": "ST2VN1G6EBXPMMAJKCSY1HR50YQCVFSK68KKP9SKW"
Expand Down